Merge branch 'main' into shadow/discord-transport

This commit is contained in:
Peter Steinberger 2025-12-16 09:40:22 +00:00 committed by GitHub
commit c853b344bb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 41 additions and 18 deletions

View File

@ -2,7 +2,8 @@
## 2.0.0 — Unreleased
_No changes since 2.0.0-beta1._
### Bug Fixes
- macOS: Voice Wake / push-to-talk no longer initialize `AVAudioEngine` at app launch, preventing Bluetooth headphones from switching into headset profile when voice features are unused. (Thanks @Nachx639)
## 2.0.0-beta1 — 2025-12-14

View File

@ -80,7 +80,9 @@ actor VoicePushToTalk {
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "voicewake.ptt")
private var recognizer: SFSpeechRecognizer?
private var audioEngine = AVAudioEngine()
// Lazily created on begin() to avoid creating an AVAudioEngine at app launch, which can switch Bluetooth
// headphones into the low-quality headset profile even if push-to-talk is never used.
private var audioEngine: AVAudioEngine?
private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest?
private var recognitionTask: SFSpeechRecognitionTask?
private var tapInstalled = false
@ -166,7 +168,7 @@ actor VoicePushToTalk {
// Stop feeding Speech buffers first, then end the request. Stopping the engine here can race with
// Speech draining its converter chain (and we already stop/cancel in finalize).
if self.tapInstalled {
self.audioEngine.inputNode.removeTap(onBus: 0)
self.audioEngine?.inputNode.removeTap(onBus: 0)
self.tapInstalled = false
}
self.recognitionRequest?.endAudio()
@ -201,7 +203,13 @@ actor VoicePushToTalk {
self.recognitionRequest?.shouldReportPartialResults = true
guard let request = self.recognitionRequest else { return }
let input = self.audioEngine.inputNode
// Lazily create the engine here so app launch doesn't grab audio resources / trigger Bluetooth HFP.
if self.audioEngine == nil {
self.audioEngine = AVAudioEngine()
}
guard let audioEngine = self.audioEngine else { return }
let input = audioEngine.inputNode
let format = input.outputFormat(forBus: 0)
if self.tapInstalled {
input.removeTap(onBus: 0)
@ -213,8 +221,8 @@ actor VoicePushToTalk {
}
self.tapInstalled = true
self.audioEngine.prepare()
try self.audioEngine.start()
audioEngine.prepare()
try audioEngine.start()
self.recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, error in
guard let self else { return }
@ -301,13 +309,15 @@ actor VoicePushToTalk {
self.recognitionRequest = nil
self.recognitionTask = nil
if self.tapInstalled {
self.audioEngine.inputNode.removeTap(onBus: 0)
self.audioEngine?.inputNode.removeTap(onBus: 0)
self.tapInstalled = false
}
if self.audioEngine.isRunning {
self.audioEngine.stop()
self.audioEngine.reset()
if self.audioEngine?.isRunning == true {
self.audioEngine?.stop()
self.audioEngine?.reset()
}
// Release the engine so we also release any audio session/resources when push-to-talk ends.
self.audioEngine = nil
self.committed = ""
self.volatile = ""

View File

@ -15,7 +15,9 @@ actor VoiceWakeRuntime {
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "voicewake.runtime")
private var recognizer: SFSpeechRecognizer?
private var audioEngine = AVAudioEngine()
// Lazily created on start to avoid creating an AVAudioEngine at app launch, which can switch Bluetooth
// headphones into the low-quality headset profile even if Voice Wake is disabled.
private var audioEngine: AVAudioEngine?
private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest?
private var recognitionTask: SFSpeechRecognitionTask?
private var recognitionGeneration: Int = 0 // drop stale callbacks after restarts
@ -54,8 +56,10 @@ actor VoiceWakeRuntime {
self.recognitionTask = nil
self.recognitionRequest?.endAudio()
self.recognitionRequest = nil
self.audioEngine.inputNode.removeTap(onBus: 0)
self.audioEngine.stop()
self.audioEngine?.inputNode.removeTap(onBus: 0)
self.audioEngine?.stop()
// Release the engine so we also release any audio session/resources when Voice Wake is idle.
self.audioEngine = nil
}
struct RuntimeConfig: Equatable {
@ -115,7 +119,13 @@ actor VoiceWakeRuntime {
self.recognitionRequest?.shouldReportPartialResults = true
guard let request = self.recognitionRequest else { return }
let input = self.audioEngine.inputNode
// Lazily create the engine here so app launch doesn't grab audio resources / trigger Bluetooth HFP.
if self.audioEngine == nil {
self.audioEngine = AVAudioEngine()
}
guard let audioEngine = self.audioEngine else { return }
let input = audioEngine.inputNode
let format = input.outputFormat(forBus: 0)
input.removeTap(onBus: 0)
input.installTap(onBus: 0, bufferSize: 2048, format: format) { [weak self, weak request] buffer, _ in
@ -127,8 +137,8 @@ actor VoiceWakeRuntime {
}
}
self.audioEngine.prepare()
try self.audioEngine.start()
audioEngine.prepare()
try audioEngine.start()
self.currentConfig = config
self.lastHeard = Date()
@ -168,8 +178,10 @@ actor VoiceWakeRuntime {
self.recognitionTask = nil
self.recognitionRequest?.endAudio()
self.recognitionRequest = nil
self.audioEngine.inputNode.removeTap(onBus: 0)
self.audioEngine.stop()
self.audioEngine?.inputNode.removeTap(onBus: 0)
self.audioEngine?.stop()
// Release the engine so we also release any audio session/resources when Voice Wake is disabled/stopped.
self.audioEngine = nil
self.currentConfig = nil
self.listeningState = .idle
self.logger.debug("voicewake runtime stopped")