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:
@@ -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).
|
||||
|
||||
+28
-2
@@ -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 =
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user