From 1bf406d3a8a7d764eb2e8dfa5b6c5b2aa2aa3ad3 Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Tue, 16 Jun 2026 10:20:56 +0200 Subject: [PATCH] Fix post-transcribe freeze: batch embeddings off the main thread + guard load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two reported symptoms — the transcript screen sitting on its loading spinner after a transcription, and a follow-up transcription stuck at 0% — both trace to the same cause: after a transcript saves, embedOne() embeds the whole lecture in ONE synchronous WASM call on the MAIN thread. Measured in a browser harness: embedding 150 segments blocks the thread for ~1.86s straight (a 50ms heartbeat fired once instead of ~35x); on a phone or a long lecture that's many seconds of total UI freeze. During it the just-navigated transcript screen can't run its get() callback (looks "stuck loading") and a concurrent transcription's progress UI is frozen at 0%. - embeddingStore.embedTranscript: embed in batches of 16, yielding to the event loop between batches, so the UI stays responsive. Per-text vectors are identical (e5 pools each text independently); this only changes scheduling. - transcript/[id].tsx: the get() load had NO error handling, so a rejected load (or a long freeze) left the spinner up forever. Add .catch -> show a real "couldn't load" message instead of an infinite spinner; also catch the (non-fatal) media-url load. Ruled out (via harness) the theory that the e5 embedding model hits the same WASM MatMulNBits load failure as Whisper — e5 (encoder-only) loads fine at both q8 and fp32 on WASM, so its dtype is left unchanged. Proper long-term fix (separate, larger): run embeddings in a Web Worker. Validated: tsc clean, 282 tests, web export OK. Co-Authored-By: Claude Opus 4.8 --- src/app/transcript/[id].tsx | 15 ++++++++++++++- src/stores/embeddingStore.ts | 19 +++++++++++++++---- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/app/transcript/[id].tsx b/src/app/transcript/[id].tsx index e570851..909a7f0 100644 --- a/src/app/transcript/[id].tsx +++ b/src/app/transcript/[id].tsx @@ -17,6 +17,7 @@ import { MaxContentWidth, Spacing } from '@/constants/theme'; import { useTheme } from '@/hooks/use-theme'; import { getRepo, type Transcript } from '@/lib/db'; import { downloadText } from '@/lib/download'; +import { errorMessage } from '@/lib/errorMessage'; import { detectCitations, detectEvents, @@ -52,6 +53,7 @@ export default function TranscriptScreen() { }, [t]); const [transcript, setTranscript] = useState(undefined); + const [loadError, setLoadError] = useState(null); const [title, setTitle] = useState(''); const [segments, setSegments] = useState([]); const [dirty, setDirty] = useState(false); @@ -99,6 +101,9 @@ export default function TranscriptScreen() { if (!alive) return; url = u; setPersistedUrl(u); + }) + .catch(() => { + // Missing/failed media is non-fatal — playback just stays unavailable. }); return () => { alive = false; @@ -115,6 +120,12 @@ export default function TranscriptScreen() { setTranscript(t ?? null); setTitle(t?.title ?? ''); setSegments(t?.segments ?? []); + }) + .catch((e) => { + // Without this, a rejected load left the screen on its spinner forever. + if (!alive) return; + setLoadError(errorMessage(e)); + setTranscript(null); }); return () => { alive = false; @@ -277,7 +288,9 @@ export default function TranscriptScreen() { if (transcript === null) return ( - Transcript not found. + + {loadError ? `Couldn't load this transcript: ${loadError}` : 'Transcript not found.'} + ); diff --git a/src/stores/embeddingStore.ts b/src/stores/embeddingStore.ts index d93c404..b8e4c10 100644 --- a/src/stores/embeddingStore.ts +++ b/src/stores/embeddingStore.ts @@ -40,10 +40,21 @@ async function embedTranscript(transcriptId: string): Promise { return; } const eng = getEmbeddingEngine(); - const vecs = await eng.embed( - t.segments.map((s) => s.text), - 'passage', - ); + // Embed in small batches, yielding to the event loop between them. The WASM + // backend runs synchronously on the MAIN thread, so embedding a whole lecture + // (100s of segments) in one call froze the UI for seconds — which, right after + // a transcription, looked like the transcript screen was "stuck loading" and + // starved any follow-up transcription. Per-text vectors are identical + // regardless of batch size (e5 pools each text independently). + const texts = t.segments.map((s) => s.text); + const BATCH = 16; + const vecs: Float32Array[] = []; + for (let i = 0; i < texts.length; i += BATCH) { + const out = await eng.embed(texts.slice(i, i + BATCH), 'passage'); + for (const v of out) vecs.push(v); + // Hand the thread back so navigation, rendering and other work can run. + await new Promise((resolve) => setTimeout(resolve, 0)); + } await repo.upsertVectors( transcriptId, EMBED_MODEL,