Fix post-transcribe freeze: batch embeddings off the main thread + guard load
CI / test (push) Successful in 20s
CI / deploy-web (push) Successful in 37s
CI / build-apk (push) Successful in 7m21s

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 10:20:56 +02:00
parent ed1df8986f
commit 1bf406d3a8
2 changed files with 29 additions and 5 deletions
+14 -1
View File
@@ -17,6 +17,7 @@ import { MaxContentWidth, Spacing } from '@/constants/theme';
import { useTheme } from '@/hooks/use-theme'; import { useTheme } from '@/hooks/use-theme';
import { getRepo, type Transcript } from '@/lib/db'; import { getRepo, type Transcript } from '@/lib/db';
import { downloadText } from '@/lib/download'; import { downloadText } from '@/lib/download';
import { errorMessage } from '@/lib/errorMessage';
import { import {
detectCitations, detectCitations,
detectEvents, detectEvents,
@@ -52,6 +53,7 @@ export default function TranscriptScreen() {
}, [t]); }, [t]);
const [transcript, setTranscript] = useState<Transcript | null | undefined>(undefined); const [transcript, setTranscript] = useState<Transcript | null | undefined>(undefined);
const [loadError, setLoadError] = useState<string | null>(null);
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [segments, setSegments] = useState<Segment[]>([]); const [segments, setSegments] = useState<Segment[]>([]);
const [dirty, setDirty] = useState(false); const [dirty, setDirty] = useState(false);
@@ -99,6 +101,9 @@ export default function TranscriptScreen() {
if (!alive) return; if (!alive) return;
url = u; url = u;
setPersistedUrl(u); setPersistedUrl(u);
})
.catch(() => {
// Missing/failed media is non-fatal — playback just stays unavailable.
}); });
return () => { return () => {
alive = false; alive = false;
@@ -115,6 +120,12 @@ export default function TranscriptScreen() {
setTranscript(t ?? null); setTranscript(t ?? null);
setTitle(t?.title ?? ''); setTitle(t?.title ?? '');
setSegments(t?.segments ?? []); 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 () => { return () => {
alive = false; alive = false;
@@ -277,7 +288,9 @@ export default function TranscriptScreen() {
if (transcript === null) if (transcript === null)
return ( return (
<Centered> <Centered>
<ThemedText type="small" themeColor="textSecondary">Transcript not found.</ThemedText> <ThemedText type="small" themeColor="textSecondary">
{loadError ? `Couldn't load this transcript: ${loadError}` : 'Transcript not found.'}
</ThemedText>
</Centered> </Centered>
); );
+15 -4
View File
@@ -40,10 +40,21 @@ async function embedTranscript(transcriptId: string): Promise<void> {
return; return;
} }
const eng = getEmbeddingEngine(); const eng = getEmbeddingEngine();
const vecs = await eng.embed( // Embed in small batches, yielding to the event loop between them. The WASM
t.segments.map((s) => s.text), // backend runs synchronously on the MAIN thread, so embedding a whole lecture
'passage', // (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<void>((resolve) => setTimeout(resolve, 0));
}
await repo.upsertVectors( await repo.upsertVectors(
transcriptId, transcriptId,
EMBED_MODEL, EMBED_MODEL,