mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
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:
parent
f9d7be82fb
commit
eb8d88f33a
2 changed files with 49 additions and 8 deletions
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue