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
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).
+27 -1
View File
@@ -889,8 +889,34 @@ export function App() {
try {
const mic = await startMicCapture((chunk) => transcriber.push(chunk));
liveRef.current = { mic, transcriber };
const session = { mic, transcriber };
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) {
setLiveStatus("idle");
const message =
+6 -2
View File
@@ -1,6 +1,6 @@
/** 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 {
status: LiveStatus;
@@ -13,6 +13,7 @@ interface LiveControlsProps {
const LABELS: Record<LiveStatus, string> = {
idle: "Start live transcription",
starting: "Requesting microphone…",
"loading-model": "Loading model…",
listening: "Stop",
stopping: "Finishing…",
};
@@ -31,7 +32,9 @@ export function LiveControls({
type="button"
className={`live-button${listening ? " live-button-active" : ""}`}
onClick={status === "idle" ? onStart : onStop}
disabled={disabled || status === "starting" || status === "stopping"}
disabled={
disabled || status === "starting" || status === "loading-model" || status === "stopping"
}
aria-pressed={listening}
>
{LABELS[status]}
@@ -46,6 +49,7 @@ export function LiveControls({
whoever triggered it. */}
<span className="visually-hidden" role="status">
{status === "starting" && "Requesting microphone access"}
{status === "loading-model" && "Loading model"}
{status === "listening" && "Live transcription started"}
{status === "stopping" && "Finishing live transcription"}
</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
to idle and the error "Microphone access was denied…" must appear; the app
stays usable (file flow unaffected).
3. Click again and **allow**: button becomes **Stop** with a pulsing red dot;
the browser's mic indicator turns on. If the model wasn't downloaded yet,
the download progress panel appears and live text simply starts once it is
ready (segment passes queue in the worker).
3. Click again and **allow**: the browser's mic indicator turns on immediately.
If the model was already loaded, the button becomes **Stop** with a pulsing
red dot right away. If not (first use, or a model/device switch), the
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