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
|
(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
@@ -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 =
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user