The report
Pressing Hands-free feels like nothing happened — there is a visible delay before anything changes,
with no indication the tap registered.
Cause: every state flag flips only AFTER the whole startup completes
packages/sdk/src/voice/use-voice.ts:1080 — toggleConvo:
try { getAudioCtx().resume(); } catch {}
unlockSpeechSynthesis(); // prime TTS for iOS/Safari
void ensureTts().then((t) => t.unlock()); // warm the TTS audio context
try {
setPaused(false);
sttRef.current = await makeStt(); // ← may fetch voice config
await sttRef.current.start(); // ← getUserMedia: device open + permission
startAudioMonitor(); // ← AudioContext + analyser
setConvoOn(true); // ← FIRST visible change
setSpeakOn(true);
setMicOn(true);
void acquireWakeLock();
playListeningChime();
} catch { setConvoOn(false); }
setConvoOn / setMicOn / setSpeakOn are the last statements. Everything the UI keys off happens
after two awaits — makeStt() and start() (which is getUserMedia, opening a hardware device
and possibly checking permission). Until those resolve the button is in exactly the state it was
before the tap.
Confirmed: there is no starting / pending / busy state anywhere in the hook. The UI has no
value it could bind a spinner to even if it wanted one.
And the failure path is silent, which is worse than slow
} catch { setConvoOn(false); }
The catch swallows the error and resets to off. If the mic fails to open — device busy, permission
denied, another tab holding it — the user taps, waits, and the control simply stays off. No message,
no error, nothing distinguishing "still starting" from "failed" from "you didn't press it".
There is already classifyVoiceError / micUnavailableMessage for exactly this, used elsewhere in
the hook. This path does not reach them.
What it should do
- Expose a
starting state, set synchronously at the top of toggleConvo and cleared in a
finally. The mode control binds a spinner to it, so the press registers on the same frame.
- Keep the chime where it is — it confirms ready, not pressed, and those are different events.
- On failure, surface it through
micUnavailableMessage rather than resetting silently. A user who
denied permission months ago currently has no way to learn that is the problem.
- Worth measuring which await dominates. If
makeStt() fetches voice config on every entry, that is
cacheable and would cut the delay rather than just papering it with a spinner — the better fix
where it applies.
Verification
- Tapping Hands-free changes the control's appearance within one frame, before the mic is live.
- The spinner clears exactly when listening actually begins (the chime and the spinner agree).
- A denied or busy mic produces a visible message, not a silent revert.
- Tapping again while starting does not open a second
getUserMedia (there is an existing
leaked-stream note at :1260 about ptt→handsfree doing precisely that).
The report
Pressing Hands-free feels like nothing happened — there is a visible delay before anything changes,
with no indication the tap registered.
Cause: every state flag flips only AFTER the whole startup completes
packages/sdk/src/voice/use-voice.ts:1080—toggleConvo:setConvoOn/setMicOn/setSpeakOnare the last statements. Everything the UI keys off happensafter two awaits —
makeStt()andstart()(which isgetUserMedia, opening a hardware deviceand possibly checking permission). Until those resolve the button is in exactly the state it was
before the tap.
Confirmed: there is no
starting/pending/busystate anywhere in the hook. The UI has novalue it could bind a spinner to even if it wanted one.
And the failure path is silent, which is worse than slow
The catch swallows the error and resets to off. If the mic fails to open — device busy, permission
denied, another tab holding it — the user taps, waits, and the control simply stays off. No message,
no error, nothing distinguishing "still starting" from "failed" from "you didn't press it".
There is already
classifyVoiceError/micUnavailableMessagefor exactly this, used elsewhere inthe hook. This path does not reach them.
What it should do
startingstate, set synchronously at the top oftoggleConvoand cleared in afinally. The mode control binds a spinner to it, so the press registers on the same frame.micUnavailableMessagerather than resetting silently. A user whodenied permission months ago currently has no way to learn that is the problem.
makeStt()fetches voice config on every entry, that iscacheable and would cut the delay rather than just papering it with a spinner — the better fix
where it applies.
Verification
getUserMedia(there is an existingleaked-stream note at
:1260about ptt→handsfree doing precisely that).