Don't show the live "recording"/listening indicator until the model is ready

startLive()'s browser-engine branch flipped liveStatus to "listening" (the
red live-dot, "Listening..." hint) the instant the mic was granted, even if
the Whisper pipeline was still loading — the user saw an active-looking
session while nothing was being transcribed yet. transcribeLiveSegment()
already correctly awaits the model-ready gate before posting a segment, so
this was purely a misleading-status bug.

Adds a "loading-model" LiveStatus between "starting" and "listening";
startLive() now waits on the same browserModelReady() gate the transcribe
path uses before claiming "listening", with race-safe cleanup if the load
fails while waiting. Server-engine live is unaffected.

Adds Decisions.md D-034; updates tests/live.md's manual verification steps.
This commit is contained in:
Lucas Winther committed 2026-07-30 08:31:23 +02:00
1 parent c5d193600a
commit 500cf3b36e
4 files changed
+116 -8

No files matched your search

+75
View File
@@ -958,3 +958,78 @@ already-cached one.
(e.g. for the storage-management panel's exact byte accounting), reuse it
here too so a genuinely partial cache falls back to the "Downloading" copy
instead of "Loading from cache."
## D-034 — Browser-engine live status no longer claims "Listening" before the model is ready
**Status:** Accepted (2026-07-30)
**Symptom:** On the browser engine (not `useServer`), clicking "Start live
transcription" showed the full "recording" UI — the pulsing red `live-dot`
and the "Listening — like everything else, your audio stays on this device."
hint (`src/components/LiveControls.tsx`) — as soon as microphone permission
was granted, even when the model still had to load (first use of a model, or
a model/device switch mid-session that reset the pipeline). The user saw what
looked like an active live session while nothing was actually being
transcribed yet.
**Cause:** in `startLive()`'s browser-engine branch (`src/App.tsx`), the
`await startMicCapture(...)` call was immediately followed by
`setLiveStatus("listening")` with no check on whether the pipeline had
actually reported `ready`. The model load itself was already correctly
kicked off just above (idempotent `startLoad`/`load` postMessage, D-020) and
`transcribeLiveSegment()` already correctly `await`s `browserModelReady()`
(the promise on `loadGate.current`, resolved by the worker's `{status:
"ready"}` message or rejected on a load-stage error/cancel, D-025) before
posting any segment — so no segments were ever lost or misrouted to a wrong
pipeline. This was a pure UI/status bug, not a data-correctness one. The
server-engine (`useServer`) branch never loads a browser pipeline, so it was
never affected and is untouched by this fix.
**Fix:**
1. `LiveStatus` (`src/components/LiveControls.tsx`) gains a `"loading-model"`
member between `"starting"` and `"listening"`, labeled "Loading model…".
It disables the Start/Stop button exactly like `"starting"`/`"stopping"`
do, and — because only `status === "listening"` sets the component's
`listening` boolean — it renders neither the `live-dot` nor the
"Listening — …" hint (falls into the same "not listening yet" branch as
`"idle"`/`"starting"`/`"stopping"`).
2. In `startLive()`, once `startMicCapture` resolves and `liveRef.current` is
set: if `loadProgress.ready` is already `true` (pipeline loaded earlier
this session, no model/device switch pending), status goes straight to
`"listening"` as before. Otherwise status becomes `"loading-model"` and the
code `await`s `browserModelReady()`:
- On resolve, it re-checks `liveRef.current === session` (the user could
have hit Stop, or started a fresh session, while the wait was pending)
before flipping to `"listening"` — never resurrects a torn-down session.
- On reject (load failed), it stops the mic and clears `liveRef.current`
(same re-check) and returns status to `"idle"`, **without** calling
`setError()` itself — the worker's own `"error"` message handler already
calls `setError()` for `stage === "load"` failures (`src/App.tsx`
~line 380), so a second call here would have shown two error messages
for one failure.
3. `stopLive()` needed no change: its existing guard
(`if (!session || liveStatus !== "listening") return;`) already makes it a
no-op during `"loading-model"`, exactly mirroring how it already no-ops
during `"starting"` — consistent with the Stop button being disabled for
both statuses per (1).
**Consequence:** live-dot/hint/button now truthfully reflect whether audio is
actually reaching a ready pipeline. The only added latency is the same wait
`transcribeLiveSegment` was already imposing on the first segment pass — nothing
downstream changed. `tests/live.md`'s permission-flow step was updated to
describe the new "Loading model…" interim state instead of the old
immediate-"Stop" behavior.
**Alternatives rejected:** gating on `loadGate.current === null` instead of
`loadProgress.ready` — `loadGate.current` is only nulled out on an explicit
model/device-switch reset or load cancel, not after a successful `ready`, so
it stays non-null (holding an already-resolved promise) for the rest of the
session; checking it would have made every subsequent live start take a
detour through `"loading-model"` for a promise that resolves on the same
tick, an unnecessary (if harmless) status flash. `loadProgress.ready` is the
same signal `showModelProgress`/`modelReady` already use elsewhere in
`App.tsx` and tracks exactly what the fix needs: has *this* model/device
selection reported ready yet.
**Testing:** no unit test added — `LiveControls` has no existing
component-level test file to extend, and the state machine this fix changes
(`startLive()`'s wait/race handling around a real `Worker`, `MediaStream`, and
DOM) has no natural seam for a `bun test` unit test without mocking most of
the worker/mic stack, which the project's live tests (`tests/live.test.ts`,
`tests/live-accuracy.test.ts`) deliberately avoid — they test the pure
segment/scheduling logic in `src/lib/live.ts` instead. Manual verification
via `tests/live.md` remains the intended coverage for this UI path, and its
permission-flow step was updated accordingly (see Consequence above).