diff --git a/src/App.tsx b/src/App.tsx index 6170aee..f0f8182 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -685,34 +685,68 @@ export function App() { if (options.task) wsUrl.searchParams.set("task", options.task); const ws = new WebSocket(wsUrl); - + + // Ends the session locally: stops the mic (releasing the hardware and + // the browser's recording indicator — nothing else holds this + // MediaStream) and clears the refs so a stale onclose/onerror firing + // later is a no-op (guarded by `liveWsRef.current === ws` below). + const endSession = (message: string | null) => { + liveWsRef.current = null; + const session = liveRef.current; + liveRef.current = null; + void session?.mic.stop().catch(() => {}); + setLiveCommitted(""); + setLiveTentative(""); + setLiveStatus("idle"); + if (message) setError(message); + }; + ws.onmessage = (event) => { - const data = JSON.parse(event.data); + let data: { type: string; [key: string]: unknown }; + try { + data = JSON.parse(event.data); + } catch { + // Not a frame we understand — ignore rather than crash the handler + // and strand the session in "listening"/"stopping" forever. + return; + } if (data.type === "update") { - setLiveCommitted(data.update.committedText); - setLiveTentative(data.update.tentativeText); - setLivePending(data.update.pendingFinalPasses ?? 0); - if (data.update.error) setError(data.update.error); + const update = data.update as { + committedText: string; + tentativeText: string; + pendingFinalPasses?: number; + error?: string; + }; + setLiveCommitted(update.committedText); + setLiveTentative(update.tentativeText); + setLivePending(update.pendingFinalPasses ?? 0); + if (update.error) setError(update.error); } else if (data.type === "result") { - setResult(data.result); - setLiveCommitted(""); - setLiveTentative(""); - setLiveStatus("idle"); - liveWsRef.current = null; - liveRef.current = null; + setResult(data.result as TranscriptResult); + endSession(null); } else if (data.type === "error") { - setError(data.error); - setLiveStatus("idle"); - liveWsRef.current = null; - liveRef.current = null; + endSession(typeof data.error === "string" ? data.error : "Live transcription failed."); } }; ws.onerror = () => { - setError("WebSocket connection error."); - setLiveStatus("idle"); - liveWsRef.current = null; - liveRef.current = null; + // A genuine connection failure also fires "close" right after, which + // does the actual cleanup — this only needs to supply the message + // (onclose can't distinguish a clean vs. abrupt closure on its own). + if (liveWsRef.current === ws) { + setError( + "Lost connection to the server during live transcription. The microphone has been stopped.", + ); + } + }; + + ws.onclose = () => { + // Fires after a normal finish too (onmessage already nulled the ref + // there), so this only acts on an *unexpected* disconnect — otherwise + // stop() would hang forever waiting for a "result" that never comes, + // and the mic would stay open with no code left holding a reference + // to it. + if (liveWsRef.current === ws) endSession(null); }; ws.onopen = async () => { @@ -796,7 +830,19 @@ export function App() { await session.mic.stop(); if (liveWsRef.current) { - liveWsRef.current.send(JSON.stringify({ type: "stop" })); + if (liveWsRef.current.readyState === WebSocket.OPEN) { + liveWsRef.current.send(JSON.stringify({ type: "stop" })); + } else { + // The connection is already closing/closed — send() would silently + // drop the "stop" frame (WebSocket.send() does not throw for a + // non-OPEN socket), and nothing would ever answer with a "result", + // leaving the UI stuck on "Finishing up…" forever. Its close/error + // handler already stops the mic; just clear the ref and settle. + liveWsRef.current = null; + liveRef.current = null; + setLiveStatus("idle"); + setError("Live transcription lost its connection to the server before it could finish."); + } } else { try { const final = await session.transcriber!.stop(); diff --git a/src/server/live.ts b/src/server/live.ts index 4a934e9..ad6f417 100644 --- a/src/server/live.ts +++ b/src/server/live.ts @@ -65,7 +65,15 @@ export const liveWebSocket = { message(ws: ServerWebSocket, message: string | Buffer | Uint8Array) { if (typeof message === "string") { - const parsed = JSON.parse(message); + let parsed: any; + try { + parsed = JSON.parse(message); + } catch { + // Not a frame we understand — ignore rather than crash the handler + // (which would otherwise take the whole connection down for every + // client sharing this Bun.serve process, not just this session). + return; + } if (parsed.type === "stop") { const transcriber = (ws as any).transcriber as LiveTranscriber; transcriber.stop().then((result) => {