Skip to content

[bug] Hands-free gives up after four failed mic restarts and says nothing — no notice, no error-log row, indistinguishable from a crash #387

Description

@serge-ivo

Hands-free can switch itself off, and that is the one path that says nothing

packages/sdk/src/voice/use-voice.ts:1228-1241, the recognizer's onEnd:

const { bail, nextRapidEnds } = decideRestart(Date.now() - lastListenStartRef.current, rapidEndsRef.current);
rapidEndsRef.current = nextRapidEnds;
if (bail) { setConvoOn(false); setMicOn(false); return; }

decideRestart (convo.ts:43) bails after 4 consecutive recognizer ends within 800ms of
starting
— the signature of a failing restart loop (mic blocked, permission revoked mid-session,
another tab or app took the microphone, the OS suspended it). Bailing is correct: the alternative is
a restart loop that pegs the CPU and freezes the page.

What happens next is nothing. No setNotice, no reportClientError, no dictate({type:"failed"}),
no console line. Hands-free simply stops. The user is left looking at a mode toggle that has quietly
flipped back, with no way to tell it apart from a crash — or from the app deciding it was done.

The asymmetry is right there in the same handler

Twenty lines above (:1202-1225), the onError branch does all three:

reportClientError("voice", String(err), { sttWhisper:  });   // durable log
dictate({ type: "failed", note: String(err), at: Date.now() }); // the turn survives, marked failed
flushSync(() => setNotice(`⚠ ${err}`));                        // and the user is told

So an error the recognizer reports is logged, surfaced and self-clearing. A failure it expresses
by dying four times in a row — the more serious of the two, because it ends the whole session rather
than one turn — is silent.

This also means the failure is invisible server-side: list_errors has no row, so "hands-free keeps
dropping out on my phone" cannot be confirmed, counted, or correlated with a browser or a device.

Why it matters more than a missing toast

Every cause on the list is user-fixable, and only if named: grant the mic permission again, close
the other tab that grabbed it, come back to the foreground. Without a message the user's model is
"hands-free is flaky", which is the conclusion #241 was written to prevent in the runner and #376 in
the loop — the same defect shape twice already on this board: the system recorded the state and no
surface read it back.

Suggested fix

  1. On bail: set a notice that says what happened and what to try — the mic became unavailable, check
    permissions / another tab may be using it — and keep it up (this one should not auto-clear after
    4.5s like a transcription error; the session is gone until the user acts).
  2. reportClientError("voice", "handsfree-bail", …) with the rapid-end count and whether the main
    path was Whisper or browser dictation, so the frequency is measurable.
  3. Consider distinguishing "gave up" from "you switched it off" in the UI state, so the toggle
    flipping back reads as a result rather than a glitch.

Related: #386 (the control listener acts on the agent's own voice — another way hands-free ends
without the user asking), #385 (a built-in "stop" exits a session the user never bound it to).
All three produce the same user-visible event — hands-free stopped and I don't know why — from
three unrelated causes, and none of them leaves a trace.

Files: packages/sdk/src/voice/use-voice.ts:1228-1241, packages/sdk/src/voice/convo.ts:43-56
(decideRestart).

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingvoiceVoice / STT / TTS

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions