Skip to content

[bug] Hands-free start has no feedback until the mic is live — and a failed start reverts silently #284

Description

@serge-ivo

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:1080toggleConvo:

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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions