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
2026-07-30 08:31:23 +02:00
parent c5d193600a
commit 500cf3b36e
4 changed files with 116 additions and 8 deletions
+75
View File
@@ -958,3 +958,78 @@ already-cached one.
(e.g. for the storage-management panel's exact byte accounting), reuse it (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 here too so a genuinely partial cache falls back to the "Downloading" copy
instead of "Loading from cache." 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).
+28 -2
View File
@@ -889,8 +889,34 @@ export function App() {
try { try {
const mic = await startMicCapture((chunk) => transcriber.push(chunk)); const mic = await startMicCapture((chunk) => transcriber.push(chunk));
liveRef.current = { mic, transcriber }; const session = { mic, transcriber };
setLiveStatus("listening"); liveRef.current = session;
if (loadProgress.ready) {
// Pipeline already loaded for the current model/device — nothing to
// wait on, go straight to "listening" as before.
setLiveStatus("listening");
} else {
// Model not ready yet (first use, or a model/device switch): don't
// claim "Listening" until the pipeline actually is one, otherwise the
// live-dot/hint imply an active session while nothing is being
// transcribed (D-034). `transcribeLiveSegment` already awaits the
// same gate before posting, so no segments are lost either way.
setLiveStatus("loading-model");
try {
await browserModelReady();
// The session could have been torn down (Stop, or another start)
// while we were waiting — only advance if it's still this one.
if (liveRef.current === session) setLiveStatus("listening");
} catch {
// Load failed; the worker's own "error" handler already calls
// setError() for stage:"load" failures, so don't duplicate it here.
if (liveRef.current === session) {
liveRef.current = null;
await mic.stop().catch(() => {});
setLiveStatus("idle");
}
}
}
} catch (micError) { } catch (micError) {
setLiveStatus("idle"); setLiveStatus("idle");
const message = const message =
+6 -2
View File
@@ -1,6 +1,6 @@
/** Live microphone transcription start/stop (M7, Decisions.md D-016). */ /** Live microphone transcription start/stop (M7, Decisions.md D-016). */
export type LiveStatus = "idle" | "starting" | "listening" | "stopping"; export type LiveStatus = "idle" | "starting" | "loading-model" | "listening" | "stopping";
interface LiveControlsProps { interface LiveControlsProps {
status: LiveStatus; status: LiveStatus;
@@ -13,6 +13,7 @@ interface LiveControlsProps {
const LABELS: Record<LiveStatus, string> = { const LABELS: Record<LiveStatus, string> = {
idle: "Start live transcription", idle: "Start live transcription",
starting: "Requesting microphone…", starting: "Requesting microphone…",
"loading-model": "Loading model…",
listening: "Stop", listening: "Stop",
stopping: "Finishing…", stopping: "Finishing…",
}; };
@@ -31,7 +32,9 @@ export function LiveControls({
type="button" type="button"
className={`live-button${listening ? " live-button-active" : ""}`} className={`live-button${listening ? " live-button-active" : ""}`}
onClick={status === "idle" ? onStart : onStop} onClick={status === "idle" ? onStart : onStop}
disabled={disabled || status === "starting" || status === "stopping"} disabled={
disabled || status === "starting" || status === "loading-model" || status === "stopping"
}
aria-pressed={listening} aria-pressed={listening}
> >
{LABELS[status]} {LABELS[status]}
@@ -46,6 +49,7 @@ export function LiveControls({
whoever triggered it. */} whoever triggered it. */}
<span className="visually-hidden" role="status"> <span className="visually-hidden" role="status">
{status === "starting" && "Requesting microphone access"} {status === "starting" && "Requesting microphone access"}
{status === "loading-model" && "Loading model"}
{status === "listening" && "Live transcription started"} {status === "listening" && "Live transcription started"}
{status === "stopping" && "Finishing live transcription"} {status === "stopping" && "Finishing live transcription"}
</span> </span>
+7 -4
View File
@@ -43,10 +43,13 @@ Re-run it after any change to `src/lib/live.ts`, `src/lib/mic.ts`,
2. The browser prompts for the microphone. **Deny** it: the button must return 2. The browser prompts for the microphone. **Deny** it: the button must return
to idle and the error "Microphone access was denied…" must appear; the app to idle and the error "Microphone access was denied…" must appear; the app
stays usable (file flow unaffected). stays usable (file flow unaffected).
3. Click again and **allow**: button becomes **Stop** with a pulsing red dot; 3. Click again and **allow**: the browser's mic indicator turns on immediately.
the browser's mic indicator turns on. If the model wasn't downloaded yet, If the model was already loaded, the button becomes **Stop** with a pulsing
the download progress panel appears and live text simply starts once it is red dot right away. If not (first use, or a model/device switch), the
ready (segment passes queue in the worker). button instead shows **"Loading model…"** (disabled, no live dot, no
"Listening…" hint) while the download/progress panel appears, and only
flips to **Stop**/pulsing dot once the pipeline actually reports ready
(D-034) — it must never claim "Listening" before that.
## 2. Live transcription behavior ## 2. Live transcription behavior