diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts index 608431d2f7a1..e25a6bfc8ed3 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts @@ -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 | 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(() => { diff --git a/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts b/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts index 58dea1b7fe27..59ba4ee3c8ef 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts @@ -31,6 +31,9 @@ interface VoiceConversationOptions { onTranscribeAudio?: (audio: Blob) => Promise 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 } 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({