rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
f84acc7
·
1 Parent(s): 9232416

fix(voice): KI-173 — heartbeat watchdog revives stalled SpeechRecognition

Browse files

User report (verbatim): "voice stopped taking input suddenly until I
turned it off and on again."

Root cause: browser Web Speech API's SpeechRecognition occasionally
enters a stopped state WITHOUT firing the `onend` event. Known
triggers include certain network errors, transient OS audio
interruptions, and tab-visibility transitions. The KI-168 auto-restart
loop lives inside `onend`, so when `onend` doesn't fire, the restart
never runs, and the mic stays silently dead until the user toggles
voice off+on.

Fix: added a 4s heartbeat effect inside useStreamingVoice. While
`enabled` is true and no text turn is racing and no restart is
already scheduled, call `safeStart()` unconditionally. If recognition
is already running, the InvalidStateError is swallowed (per safeStart's
existing catch). If it's dead, this revives it within ~4s.

KI-165 race guard preserved (isTextRequestPendingRef check) so the
heartbeat doesn't fight the text-in-flight path.

Verification: `npx tsc --noEmit` clean (exit 0).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

frontend/src/lib/useStreamingVoice.ts CHANGED
@@ -516,6 +516,29 @@ export function useStreamingVoice(
516
  // eslint-disable-next-line react-hooks/exhaustive-deps
517
  }, [enabled]);
518
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
519
  // Unmount cleanup.
520
  useEffect(() => {
521
  return () => {
 
516
  // eslint-disable-next-line react-hooks/exhaustive-deps
517
  }, [enabled]);
518
 
519
+ // KI-173 (2026-05-15) — heartbeat watchdog. Browser SpeechRecognition
520
+ // occasionally enters a stopped state without `onend` firing (certain
521
+ // network errors, transient OS audio interruptions, tab visibility
522
+ // edge cases). The auto-restart in `onend` never gets the chance to
523
+ // run, and the mic stays silently dead until the user toggles voice
524
+ // off+on. Every 4s, if we WANT to be listening (enabled + wantRunningRef)
525
+ // and no text turn is racing and no restart is already scheduled, call
526
+ // `safeStart()` unconditionally — InvalidStateError is swallowed if
527
+ // recognition is already running, otherwise this revives the dead state.
528
+ useEffect(() => {
529
+ if (!enabled || !isSupported) return;
530
+ const tick = setInterval(() => {
531
+ if (
532
+ wantRunningRef.current
533
+ && !isTextRequestPendingRef.current
534
+ && restartTimerRef.current === null
535
+ ) {
536
+ safeStart();
537
+ }
538
+ }, 4000);
539
+ return () => clearInterval(tick);
540
+ }, [enabled, isSupported, isTextRequestPendingRef, safeStart]);
541
+
542
  // Unmount cleanup.
543
  useEffect(() => {
544
  return () => {