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 committed 2026-07-30 08:31:23 +02:00
1 parent c5d193600a
commit 500cf3b36e
4 files changed
+116 -8

No files matched your search

+28 -2
View File
@@ -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 =
+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>