Fix post-transcribe freeze: batch embeddings off the main thread + guard load
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:
@@ -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>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user