Spaces:
Sleeping
fix(voice): KI-173 — heartbeat watchdog revives stalled SpeechRecognition
Browse filesUser 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>
|
@@ -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 () => {
|