From 0a10c857bda5658e0255944c3c81f37135bc01ce Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Jul 2026 06:04:02 +0000 Subject: [PATCH 1/3] Sync plain-text transcripts to audio like Descript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accept .txt (and unlabeled plain text) with optional "Speaker: " labels. Timed SRT/VTT/JSON still skip Whisper; untimed text runs Whisper for timing then Needleman–Wunsch-aligns the user's words onto those stamps. Works from the upload import option and mid-session Replace. --- components/ImportTranscriptOption.tsx | 26 +++- components/TranscriptPanel.tsx | 34 ++++- components/UploadScreen.tsx | 18 ++- hooks/useTranscriber.ts | 161 +++++++++++++++-------- lib/alignTranscript.ts | 181 ++++++++++++++++++++++++++ lib/parseTranscript.ts | 126 ++++++++++++------ lib/store.ts | 60 +++++++-- scripts/parse-transcript-test.ts | 85 +++++++++++- 8 files changed, 566 insertions(+), 125 deletions(-) create mode 100644 lib/alignTranscript.ts diff --git a/components/ImportTranscriptOption.tsx b/components/ImportTranscriptOption.tsx index 75f10f0..20a9c1c 100644 --- a/components/ImportTranscriptOption.tsx +++ b/components/ImportTranscriptOption.tsx @@ -111,7 +111,7 @@ export default function ImportTranscriptOption() { { @@ -157,7 +157,7 @@ export default function ImportTranscriptOption() { } if (!isTranscriptFile(file)) { setPicking(false); - setError("Choose an SRT, VTT, or JSON file."); + setError("Choose an SRT, VTT, JSON, or TXT file."); setPendingTranscript(null); setModel("import"); menu?.keepMenuOpen(); @@ -168,9 +168,21 @@ export default function ImportTranscriptOption() { setError(null); setModel("import"); // so the closed trigger can show progress try { - const words = await parseTranscriptFile(file); + const parsed = await parseTranscriptFile(file); if (pickGenRef.current !== gen) return; - setPendingTranscript({ name: file.name, words }); + if (parsed.kind === "timed") { + setPendingTranscript({ + name: file.name, + kind: "timed", + words: parsed.words, + }); + } else { + setPendingTranscript({ + name: file.name, + kind: "untimed", + tokens: parsed.tokens, + }); + } setModel("import"); menu?.closeMenu(); } catch (err) { @@ -245,9 +257,11 @@ function ImportStatus({ ) : error ? ( error ) : pendingTranscript ? ( - `${pendingTranscript.name} · ${pendingTranscript.words.length} words` + pendingTranscript.kind === "timed" + ? `${pendingTranscript.name} · ${pendingTranscript.words.length} words` + : `${pendingTranscript.name} · ${pendingTranscript.tokens.length} words · syncs to audio` ) : picking ? ( - "Choose an SRT, VTT, or JSON file…" + "Choose an SRT, VTT, JSON, or TXT file…" ) : null} ); diff --git a/components/TranscriptPanel.tsx b/components/TranscriptPanel.tsx index e3ac348..4b1aacc 100644 --- a/components/TranscriptPanel.tsx +++ b/components/TranscriptPanel.tsx @@ -128,7 +128,7 @@ export default function TranscriptPanel() { const file = files?.[0]; if (!file) return; if (!isTranscriptFile(file)) { - alert("Please choose an SRT, VTT, or JSON transcript."); + alert("Please choose an SRT, VTT, JSON, or TXT transcript."); return; } if ( @@ -138,8 +138,34 @@ export default function TranscriptPanel() { return; } try { - const imported = await parseTranscriptFile(file); - importWords(imported); + const parsed = await parseTranscriptFile(file); + if (parsed.kind === "timed") { + importWords(parsed.words); + return; + } + // Plain text (Descript-style): align to existing timings when we have + // them; otherwise run Whisper and sync the reference text onto it. + const state = useEditorStore.getState(); + if (state.words.length > 0) { + const { alignTranscript } = await import("@/lib/alignTranscript"); + const aligned = alignTranscript( + parsed.tokens, + state.words, + state.duration + ); + importWords(aligned); + return; + } + if (!state.audio) { + alert("Wait for the media to finish loading, then try again."); + return; + } + state.setSyncTokens(parsed.tokens); + const { startTranscription } = await import("@/hooks/useTranscriber"); + startTranscription( + state.audio, + state.duration || state.audio.length / 16000 + ); } catch (err) { console.error(err); alert(err instanceof Error ? err.message : "Could not read that transcript."); @@ -333,7 +359,7 @@ export default function TranscriptPanel() { <> + + +
+

+ Paste a plain-text script to sync with your media, or drop in timed + captions (SRT / VTT / JSON). Optional speaker labels:{" "} + Name: dialogue +

+