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:
1 parent
c5d193600a
commit
500cf3b36e
4 files changed
+116
-8
No files matched your search
@@ -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).
|
||||
Reference in new issue
Block a user