diff --git a/Sources/RemoteBuddy/Audio/AudioOutput.swift b/Sources/RemoteBuddy/Audio/AudioOutput.swift index 8d4a5e0..4162d44 100644 --- a/Sources/RemoteBuddy/Audio/AudioOutput.swift +++ b/Sources/RemoteBuddy/Audio/AudioOutput.swift @@ -229,6 +229,7 @@ final class AudioOutput { private func scheduleRecovery() { guard enabled else { return } + logger.notice("Audio configuration changed running=\(self.engine?.isRunning == true) currentDevice=\(self.currentDevice()) configuredDevice=\(self.configuredDevice)") recoveryWork?.cancel() let work = DispatchWorkItem { [weak self] in self?.recover() } recoveryWork = work diff --git a/Sources/RemoteBuddy/Bluetooth/BLEController.swift b/Sources/RemoteBuddy/Bluetooth/BLEController.swift index e1e4570..9e2c714 100644 --- a/Sources/RemoteBuddy/Bluetooth/BLEController.swift +++ b/Sources/RemoteBuddy/Bluetooth/BLEController.swift @@ -192,6 +192,7 @@ final class BLEController: NSObject, CBCentralManagerDelegate, CBPeripheralDeleg if characteristic.uuid == audioUUID { receiveAudio(data) } else if characteristic.uuid == control || characteristic.uuid == command { + audioDiagnostics.notice("ATVV control=\(data.map { String(format: "%02X", $0) }.joined(separator: " "), privacy: .public)") handle(session.parseControl(data)) } } @@ -227,7 +228,10 @@ final class BLEController: NSObject, CBCentralManagerDelegate, CBPeripheralDeleg } onStatus?(L10n.tr("已就绪 · 按住语音键说话")) case .startSearch: - guard !shortcutsSuspended else { return } + // Firmware 26.2 sends START_SEARCH immediately after the HTT + // AUDIO_START for the same physical press. Keep that gesture until + // AUDIO_STOP/release so it can become a hold or reopen on a tap. + guard !shortcutsSuspended, !voiceGesture.isPressed else { return } handleTap() case .audioSync(let codec, let sequence, let predictor, let stepIndex): audioDiagnostics.notice("Voice sync codec=\(codec.rawValue) sourceRate=\(codec.sampleRate) sequence=\(sequence)") @@ -272,6 +276,9 @@ final class BLEController: NSObject, CBCentralManagerDelegate, CBPeripheralDeleg onStreaming?(true) onStatus?(shortcutsSuspended ? L10n.tr("按键设置:正在识别语音键") : L10n.tr("正在传输遥控器麦克风")) case .audioStop(let reason): + // MIC_CLOSE can produce another AUDIO_STOP even when already idle. + // Ignore duplicate acknowledgements instead of starting a close loop. + guard streaming || voiceGesture.isPressed || voiceGesture.toggleActive else { return } let frames = streamFrameCount let peak = streamPeak audioDiagnostics.notice("Voice stop reason=\(reason) packets=\(self.receivedAudioPackets) decodedFrames=\(frames) peak=\(peak) suspended=\(self.shortcutsSuspended) \(self.audio.diagnosticSummary, privacy: .public)") @@ -433,7 +440,9 @@ final class BLEController: NSObject, CBCentralManagerDelegate, CBPeripheralDeleg cancelPendingWork() _ = voiceGesture.reset() } else { - resetVoice() + // The remote has already stopped. Sending MIC_CLOSE here makes its + // acknowledgement trigger another reset/close indefinitely. + resetVoice(closeMicrophone: false) } } diff --git a/Tests/RemoteBuddyTests/VoiceRecoveryTests.swift b/Tests/RemoteBuddyTests/VoiceRecoveryTests.swift index ddb9742..8479476 100644 --- a/Tests/RemoteBuddyTests/VoiceRecoveryTests.swift +++ b/Tests/RemoteBuddyTests/VoiceRecoveryTests.swift @@ -193,6 +193,100 @@ final class VoiceRecoveryTests: XCTestCase { ble.stop() } + func testRemoteStopDoesNotEchoCloseAndNextHoldStillWorks() { + let audio = AudioOutput() + let keyboard = Keyboard() + var commands: [Data] = [] + let ble = controller(audio, keyboard) { commands.append($0) } + ble.handle(.audioStart(reason: 3, codec: .adpcm16k, streamID: 1)) + ble.handle(.audioStop(reason: 0)) + XCTAssertTrue(commands.isEmpty) + for _ in 0..<10 { ble.handle(.audioStop(reason: 0)) } + XCTAssertTrue(commands.isEmpty) + XCTAssertEqual(keyboard.taps, 0) + + ble.handle(.audioStart(reason: 3, codec: .adpcm16k, streamID: 2)) + let held = expectation(description: "next hold survives stop acknowledgements") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { held.fulfill() } + wait(for: [held], timeout: 1.5) + XCTAssertEqual(keyboard.holds, [true]) + ble.handle(.audioStop(reason: 0)) + XCTAssertEqual(keyboard.holds, [true, false]) + XCTAssertTrue(commands.isEmpty) + ble.stop() + } + + func testHTTSearchCompanionPreservesHoldUntilRelease() { + let audio = AudioOutput() + let keyboard = Keyboard() + var commands: [Data] = [] + let ble = controller(audio, keyboard) { commands.append($0) } + ble.handle(.audioStart(reason: 3, codec: .adpcm16k, streamID: 4)) + ble.handle(.startSearch) + XCTAssertEqual(keyboard.taps, 0) + let held = expectation(description: "HTT still becomes a hold") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { held.fulfill() } + wait(for: [held], timeout: 1.5) + XCTAssertEqual(keyboard.holds, [true]) + XCTAssertTrue(commands.isEmpty) + ble.handle(.audioStop(reason: 2)) + ble.checkVoiceHealth() + XCTAssertEqual(keyboard.holds, [true, false]) + XCTAssertEqual(keyboard.taps, 0) + ble.stop() + } + + func testHTTSearchCompanionReopensTapAndRecordsPastTimeout() { + let audio = AudioOutput() + let keyboard = Keyboard() + var commands: [Data] = [] + let ble = controller(audio, keyboard) { commands.append($0) } + ble.handle(.audioStart(reason: 3, codec: .adpcm16k, streamID: 5)) + ble.handle(.startSearch) + ble.handle(.audioStop(reason: 2)) + XCTAssertEqual(keyboard.taps, 1) + let reopened = expectation(description: "release requests persistent microphone") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { reopened.fulfill() } + wait(for: [reopened], timeout: 1) + XCTAssertEqual(commands, [Data([0x0c, 0x00])]) + ble.handle(.audioStart(reason: 0, codec: .adpcm16k, streamID: 0)) + let now = ProcessInfo.processInfo.systemUptime + for second in 0..<12 { + ble.receiveAudio(Data([0x00]), at: now + Double(second)) + ble.checkVoiceHealth(now: now + Double(second) + 0.5) + } + XCTAssertEqual(keyboard.taps, 1) + // The companion event on the next press must not start a new toggle. + ble.handle(.audioStart(reason: 3, codec: .adpcm16k, streamID: 6)) + ble.handle(.startSearch) + ble.handle(.audioStop(reason: 2)) + ble.checkVoiceHealth(now: ProcessInfo.processInfo.systemUptime + 2.1) + XCTAssertEqual(keyboard.taps, 2) + XCTAssertEqual(keyboard.holds, []) + ble.stop() + XCTAssertEqual(keyboard.taps, 2) + } + + func testDuplicateStopPreservesBufferedTailAndDoesNotSendClose() { + let audio = AudioOutput() + let keyboard = Keyboard() + var commands: [Data] = [] + let ble = controller(audio, keyboard) { commands.append($0) } + ble.handle(.audioStart(reason: 3, codec: .adpcm16k, streamID: 1)) + audio.feed([123], sampleRate: 16000) + ble.handle(.audioStop(reason: 2)) + ble.handle(.audioStart(reason: 0, codec: .adpcm16k, streamID: 0)) + ble.handle(.startSearch) + let closeCount = commands.count + for _ in 0..<10 { ble.handle(.audioStop(reason: 0)) } + XCTAssertEqual(commands.count, closeCount) + XCTAssertEqual(audio.queuedSamples, 1) + XCTAssertEqual(keyboard.taps, 1) + ble.checkVoiceHealth(now: ProcessInfo.processInfo.systemUptime + 2.1) + XCTAssertEqual(keyboard.taps, 2) + ble.stop() + } + func testTransportWatchdogUsesArrivalNotVolumeAndInvalidatesTasks() { var watchdog = VoiceSessionWatchdog() let generation = watchdog.generation