fix(desktop): await wake-word mic release before opening the voice-chat mic

'Clicked voice chat but it never starts listening' — a mic-device
contention race. Starting a voice conversation fires wake.pause (to free
the mic from the wake-word listener) and opens the conversation's own mic
in two separate effects, both keyed on voiceConversationActive with no
ordering between them. wake.pause was fire-and-forget, so getUserMedia
often raced the wake listener's stream teardown (which joins a reader
thread + closes the device in a finally). On Windows the capture device is
effectively single-owner, so opening it while wake still held it failed and
the conversation never started listening.

Wake-word-initiated starts don't hit this: the backend's _on_detect calls
pause_listening synchronously before emitting wake.detected, so the mic is
already free by the time the frontend opens it. Only the button/hotkey path
raced — matching the report.

Fix: pauseWakeForVoice now returns an awaitable barrier for the in-flight
wake.pause round-trip, and useVoiceConversation awaits a new beforeMicOpen
hook (wired to that barrier) right before handle.start(), re-checking
enabled/muted/busy/idle after the wait. No behavior change when the wake
word isn't running (barrier is null → no wait).

tsc + eslint clean, 39 desktop voice/wake tests green.
This commit is contained in:
Teknium 2026-07-28 12:16:54 -07:00
parent f9d7be82fb
commit eb8d88f33a
No known key found for this signature in database
2 changed files with 49 additions and 8 deletions

View file

@ -116,6 +116,14 @@ export function useComposerVoice({
await onSubmit(text)
}
const wakePausedRef = useRef(false)
// Resolves once the in-flight wake.pause round-trip completes (mic released by
// the wake listener). The conversation awaits this before opening its own mic
// so the two never contend for the device — on Windows especially, opening the
// capture device while the wake listener still holds it makes getUserMedia
// fail and the conversation never starts listening.
const wakePauseBarrierRef = useRef<Promise<void> | null>(null)
const conversation = useVoiceConversation({
busy,
consumePendingResponse,
@ -128,7 +136,10 @@ export function useComposerVoice({
onStopWord: () => setVoiceConversationActive(false),
onSubmit: submitVoiceTurn,
onTranscribeAudio,
pendingResponse: pendingTurnResponse
pendingResponse: pendingTurnResponse,
// Before the conversation opens the mic, wait for any in-flight wake.pause
// to finish releasing the capture device (see wakePauseBarrierRef).
beforeMicOpen: () => wakePauseBarrierRef.current ?? undefined
})
// The `composer.voice` hotkey (Ctrl+B) toggles the conversation. Starting
@ -158,14 +169,13 @@ export function useComposerVoice({
}
}, [disabled, target, voiceConversationActive, voiceStartRequest])
const wakePausedRef = useRef(false)
const resumeWakeIfPaused = useCallback(() => {
if (!wakePausedRef.current) {
return
}
wakePausedRef.current = false
wakePauseBarrierRef.current = null
// Reconcile, don't just resume: the wake word is a persistent setting, so
// ending a voice chat must re-arm the listener whenever config says
// enabled — including when the raw resume loses the mic-release race.
@ -176,10 +186,16 @@ export function useComposerVoice({
// it guards resumeWakeIfPaused from resuming a detector another surface owns.
const pauseWakeForVoice = useCallback(() => {
wakePausedRef.current = true
void $gateway
.get()
?.request('wake.pause', {})
.catch(() => undefined)
const barrier = (async () => {
try {
await $gateway.get()?.request('wake.pause', {})
} catch {
// No wake listener / older backend — nothing held the mic.
}
})()
wakePauseBarrierRef.current = barrier
return barrier
}, [])
useEffect(() => {

View file

@ -31,6 +31,9 @@ interface VoiceConversationOptions {
onTranscribeAudio?: (audio: Blob) => Promise<string>
pendingResponse: () => PendingVoiceResponse | null
consumePendingResponse: () => void
/** Awaited right before the mic is opened. Used to let the wake-word listener
* fully release the capture device first, so the two never contend. */
beforeMicOpen?: () => Promise<void> | void
}
export function useVoiceConversation({
@ -41,7 +44,8 @@ export function useVoiceConversation({
onSubmit,
onTranscribeAudio,
pendingResponse,
consumePendingResponse
consumePendingResponse,
beforeMicOpen
}: VoiceConversationOptions) {
const { t } = useI18n()
const voiceCopy = t.notifications.voice
@ -69,6 +73,13 @@ export function useVoiceConversation({
onStopWordRef.current = onStopWord
}, [onStopWord])
const beforeMicOpenRef = useRef(beforeMicOpen)
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
beforeMicOpenRef.current = beforeMicOpen
}, [beforeMicOpen])
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
enabledRef.current = enabled
@ -188,6 +199,20 @@ export function useVoiceConversation({
return
}
// Let the wake-word listener fully release the capture device before we
// open ours — opening the mic while wake still holds it makes getUserMedia
// fail (the "clicked voice but it never starts listening" bug).
try {
await beforeMicOpenRef.current?.()
} catch {
// A pause failure shouldn't block the user's explicit start.
}
// enabled/muted/busy or an interleaved turn may have changed while we waited.
if (!enabledRef.current || mutedRef.current || busyRef.current || statusRef.current !== 'idle') {
return
}
try {
// VAD tuning mirrors `tools.voice_mode` defaults so the browser loop matches the CLI.
await handle.start({