Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Sources/RemoteBuddy/Audio/AudioOutput.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 11 additions & 2 deletions Sources/RemoteBuddy/Bluetooth/BLEController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
Expand Down Expand Up @@ -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)")
Expand Down Expand Up @@ -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)")
Expand Down Expand Up @@ -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)
}
}

Expand Down
94 changes: 94 additions & 0 deletions Tests/RemoteBuddyTests/VoiceRecoveryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down