diff --git a/docs/Decisions.md b/docs/Decisions.md index a4bb677..199b036 100644 --- a/docs/Decisions.md +++ b/docs/Decisions.md @@ -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). diff --git a/src/App.tsx b/src/App.tsx index 8570064..89370b8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -889,8 +889,34 @@ export function App() { try { const mic = await startMicCapture((chunk) => transcriber.push(chunk)); - liveRef.current = { mic, transcriber }; - setLiveStatus("listening"); + 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 = diff --git a/src/components/LiveControls.tsx b/src/components/LiveControls.tsx index 1e1edfc..249e345 100644 --- a/src/components/LiveControls.tsx +++ b/src/components/LiveControls.tsx @@ -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 = { 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. */} {status === "starting" && "Requesting microphone access"} + {status === "loading-model" && "Loading model"} {status === "listening" && "Live transcription started"} {status === "stopping" && "Finishing live transcription"} diff --git a/tests/live.md b/tests/live.md index 5d47577..61d3de6 100644 --- a/tests/live.md +++ b/tests/live.md @@ -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