From 1964349533447fdc00dae6c604ab8f8ef868dbde Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 18:42:09 +0000 Subject: [PATCH] Add manual speaker controls Let users rename, reassign, add, move, merge, and remove speakers in the transcript. Names persist through undo/redo, project saves, and import/export (SRT/VTT/JSON/TXT/MD). Co-authored-by: Wassim Gharbi --- ROADMAP.md | 1 + components/ExportDialog.tsx | 3 + components/ImportTranscriptOption.tsx | 8 +- components/SpeakerLabel.tsx | 558 ++++++++++++++++++++++++++ components/TranscriptPanel.tsx | 97 +++-- components/UploadScreen.tsx | 9 +- hooks/useTranscriptSelection.ts | 20 +- lib/autosave.ts | 1 + lib/parseTranscript.ts | 92 ++++- lib/projects.ts | 5 +- lib/serializeTranscript.ts | 32 +- lib/speakers.ts | 219 ++++++++++ lib/store.ts | 169 +++++++- lib/types.ts | 7 + tests/parse-transcript-test.ts | 8 +- tests/serialize-transcript-test.ts | 21 +- tests/speakers-test.ts | 143 +++++++ 17 files changed, 1298 insertions(+), 95 deletions(-) create mode 100644 components/SpeakerLabel.tsx create mode 100644 lib/speakers.ts create mode 100644 tests/speakers-test.ts diff --git a/ROADMAP.md b/ROADMAP.md index a67e6f1..0b9845f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -23,6 +23,7 @@ User-facing progress for Rescript. Items are marked done when they shipped to - [x] One-click **Remove silences** (pauses / dead air ≥ 0.3s) - [x] Import your own transcript (SRT / VTT / JSON) instead of running Whisper - [x] Import cancel UX (no stuck selector / blocked media drop) +- [x] Manual speaker controls (rename, change, add, move label, merge, remove) ### Transcription quality & models - [x] Whisper Base and Whisper Small selectable on upload diff --git a/components/ExportDialog.tsx b/components/ExportDialog.tsx index a78d440..7ba9241 100644 --- a/components/ExportDialog.tsx +++ b/components/ExportDialog.tsx @@ -63,6 +63,7 @@ export default function ExportDialog() { const mediaKind = useEditorStore((s) => s.mediaKind); const duration = useEditorStore((s) => s.duration); const words = useEditorStore((s) => s.words); + const speakers = useEditorStore((s) => s.speakers); const hasAudioTrack = useEditorStore((s) => s.audio !== null); const status = useEditorStore((s) => s.status); const setStatus = useEditorStore((s) => s.setStatus); @@ -217,6 +218,7 @@ export default function ExportDialog() { downloadTranscript(words, format, baseName, { duration, cuts, + speakers, }); setError(null); } catch (err) { @@ -226,6 +228,7 @@ export default function ExportDialog() { [ hasWords, words, + speakers, transcriptFormat, subtitleFormat, baseName, diff --git a/components/ImportTranscriptOption.tsx b/components/ImportTranscriptOption.tsx index b403360..d71a489 100644 --- a/components/ImportTranscriptOption.tsx +++ b/components/ImportTranscriptOption.tsx @@ -140,9 +140,13 @@ export default function ImportTranscriptOption() { setError(null); setSource("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 }); + setPendingTranscript({ + name: file.name, + words: parsed.words, + speakers: parsed.speakers, + }); setSource("import"); menu?.closeMenu(); } catch (err) { diff --git a/components/SpeakerLabel.tsx b/components/SpeakerLabel.tsx new file mode 100644 index 0000000..106e5ff --- /dev/null +++ b/components/SpeakerLabel.tsx @@ -0,0 +1,558 @@ +"use client"; + +import { + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, + type PointerEvent as ReactPointerEvent, +} from "react"; +import { + Check, + ChevronDown, + GripVertical, + MoreHorizontal, + Plus, + Replace, + Trash2, + UserRoundPen, +} from "lucide-react"; +import { useEditorStore } from "@/lib/store"; +import { + findSpeakerByName, + speakerColor, + speakerLabel, +} from "@/lib/speakers"; +import type { SpeakerInfo } from "@/lib/types"; +import Popover, { PopoverContent, PopoverTrigger } from "./Popover"; + +type PickerMode = "change" | "rename" | "replace"; + +/** + * Speaker heading: rename / change / replace / remove, plus a grip to drag + * the turn boundary onto another word in the adjacent turns. + */ +export default function SpeakerLabel({ + speakerId, + turnWordIds, + turnStartWordId, + canMove, +}: { + speakerId: number; + /** All word ids in this speaker turn (for Change speaker). */ + turnWordIds: number[]; + turnStartWordId: number; + /** False for the first turn — nothing to merge a boundary against. */ + canMove: boolean; +}) { + const speakers = useEditorStore((s) => s.speakers); + const changeTurnSpeaker = useEditorStore((s) => s.changeTurnSpeaker); + const renameSpeaker = useEditorStore((s) => s.renameSpeaker); + const replaceSpeakerInProject = useEditorStore((s) => s.replaceSpeakerInProject); + const removeSpeakerFromProject = useEditorStore( + (s) => s.removeSpeakerFromProject + ); + const moveSpeakerLabel = useEditorStore((s) => s.moveSpeakerLabel); + + const label = speakerLabel(speakers, speakerId); + const color = speakerColor(speakerId); + + const [pickerOpen, setPickerOpen] = useState(false); + const [menuOpen, setMenuOpen] = useState(false); + const [mode, setMode] = useState("change"); + const [query, setQuery] = useState(""); + const inputRef = useRef(null); + const panelId = useId(); + + const openPicker = useCallback((next: PickerMode) => { + setMode(next); + setQuery(next === "rename" ? label : ""); + setMenuOpen(false); + setPickerOpen(true); + }, [label]); + + useEffect(() => { + if (!pickerOpen) return; + const t = window.setTimeout(() => { + inputRef.current?.focus(); + if (mode === "rename") inputRef.current?.select(); + }, 0); + return () => window.clearTimeout(t); + }, [pickerOpen, mode]); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return speakers; + return speakers.filter((s) => s.name.toLowerCase().includes(q)); + }, [speakers, query]); + + const exact = findSpeakerByName(speakers, query); + const trimmedQuery = query.trim(); + const canCreate = + mode !== "replace" && + trimmedQuery.length > 0 && + !exact; + const canRenameTo = + mode !== "replace" && + trimmedQuery.length > 0 && + trimmedQuery.toLowerCase() !== label.toLowerCase(); + + const applySpeaker = useCallback( + (target: number | "new", name?: string) => { + if (mode === "replace") { + if (target === "new" || target === speakerId) return; + replaceSpeakerInProject(speakerId, target); + } else if (mode === "rename") { + const nextName = (name ?? trimmedQuery).trim(); + if (!nextName) return; + // Typing an existing name merges this label into that speaker. + const existing = findSpeakerByName(speakers, nextName); + if (existing && existing.id !== speakerId) { + replaceSpeakerInProject(speakerId, existing.id); + } else { + renameSpeaker(speakerId, nextName); + } + } else { + changeTurnSpeaker(turnWordIds, target, name); + } + setPickerOpen(false); + setQuery(""); + }, + [ + mode, + speakerId, + trimmedQuery, + speakers, + turnWordIds, + changeTurnSpeaker, + renameSpeaker, + replaceSpeakerInProject, + ] + ); + + const onSubmit = useCallback(() => { + if (!trimmedQuery) return; + if (mode === "rename") { + applySpeaker(speakerId, trimmedQuery); + return; + } + if (exact) { + applySpeaker(exact.id); + return; + } + if (mode === "replace") return; + applySpeaker("new", trimmedQuery); + }, [trimmedQuery, mode, exact, applySpeaker, speakerId]); + + // --- Drag to move turn boundary --- + const draggingRef = useRef(false); + const [dragOverWordId, setDragOverWordId] = useState(null); + + const clearDrag = useCallback(() => { + draggingRef.current = false; + setDragOverWordId(null); + document.body.style.removeProperty("cursor"); + document.body.style.removeProperty("user-select"); + }, []); + + const onGripPointerDown = useCallback( + (e: ReactPointerEvent) => { + if (!canMove) return; + e.preventDefault(); + e.stopPropagation(); + draggingRef.current = true; + document.body.style.cursor = "grabbing"; + document.body.style.userSelect = "none"; + + const onMove = (ev: PointerEvent) => { + if (!draggingRef.current) return; + const el = document.elementFromPoint(ev.clientX, ev.clientY); + const span = el?.closest?.("[data-wid]") as HTMLElement | null; + const wid = span ? Number(span.dataset.wid) : NaN; + setDragOverWordId(Number.isFinite(wid) ? wid : null); + }; + const onUp = (ev: PointerEvent) => { + document.removeEventListener("pointermove", onMove); + document.removeEventListener("pointerup", onUp); + const el = document.elementFromPoint(ev.clientX, ev.clientY); + const span = el?.closest?.("[data-wid]") as HTMLElement | null; + const wid = span ? Number(span.dataset.wid) : NaN; + clearDrag(); + if (Number.isFinite(wid)) { + moveSpeakerLabel(turnStartWordId, wid); + } + }; + document.addEventListener("pointermove", onMove); + document.addEventListener("pointerup", onUp); + }, + [canMove, clearDrag, moveSpeakerLabel, turnStartWordId] + ); + + // Highlight the word under the drag cursor. + useEffect(() => { + if (dragOverWordId == null) return; + const el = document.querySelector( + `[data-wid="${dragOverWordId}"]` + ) as HTMLElement | null; + if (!el) return; + const prev = el.style.boxShadow; + el.style.boxShadow = `inset 3px 0 0 ${color}`; + return () => { + el.style.boxShadow = prev; + }; + }, [dragOverWordId, color]); + + const title = + mode === "rename" + ? "Rename speaker" + : mode === "replace" + ? "Replace in project with…" + : "Change speaker"; + + return ( +
+ {canMove && ( + + )} + + +
+ + + + + +
+

+ {title} +

+ setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + onSubmit(); + } + }} + placeholder={ + mode === "rename" + ? "New name" + : mode === "replace" + ? "Find a speaker…" + : "Search or create…" + } + className="w-full rounded-lg border border-zinc-200 bg-zinc-50 px-2 py-1.5 text-[13px] text-zinc-800 outline-none focus:border-zinc-400 focus:bg-white dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100 dark:focus:border-zinc-500" + /> +
+ +
+ {mode !== "replace" && canRenameTo && ( + + )} + {canCreate && ( + + )} + {filtered.length === 0 && !canCreate && !canRenameTo && ( +

+ No matching speakers +

+ )} + {filtered.map((s) => ( + applySpeaker(s.id)} + /> + ))} +
+
+
+
+ + +
+ + + + + openPicker("rename")} + /> + openPicker("replace")} + /> + { + setMenuOpen(false); + removeSpeakerFromProject(speakerId); + }} + /> + +
+
+
+ ); +} + +/** Toolbar button that opens the selection speaker picker. */ +export function SelectionSpeakerButton({ onClick }: { onClick: () => void }) { + return ( + + ); +} + +/** Anchored picker for reassigning the current word selection. */ +export function SelectionSpeakerPopover({ + wordIds, + top, + left, + onClose, +}: { + wordIds: number[]; + top: number; + left: number; + onClose: () => void; +}) { + const speakers = useEditorStore((s) => s.speakers); + const words = useEditorStore((s) => s.words); + const changeTurnSpeaker = useEditorStore((s) => s.changeTurnSpeaker); + const [query, setQuery] = useState(""); + const inputRef = useRef(null); + const panelRef = useRef(null); + + const currentSpeaker = useMemo(() => { + const idSet = new Set(wordIds); + const selected = words.filter((w) => idSet.has(w.id)); + if (selected.length === 0) return 0; + return selected[0].speaker; + }, [wordIds, words]); + + useEffect(() => { + const t = window.setTimeout(() => inputRef.current?.focus(), 0); + return () => window.clearTimeout(t); + }, []); + + useEffect(() => { + const handler = (e: MouseEvent) => { + if (!panelRef.current?.contains(e.target as Node)) onClose(); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("mousedown", handler); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", handler); + document.removeEventListener("keydown", onKey); + }; + }, [onClose]); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return speakers; + return speakers.filter((s) => s.name.toLowerCase().includes(q)); + }, [speakers, query]); + + const exact = findSpeakerByName(speakers, query); + const trimmedQuery = query.trim(); + const canCreate = trimmedQuery.length > 0 && !exact; + + const apply = useCallback( + (target: number | "new", name?: string) => { + changeTurnSpeaker(wordIds, target, name); + onClose(); + }, + [changeTurnSpeaker, wordIds, onClose] + ); + + return ( +
+
+ setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key !== "Enter") return; + e.preventDefault(); + if (exact) apply(exact.id); + else if (canCreate) apply("new", trimmedQuery); + }} + placeholder="Search or create…" + className="w-full rounded-lg border border-zinc-200 bg-zinc-50 px-2 py-1.5 text-[13px] text-zinc-800 outline-none focus:border-zinc-400 focus:bg-white dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-100 dark:focus:border-zinc-400" + /> +
+
+ {canCreate && ( + + )} + {filtered.map((s) => ( + apply(s.id)} + /> + ))} +
+
+ ); +} + +function SpeakerOption({ + speaker, + active, + disabled, + onSelect, +}: { + speaker: SpeakerInfo; + active?: boolean; + disabled?: boolean; + onSelect: () => void; +}) { + return ( + + ); +} + +function MenuItem({ + icon: Icon, + label, + onClick, + disabled, + danger, +}: { + icon: typeof Trash2; + label: string; + onClick: () => void; + disabled?: boolean; + danger?: boolean; +}) { + return ( + + ); +} diff --git a/components/TranscriptPanel.tsx b/components/TranscriptPanel.tsx index 003b2a1..afa0c2e 100644 --- a/components/TranscriptPanel.tsx +++ b/components/TranscriptPanel.tsx @@ -5,7 +5,6 @@ import { ArrowUpFromLine, Eye, EyeOff, - FileText, Merge, Pencil, RotateCcw, @@ -26,6 +25,10 @@ import { } from "@/lib/parseTranscript"; import type { Word } from "@/lib/types"; import TranscriptScrollIndicator from "./TranscriptScrollIndicator"; +import SpeakerLabel, { + SelectionSpeakerButton, + SelectionSpeakerPopover, +} from "./SpeakerLabel"; import { getActiveSceneBoundaries, getKeepRanges, @@ -36,18 +39,6 @@ import { useTranscriptSelection } from "@/hooks/useTranscriptSelection"; import { useCutRanges } from "@/hooks/useCutRanges"; import { findActiveWordId, groupWordsBySpeaker } from "@/lib/transcript"; -const SPEAKER_COLORS = [ - "#16a34a", // green - "#2563eb", // blue - "#9333ea", // purple - "#ea580c", // orange - "#0d9488", // teal - "#db2777", // pink -]; - -const speakerColor = (i: number) => - SPEAKER_COLORS[Math.max(0, i) % SPEAKER_COLORS.length]; - const WordSpan = memo(function WordSpan({ word, cutOut, @@ -159,8 +150,13 @@ export default function TranscriptPanel() { containerWidth: number; } | null>(null); const [correctText, setCorrectText] = useState(""); - // Mirrors `correcting` so selection handlers can freeze highlights while open. - const correctingRef = useRef(false); + const [assigningSpeaker, setAssigningSpeaker] = useState<{ + ids: number[]; + top: number; + left: number; + } | null>(null); + // Mirrors Correct / Speaker pickers so selection handlers freeze highlights. + const freezeSelectionRef = useRef(false); const { selection, @@ -172,7 +168,7 @@ export default function TranscriptPanel() { containerRef, scrollRef, cutOutIds, - correctingRef, + freezeSelectionRef, }); const turns = useMemo(() => groupWordsBySpeaker(words), [words]); @@ -208,7 +204,7 @@ export default function TranscriptPanel() { } try { const imported = await parseTranscriptFile(file); - importWords(imported); + importWords(imported.words, imported.speakers); } catch (err) { console.error(err); alert(err instanceof Error ? err.message : "Could not read that transcript."); @@ -236,7 +232,7 @@ export default function TranscriptPanel() { .filter((w) => idSet.has(w.id)) .map((w) => w.text) .join(" "); - correctingRef.current = true; + freezeSelectionRef.current = true; setCorrectText(text); setCorrecting({ ids: selection.ids, @@ -248,11 +244,29 @@ export default function TranscriptPanel() { }, [selection, words, releaseToolbar]); const closeCorrect = useCallback(() => { - correctingRef.current = false; + freezeSelectionRef.current = false; clearMarks(); setCorrecting(null); }, [clearMarks]); + const openSpeakerAssign = useCallback(() => { + if (!selection) return; + freezeSelectionRef.current = true; + setAssigningSpeaker({ + ids: selection.ids, + top: selection.top, + left: selection.left, + }); + releaseToolbar(); + }, [selection, releaseToolbar]); + + const closeSpeakerAssign = useCallback(() => { + freezeSelectionRef.current = false; + clearMarks(); + setAssigningSpeaker(null); + clearSelection(); + }, [clearMarks, clearSelection]); + const applyCorrection = useCallback(() => { if (!correcting) return; correctWords(correcting.ids, correctText); @@ -287,6 +301,21 @@ export default function TranscriptPanel() { return () => document.removeEventListener("keydown", handler); }, [selectedWordIds, deleteWords, clearSelection]); + // "@" opens the speaker picker for the current selection. + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key !== "@" || e.metaKey || e.ctrlKey || e.altKey) return; + const target = e.target as HTMLElement; + if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) + return; + if (!selection || assigningSpeaker || correcting) return; + e.preventDefault(); + openSpeakerAssign(); + }; + document.addEventListener("keydown", handler); + return () => document.removeEventListener("keydown", handler); + }, [selection, assigningSpeaker, correcting, openSpeakerAssign]); + // Keep the active word in view during playback. useEffect(() => { if (!playing || activeWordId < 0) return; @@ -414,19 +443,21 @@ export default function TranscriptPanel() { {status === "ready" && (
- {turns.map((turn, i) => { + {turns.map((turn) => { const visible = showDeleted ? turn.words : turn.words.filter((w) => !cutOutIds.has(w.id)); if (visible.length === 0) return null; + // First turn in the full word list has no previous speaker to borrow from. + const canMove = turn.words[0].id !== words[0]?.id; return ( -
-
- Speaker {turn.speaker + 1} -
+
+ w.id)} + turnStartWordId={turn.words[0].id} + canMove={canMove} + />

{visible.map((w) => { const split = splitBeforeWordId.get(w.id); @@ -451,7 +482,7 @@ export default function TranscriptPanel() {

)} - {selection && !correcting && ( + {selection && !correcting && !assigningSpeaker && (
Correct +
)} + {assigningSpeaker && ( + + )} + {correcting && (
void; + onFile: ( + file: File, + options?: { words?: Word[]; speakers?: SpeakerInfo[] } + ) => void; }) { const inputRef = useRef(null); const [dragging, setDragging] = useState(false); @@ -212,7 +215,7 @@ export default function UploadScreen({ alert("Choose a transcript file from the source menu first."); return; } - onFile(file, { words: pending.words }); + onFile(file, { words: pending.words, speakers: pending.speakers }); return; } onFile(file); diff --git a/hooks/useTranscriptSelection.ts b/hooks/useTranscriptSelection.ts index b15c568..ade5a42 100644 --- a/hooks/useTranscriptSelection.ts +++ b/hooks/useTranscriptSelection.ts @@ -116,12 +116,16 @@ export function useTranscriptSelection({ containerRef, scrollRef, cutOutIds, - correctingRef, + /** + * When true, native selectioncollapse (e.g. focusing a popover input) must + * not clear the transcript selection — used by Correct / Speaker pickers. + */ + freezeSelectionRef, }: { containerRef: RefObject; scrollRef: RefObject; cutOutIds: Set; - correctingRef: RefObject; + freezeSelectionRef: RefObject; }) { const selectedWordIds = useEditorStore((s) => s.selectedWordIds); const setSelectedWords = useEditorStore((s) => s.setSelectedWords); @@ -225,7 +229,7 @@ export function useTranscriptSelection({ }; const updateFromNativeSelection = (mode: SelectionSyncMode) => { - if (correctingRef.current) return; + if (freezeSelectionRef.current) return; const container = containerRef.current; const sel = window.getSelection(); @@ -293,12 +297,12 @@ export function useTranscriptSelection({ document.removeEventListener("mousedown", onMouseDown); document.removeEventListener("mouseup", onMouseUp); }; - }, [clearMarks, applyMarks, syncToReact, containerRef, correctingRef]); + }, [clearMarks, applyMarks, syncToReact, containerRef, freezeSelectionRef]); // Clear a click-selection when mousedown lands outside words/toolbar (in-panel only). useEffect(() => { const handler = (e: MouseEvent) => { - if (!clickSelectionRef.current || correctingRef.current) return; + if (!clickSelectionRef.current || freezeSelectionRef.current) return; const target = e.target as HTMLElement | null; if (!target || !scrollRef.current?.contains(target)) return; if (target.closest("[data-wid], [data-transcript-toolbar]")) return; @@ -306,11 +310,11 @@ export function useTranscriptSelection({ }; document.addEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler); - }, [clearSelection, scrollRef, correctingRef]); + }, [clearSelection, scrollRef, freezeSelectionRef]); // Mirror a selection made elsewhere (timeline wordbar) into this panel. useEffect(() => { - if (correctingRef.current) return; + if (freezeSelectionRef.current) return; const container = containerRef.current; if (!container) return; const shown = selection?.ids ?? []; @@ -344,7 +348,7 @@ export function useTranscriptSelection({ clearMarks, applyMarks, containerRef, - correctingRef, + freezeSelectionRef, ]); return { diff --git a/lib/autosave.ts b/lib/autosave.ts index c3fb400..b33c3cd 100644 --- a/lib/autosave.ts +++ b/lib/autosave.ts @@ -71,6 +71,7 @@ async function writeSnapshot() { showDeleted: s.showDeleted, manualCuts: s.manualCuts, sceneBoundaries: s.sceneBoundaries, + speakers: s.speakers, media: s.videoFile, mediaType: s.videoFile.type, }); diff --git a/lib/parseTranscript.ts b/lib/parseTranscript.ts index 9d17030..8b19103 100644 --- a/lib/parseTranscript.ts +++ b/lib/parseTranscript.ts @@ -1,4 +1,5 @@ -import type { Word } from "./types"; +import { defaultSpeakerName, speakersFromWords } from "./speakers"; +import type { SpeakerInfo, Word } from "./types"; /** Caption / transcript files we can turn into timed words. */ export const TRANSCRIPT_ACCEPT = @@ -15,6 +16,11 @@ export function isTranscriptFile(file: File): boolean { ); } +export interface ParsedTranscript { + words: Word[]; + speakers: SpeakerInfo[]; +} + interface Cue { start: number; end: number; @@ -23,42 +29,48 @@ interface Cue { } /** - * Parse an SRT, WebVTT, or JSON transcript into editor `Word`s. + * Parse an SRT, WebVTT, or JSON transcript into timed words + named speakers. * Cue text is split on whitespace; each cue's time span is distributed * across its tokens by character length (same idea as word correction). */ -export function parseTranscript(text: string, filename = ""): Word[] { +export function parseTranscript( + text: string, + filename = "" +): ParsedTranscript { const trimmed = text.replace(/^\uFEFF/, "").trim(); if (!trimmed) throw new Error("That transcript file is empty."); const lower = filename.toLowerCase(); - let words: Word[]; + let parsed: ParsedTranscript; if (lower.endsWith(".json") || looksLikeJson(trimmed)) { - words = wordsFromJson(trimmed); + parsed = wordsFromJson(trimmed); } else if (lower.endsWith(".vtt") || /^WEBVTT/i.test(trimmed)) { - words = wordsFromCues(parseVtt(trimmed)); + parsed = wordsFromCues(parseVtt(trimmed)); } else if (lower.endsWith(".srt") || looksLikeSrt(trimmed)) { - words = wordsFromCues(parseSrt(trimmed)); + parsed = wordsFromCues(parseSrt(trimmed)); } else { // Fallbacks when the extension is missing or wrong. try { - words = wordsFromJson(trimmed); + parsed = wordsFromJson(trimmed); } catch { try { - words = wordsFromCues(parseVtt(trimmed)); + parsed = wordsFromCues(parseVtt(trimmed)); } catch { - words = wordsFromCues(parseSrt(trimmed)); + parsed = wordsFromCues(parseSrt(trimmed)); } } } - if (words.length === 0) { + if (parsed.words.length === 0) { throw new Error("No timed words found in that transcript."); } - return words; + return { + words: parsed.words, + speakers: speakersFromWords(parsed.words, parsed.speakers), + }; } -export async function parseTranscriptFile(file: File): Promise { +export async function parseTranscriptFile(file: File): Promise { const text = await file.text(); return parseTranscript(text, file.name); } @@ -74,7 +86,7 @@ function looksLikeSrt(text: string): boolean { ); } -function wordsFromJson(text: string): Word[] { +function wordsFromJson(text: string): ParsedTranscript { let data: unknown; try { data = JSON.parse(text); @@ -93,8 +105,14 @@ function wordsFromJson(text: string): Word[] { throw new Error('JSON must be a word array or { "words": [...] }.'); } + const namedSpeakers = readSpeakerInfos(data); const nameToIdx = new Map(); let nextSpeaker = 0; + for (const s of namedSpeakers) { + nameToIdx.set(s.name.trim().toLowerCase(), s.id); + nextSpeaker = Math.max(nextSpeaker, s.id + 1); + } + const words: Word[] = []; for (const row of rows) { @@ -110,8 +128,13 @@ function wordsFromJson(text: string): Word[] { speaker = Math.max(0, Math.floor(r.speaker)); } else if (typeof r.speaker === "string" && r.speaker.trim()) { const name = r.speaker.trim(); - if (!nameToIdx.has(name)) nameToIdx.set(name, nextSpeaker++); - speaker = nameToIdx.get(name)!; + const key = name.toLowerCase(); + if (!nameToIdx.has(key)) { + nameToIdx.set(key, nextSpeaker); + namedSpeakers.push({ id: nextSpeaker, name }); + nextSpeaker++; + } + speaker = nameToIdx.get(key)!; } const s = Math.max(0, start); @@ -125,7 +148,28 @@ function wordsFromJson(text: string): Word[] { }); } - return words; + return { words, speakers: speakersFromWords(words, namedSpeakers) }; +} + +function readSpeakerInfos(data: unknown): SpeakerInfo[] { + if (!data || typeof data !== "object" || Array.isArray(data)) return []; + const raw = (data as { speakers?: unknown }).speakers; + if (!Array.isArray(raw)) return []; + const out: SpeakerInfo[] = []; + const seen = new Set(); + for (const row of raw) { + if (!row || typeof row !== "object") continue; + const r = row as Record; + const id = Number(r.id ?? r.index); + if (!Number.isFinite(id) || id < 0 || seen.has(id)) continue; + const name = + typeof r.name === "string" && r.name.trim() + ? r.name.trim() + : defaultSpeakerName(id); + seen.add(id); + out.push({ id: Math.floor(id), name }); + } + return out.sort((a, b) => a.id - b.id); } function parseSrt(text: string): Cue[] { @@ -238,8 +282,9 @@ function stripCueMeta(raw: string): { text: string; speaker?: string } { return { text, speaker }; } -function wordsFromCues(cues: Cue[]): Word[] { +function wordsFromCues(cues: Cue[]): ParsedTranscript { const speakerIds = new Map(); + const speakers: SpeakerInfo[] = []; let nextSpeaker = 0; const words: Word[] = []; let id = 0; @@ -250,8 +295,13 @@ function wordsFromCues(cues: Cue[]): Word[] { let speaker = 0; if (cue.speaker) { - if (!speakerIds.has(cue.speaker)) speakerIds.set(cue.speaker, nextSpeaker++); - speaker = speakerIds.get(cue.speaker)!; + const key = cue.speaker.toLowerCase(); + if (!speakerIds.has(key)) { + speakerIds.set(key, nextSpeaker); + speakers.push({ id: nextSpeaker, name: cue.speaker }); + nextSpeaker++; + } + speaker = speakerIds.get(key)!; } const span = Math.max(0.02, cue.end - cue.start); @@ -273,5 +323,5 @@ function wordsFromCues(cues: Cue[]): Word[] { cursor = end; } } - return words; + return { words, speakers: speakersFromWords(words, speakers) }; } diff --git a/lib/projects.ts b/lib/projects.ts index 1e0c986..f4e2719 100644 --- a/lib/projects.ts +++ b/lib/projects.ts @@ -13,7 +13,7 @@ import { isTranscriptLanguage, } from "./languages"; import type { MediaKind } from "./media"; -import type { ManualCut, SceneBoundary, Word } from "./types"; +import type { ManualCut, SceneBoundary, SpeakerInfo, Word } from "./types"; const DB_NAME = "rescript-projects"; const DB_VERSION = 1; @@ -47,6 +47,8 @@ export interface ProjectRecord extends ProjectMeta { manualCuts?: ManualCut[]; /** Scene split points in original media time (optional for older saves). */ sceneBoundaries?: SceneBoundary[]; + /** Named speakers (optional for older saves — derived from words when missing). */ + speakers?: SpeakerInfo[]; /** Original media bytes. */ media: Blob; /** MIME type used when reconstructing a File. */ @@ -186,6 +188,7 @@ export async function putProject(input: ProjectWrite): Promise { showDeleted: input.showDeleted, manualCuts: input.manualCuts ?? [], sceneBoundaries: input.sceneBoundaries ?? [], + speakers: input.speakers ?? [], media: input.media, mediaType: input.mediaType, createdAt: createdAt ?? now, diff --git a/lib/serializeTranscript.ts b/lib/serializeTranscript.ts index f8bed8c..100fd54 100644 --- a/lib/serializeTranscript.ts +++ b/lib/serializeTranscript.ts @@ -1,6 +1,7 @@ import { getCutRanges, originalToEdited } from "./edits"; +import { speakerLabel, speakersFromWords } from "./speakers"; import { groupWordsBySpeaker } from "./transcript"; -import type { TimeRange, Word } from "./types"; +import type { SpeakerInfo, TimeRange, Word } from "./types"; /** Timed caption / subtitle formats (importable). */ export type SubtitleFormat = "srt" | "vtt" | "json"; @@ -24,6 +25,8 @@ export interface SerializeOptions { * times match the media export. Falls back to word-derived cuts when omitted. */ cuts?: TimeRange[]; + /** Named speakers for labels in captions / documents / JSON. */ + speakers?: SpeakerInfo[]; } interface Cue { @@ -63,9 +66,10 @@ export function serializeTranscript( format: TranscriptFormat, options: SerializeOptions = {} ): string { - if (format === "json") return serializeJson(words); + const speakers = options.speakers ?? speakersFromWords(words); + if (format === "json") return serializeJson(words, speakers); if (format === "txt" || format === "md") { - return serializeDocument(words, format, options); + return serializeDocument(words, format, { ...options, speakers }); } const editedTimeline = options.editedTimeline !== false; @@ -74,7 +78,9 @@ export function serializeTranscript( throw new Error("No words to export."); } const cues = wordsToCues(prepared); - return format === "vtt" ? serializeVtt(cues) : serializeSrt(cues); + return format === "vtt" + ? serializeVtt(cues, speakers) + : serializeSrt(cues, speakers); } /** Trigger a browser download of the serialized transcript / subtitles. */ @@ -96,9 +102,10 @@ export function downloadTranscript( URL.revokeObjectURL(url); } -function serializeJson(words: Word[]): string { +function serializeJson(words: Word[], speakers: SpeakerInfo[]): string { return JSON.stringify( { + speakers: speakers.map((s) => ({ id: s.id, name: s.name })), words: words.map((w) => ({ text: w.text, start: roundTime(w.start), @@ -123,13 +130,13 @@ function serializeDocument( throw new Error("No words to export."); } + const speakers = options.speakers ?? speakersFromWords(prepared); const turns = groupWordsBySpeaker(prepared); if (format === "txt") { return ( turns .map((turn) => { - const label = - turn.speaker >= 0 ? `Speaker ${turn.speaker + 1}` : "Speaker"; + const label = speakerLabel(speakers, turn.speaker); const text = turn.words.map((w) => w.text).join(" "); return `${label}: ${text}`; }) @@ -140,8 +147,7 @@ function serializeDocument( return ( turns .map((turn) => { - const label = - turn.speaker >= 0 ? `Speaker ${turn.speaker + 1}` : "Speaker"; + const label = speakerLabel(speakers, turn.speaker); const text = turn.words.map((w) => w.text).join(" "); return `**${label}**\n\n${text}`; }) @@ -205,7 +211,7 @@ function wordsToCues(words: Word[]): Cue[] { return cues; } -function serializeSrt(cues: Cue[]): string { +function serializeSrt(cues: Cue[], speakers: SpeakerInfo[]): string { return ( cues .map((cue, i) => { @@ -214,7 +220,7 @@ function serializeSrt(cues: Cue[]): string { `${formatSrtTimestamp(cue.start)} --> ${formatSrtTimestamp(cue.end)}`, ]; if (cue.speaker >= 0) { - lines.push(`Speaker ${cue.speaker + 1}: ${cue.text}`); + lines.push(`${speakerLabel(speakers, cue.speaker)}: ${cue.text}`); } else { lines.push(cue.text); } @@ -224,13 +230,13 @@ function serializeSrt(cues: Cue[]): string { ); } -function serializeVtt(cues: Cue[]): string { +function serializeVtt(cues: Cue[], speakers: SpeakerInfo[]): string { const body = cues .map((cue) => { const timing = `${formatVttTimestamp(cue.start)} --> ${formatVttTimestamp(cue.end)}`; const text = cue.speaker >= 0 - ? `${cue.text}` + ? `${cue.text}` : cue.text; return `${timing}\n${text}`; }) diff --git a/lib/speakers.ts b/lib/speakers.ts new file mode 100644 index 0000000..18a9eec --- /dev/null +++ b/lib/speakers.ts @@ -0,0 +1,219 @@ +/** + * Speaker metadata helpers and pure edit operations. + * + * Words carry a numeric `speaker` id; display names live in `SpeakerInfo[]`. + * Diarization / import create the initial list; the user can rename, reassign, + * move turn boundaries, add, merge, and remove speakers from the transcript. + */ + +import type { SpeakerInfo, Word } from "./types"; + +export const SPEAKER_COLORS = [ + "#16a34a", // green + "#2563eb", // blue + "#9333ea", // purple + "#ea580c", // orange + "#0d9488", // teal + "#db2777", // pink +] as const; + +export function speakerColor(id: number): string { + return SPEAKER_COLORS[Math.max(0, id) % SPEAKER_COLORS.length]; +} + +export function defaultSpeakerName(id: number): string { + return id >= 0 ? `Speaker ${id + 1}` : "Speaker"; +} + +export function speakerLabel( + speakers: SpeakerInfo[], + id: number +): string { + if (id < 0) return "Speaker"; + return speakers.find((s) => s.id === id)?.name ?? defaultSpeakerName(id); +} + +/** Build / refresh the speaker list from word ids, preserving known names. */ +export function speakersFromWords( + words: Word[], + existing: SpeakerInfo[] = [] +): SpeakerInfo[] { + const byId = new Map(existing.map((s) => [s.id, s])); + const ids = new Set(); + for (const w of words) { + if (w.speaker >= 0) ids.add(w.speaker); + } + // Keep speakers the user added even if no words currently use them. + for (const s of existing) ids.add(s.id); + + return [...ids] + .sort((a, b) => a - b) + .map((id) => byId.get(id) ?? { id, name: defaultSpeakerName(id) }); +} + +export function nextSpeakerId(speakers: SpeakerInfo[]): number { + return speakers.reduce((m, s) => Math.max(m, s.id), -1) + 1; +} + +export function addSpeaker( + speakers: SpeakerInfo[], + name?: string +): { speakers: SpeakerInfo[]; id: number } { + const id = nextSpeakerId(speakers); + const trimmed = name?.trim(); + const entry: SpeakerInfo = { + id, + name: trimmed && trimmed.length > 0 ? trimmed : defaultSpeakerName(id), + }; + return { speakers: [...speakers, entry], id }; +} + +export function renameSpeaker( + speakers: SpeakerInfo[], + id: number, + name: string +): SpeakerInfo[] { + const trimmed = name.trim(); + if (!trimmed) return speakers; + const has = speakers.some((s) => s.id === id); + if (!has) { + return [...speakers, { id, name: trimmed }].sort((a, b) => a.id - b.id); + } + return speakers.map((s) => (s.id === id ? { ...s, name: trimmed } : s)); +} + +/** Find a speaker by case-insensitive name, or null. */ +export function findSpeakerByName( + speakers: SpeakerInfo[], + name: string +): SpeakerInfo | null { + const key = name.trim().toLowerCase(); + if (!key) return null; + return speakers.find((s) => s.name.trim().toLowerCase() === key) ?? null; +} + +export function reassignWords( + words: Word[], + ids: Iterable, + toSpeaker: number +): Word[] { + const idSet = ids instanceof Set ? ids : new Set(ids); + if (idSet.size === 0) return words; + let changed = false; + const next = words.map((w) => { + if (!idSet.has(w.id) || w.speaker === toSpeaker) return w; + changed = true; + return { ...w, speaker: toSpeaker }; + }); + return changed ? next : words; +} + +/** + * Merge every word (and the speaker entry) from `fromId` into `toId`. + * No-op when the ids match or `toId` is missing from the project list. + */ +export function replaceSpeaker( + words: Word[], + speakers: SpeakerInfo[], + fromId: number, + toId: number +): { words: Word[]; speakers: SpeakerInfo[] } | null { + if (fromId === toId) return null; + if (!speakers.some((s) => s.id === toId)) return null; + if (!speakers.some((s) => s.id === fromId)) return null; + return { + words: words.map((w) => + w.speaker === fromId ? { ...w, speaker: toId } : w + ), + speakers: speakers.filter((s) => s.id !== fromId), + }; +} + +/** + * Remove a speaker from the project. Each of their turns is reassigned to the + * speaker of the preceding word ("speaker above"). Turns at the start of the + * transcript take the following speaker when one exists. + */ +export function removeSpeaker( + words: Word[], + speakers: SpeakerInfo[], + id: number +): { words: Word[]; speakers: SpeakerInfo[] } | null { + if (!speakers.some((s) => s.id === id)) return null; + if (speakers.length <= 1) return null; + + const nextWords = words.map((w, i) => { + if (w.speaker !== id) return w; + let replacement = -1; + for (let j = i - 1; j >= 0; j--) { + if (words[j].speaker !== id) { + replacement = words[j].speaker; + break; + } + } + if (replacement < 0) { + for (let j = i + 1; j < words.length; j++) { + if (words[j].speaker !== id) { + replacement = words[j].speaker; + break; + } + } + } + if (replacement < 0) { + // Sole remaining voice after filter — pick another speaker id. + replacement = speakers.find((s) => s.id !== id)?.id ?? 0; + } + return { ...w, speaker: replacement }; + }); + + return { + words: nextWords, + speakers: speakers.filter((s) => s.id !== id), + }; +} + +/** + * Move the start of a speaker turn to `targetWordId`, adjusting the boundary + * with the previous turn. Target must lie within the previous turn or this one. + */ +export function moveSpeakerBoundary( + words: Word[], + turnStartWordId: number, + targetWordId: number +): Word[] | null { + const startIdx = words.findIndex((w) => w.id === turnStartWordId); + const targetIdx = words.findIndex((w) => w.id === targetWordId); + if (startIdx <= 0 || targetIdx < 0 || targetIdx === startIdx) return null; + + const speaker = words[startIdx].speaker; + const prevSpeaker = words[startIdx - 1].speaker; + if (prevSpeaker === speaker) return null; + + let turnEnd = startIdx + 1; + while (turnEnd < words.length && words[turnEnd].speaker === speaker) { + turnEnd++; + } + + let prevStart = startIdx - 1; + while (prevStart > 0 && words[prevStart - 1].speaker === prevSpeaker) { + prevStart--; + } + + if (targetIdx < prevStart || targetIdx >= turnEnd) return null; + + let changed = false; + const next = words.map((w, i) => { + if (i >= prevStart && i < targetIdx) { + if (w.speaker === prevSpeaker) return w; + changed = true; + return { ...w, speaker: prevSpeaker }; + } + if (i >= targetIdx && i < turnEnd) { + if (w.speaker === speaker) return w; + changed = true; + return { ...w, speaker: speaker }; + } + return w; + }); + return changed ? next : null; +} diff --git a/lib/store.ts b/lib/store.ts index 9f2520d..207a76c 100644 --- a/lib/store.ts +++ b/lib/store.ts @@ -7,6 +7,7 @@ import type { ManualCut, ProgressInfo, SceneBoundary, + SpeakerInfo, TimeRange, Word, } from "./types"; @@ -39,10 +40,21 @@ import { fileFromProject, getProject, } from "./projects"; +import { + addSpeaker as addSpeakerEntry, + findSpeakerByName, + moveSpeakerBoundary, + reassignWords, + removeSpeaker as removeSpeakerEntry, + renameSpeaker as renameSpeakerEntry, + replaceSpeaker as replaceSpeakerEntry, + speakersFromWords, +} from "./speakers"; interface PendingTranscript { name: string; words: Word[]; + speakers?: SpeakerInfo[]; } interface EditorState { @@ -80,6 +92,8 @@ interface EditorState { // Transcript / edits words: Word[]; + /** Named speakers in the project (ids match Word.speaker). */ + speakers: SpeakerInfo[]; manualCuts: ManualCut[]; sceneBoundaries: SceneBoundary[]; showDeleted: boolean; @@ -111,7 +125,10 @@ interface EditorState { // Actions /** Load media for editing. Pass `words` to skip Whisper and use that transcript. */ - loadVideo: (file: File, options?: { words?: Word[] }) => void; + loadVideo: ( + file: File, + options?: { words?: Word[]; speakers?: SpeakerInfo[] } + ) => void; /** Restore a saved project from IndexedDB (no re-transcription). */ openProject: (id: string) => Promise; /** Delete a saved project; if it is the active one, resets to the home screen. */ @@ -125,12 +142,33 @@ interface EditorState { setProgress: (p: ProgressInfo) => void; setPartialText: (t: string) => void; setError: (message: string) => void; - setWords: (words: Word[]) => void; + setWords: (words: Word[], speakers?: SpeakerInfo[]) => void; /** * Replace the current transcript with an imported one (keeps media). * Used when the user brings their own SRT/VTT/JSON instead of Whisper. */ - importWords: (words: Word[]) => void; + importWords: (words: Word[], speakers?: SpeakerInfo[]) => void; + /** Rename a speaker everywhere it appears. */ + renameSpeaker: (id: number, name: string) => void; + /** Create a new speaker; returns its id (or -1 if unchanged). */ + addSpeaker: (name?: string) => number; + /** Reassign selected / listed words to a speaker (creating the speaker if needed). */ + reassignWordsToSpeaker: (ids: number[], toSpeaker: number) => void; + /** + * Change who speaks a turn. Pass `toSpeaker: "new"` to create a speaker. + * Optional `name` renames/creates with that label. + */ + changeTurnSpeaker: ( + wordIds: number[], + toSpeaker: number | "new", + name?: string + ) => void; + /** Move a turn's start to `targetWordId` (boundary with the previous turn). */ + moveSpeakerLabel: (turnStartWordId: number, targetWordId: number) => void; + /** Merge all of `fromId` into `toId` across the project. */ + replaceSpeakerInProject: (fromId: number, toId: number) => void; + /** Remove a speaker; their turns join the speaker above in the script. */ + removeSpeakerFromProject: (id: number) => void; deleteWords: (ids: number[]) => void; restoreWords: (ids: number[]) => void; /** Cut arbitrary time ranges (e.g. detected silences) as manual cuts. */ @@ -178,11 +216,13 @@ function bumpAutosave() { function snapshotOf(s: { words: Word[]; + speakers: SpeakerInfo[]; manualCuts: ManualCut[]; sceneBoundaries: SceneBoundary[]; }): EditSnapshot { return { words: s.words, + speakers: s.speakers, manualCuts: s.manualCuts, sceneBoundaries: s.sceneBoundaries, }; @@ -191,6 +231,7 @@ function snapshotOf(s: { function snapshotsEqual(a: EditSnapshot, b: EditSnapshot): boolean { return ( a.words === b.words && + a.speakers === b.speakers && a.manualCuts === b.manualCuts && a.sceneBoundaries === b.sceneBoundaries ); @@ -211,6 +252,7 @@ function pushEdit( Pick< EditorState, | "words" + | "speakers" | "manualCuts" | "sceneBoundaries" | "selectedClipIndex" @@ -252,6 +294,7 @@ export const useEditorStore = create((set, get) => ({ error: null, words: [], + speakers: [], manualCuts: [], sceneBoundaries: [], showDeleted: true, @@ -278,6 +321,9 @@ export const useEditorStore = create((set, get) => ({ const prev = get().mediaUrl; if (prev) URL.revokeObjectURL(prev); const current = get().source; + const speakers = imported + ? speakersFromWords(imported, options?.speakers ?? []) + : []; set({ videoFile: file, mediaUrl: URL.createObjectURL(file), @@ -292,9 +338,10 @@ export const useEditorStore = create((set, get) => ({ value: null, }, words: imported ? imported : [], + speakers, manualCuts: [], sceneBoundaries: [], - past: [], + past: [], future: [], selectedClipIndex: null, selectedWordIds: [], @@ -318,6 +365,7 @@ export const useEditorStore = create((set, get) => ({ if (prev) URL.revokeObjectURL(prev); const manualCuts = record.manualCuts ?? []; const sceneBoundaries = record.sceneBoundaries ?? []; + const speakers = speakersFromWords(record.words, record.speakers ?? []); set({ videoFile: file, mediaUrl: URL.createObjectURL(file), @@ -333,6 +381,7 @@ export const useEditorStore = create((set, get) => ({ status: "preparing", progress: { message: "Loading media engine…", value: null }, words: record.words, + speakers, manualCuts, sceneBoundaries, showDeleted: record.showDeleted, @@ -384,19 +433,20 @@ export const useEditorStore = create((set, get) => ({ setProgress: (progress) => set({ progress }), setPartialText: (partialText) => set({ partialText }), setError: (message) => set({ status: "error", error: message }), - setWords: (words) => { + setWords: (words, speakers) => { set({ words, + speakers: speakersFromWords(words, speakers ?? []), manualCuts: [], sceneBoundaries: [], - past: [], + past: [], future: [], selectedClipIndex: null, selectedWordIds: [], }); if (get().status === "ready") bumpAutosave(); }, - importWords: (words) => { + importWords: (words, speakers) => { if (words.length === 0) return; const { status } = get(); if ( @@ -410,9 +460,10 @@ export const useEditorStore = create((set, get) => ({ void import("@/hooks/useTranscriber").then((m) => m.cancelTranscription()); set({ words, + speakers: speakersFromWords(words, speakers ?? []), manualCuts: [], sceneBoundaries: [], - past: [], + past: [], future: [], selectedClipIndex: null, selectedWordIds: [], @@ -426,6 +477,89 @@ export const useEditorStore = create((set, get) => ({ bumpAutosave(); }, + renameSpeaker: (id, name) => { + const { speakers } = get(); + const next = renameSpeakerEntry(speakers, id, name); + if (next === speakers) return; + pushEdit(get, set, { speakers: next }); + }, + + addSpeaker: (name) => { + const { speakers } = get(); + const trimmed = name?.trim(); + if (trimmed) { + const existing = findSpeakerByName(speakers, trimmed); + if (existing) return existing.id; + } + const { speakers: next, id } = addSpeakerEntry(speakers, name); + pushEdit(get, set, { speakers: next }); + return id; + }, + + reassignWordsToSpeaker: (ids, toSpeaker) => { + if (ids.length === 0) return; + const s = get(); + const words = reassignWords(s.words, ids, toSpeaker); + if (words === s.words) return; + const speakers = speakersFromWords(words, s.speakers); + pushEdit(get, set, { words, speakers }); + }, + + changeTurnSpeaker: (wordIds, toSpeaker, name) => { + if (wordIds.length === 0) return; + const s = get(); + let speakers = s.speakers; + let targetId: number; + if (toSpeaker === "new") { + const added = addSpeakerEntry(speakers, name); + speakers = added.speakers; + targetId = added.id; + } else { + targetId = toSpeaker; + if (name && name.trim()) { + speakers = renameSpeakerEntry(speakers, targetId, name); + } else if (!speakers.some((sp) => sp.id === targetId)) { + speakers = speakersFromWords( + s.words, + [...speakers, { id: targetId, name: `Speaker ${targetId + 1}` }] + ); + } + } + const words = reassignWords(s.words, wordIds, targetId); + if (words === s.words && speakers === s.speakers) return; + pushEdit(get, set, { + words, + speakers: speakersFromWords(words, speakers), + }); + }, + + moveSpeakerLabel: (turnStartWordId, targetWordId) => { + const { words } = get(); + const next = moveSpeakerBoundary(words, turnStartWordId, targetWordId); + if (!next) return; + pushEdit(get, set, { words: next }); + }, + + replaceSpeakerInProject: (fromId, toId) => { + const s = get(); + const result = replaceSpeakerEntry(s.words, s.speakers, fromId, toId); + if (!result) return; + pushEdit(get, set, { + words: result.words, + speakers: result.speakers, + }); + }, + + removeSpeakerFromProject: (id) => { + const s = get(); + const result = removeSpeakerEntry(s.words, s.speakers, id); + if (!result) return; + pushEdit(get, set, { + words: result.words, + speakers: result.speakers, + }); + }, + deleteWords: (ids) => { if (ids.length === 0) return; const { words } = get(); @@ -627,17 +761,18 @@ export const useEditorStore = create((set, get) => ({ }, undo: () => { - const { past, future, words, manualCuts, sceneBoundaries } = + const { past, future, words, speakers, manualCuts, sceneBoundaries } = get(); if (past.length === 0) return; const prev = past[past.length - 1]; set({ words: prev.words, + speakers: prev.speakers, manualCuts: prev.manualCuts, sceneBoundaries: prev.sceneBoundaries, past: past.slice(0, -1), future: [ - { words, manualCuts, sceneBoundaries }, + { words, speakers, manualCuts, sceneBoundaries }, ...future, ], selectedClipIndex: null, @@ -647,16 +782,17 @@ export const useEditorStore = create((set, get) => ({ bumpAutosave(); }, redo: () => { - const { past, future, words, manualCuts, sceneBoundaries } = + const { past, future, words, speakers, manualCuts, sceneBoundaries } = get(); if (future.length === 0) return; const next = future[0]; set({ words: next.words, + speakers: next.speakers, manualCuts: next.manualCuts, sceneBoundaries: next.sceneBoundaries, future: future.slice(1), - past: [...past, { words, manualCuts, sceneBoundaries }], + past: [...past, { words, speakers, manualCuts, sceneBoundaries }], selectedClipIndex: null, selectedWordIds: [], gestureActive: false, @@ -713,9 +849,10 @@ export const useEditorStore = create((set, get) => ({ partialText: "", error: null, words: [], + speakers: [], manualCuts: [], sceneBoundaries: [], - past: [], + past: [], future: [], selectedClipIndex: null, selectedWordIds: [], @@ -746,3 +883,9 @@ export function hydrateTranscriptLanguagePreference() { useEditorStore.setState({ transcriptLanguage: stored }); } } + +// DevTools / Playwright: inspect and drive the editor store from the console. +if (typeof window !== "undefined" && process.env.NODE_ENV === "development") { + (window as unknown as { __rescriptStore?: typeof useEditorStore }).__rescriptStore = + useEditorStore; +} diff --git a/lib/types.ts b/lib/types.ts index 3750ea5..cb88c89 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -38,11 +38,18 @@ export interface SceneBoundary { time: number; } +/** Named speaker in the project (id matches Word.speaker). */ +export interface SpeakerInfo { + id: number; + name: string; +} + /** Snapshot of all edit state for undo/redo. */ export interface EditSnapshot { words: Word[]; manualCuts: ManualCut[]; sceneBoundaries: SceneBoundary[]; + speakers: SpeakerInfo[]; } /** A contiguous kept segment of media, optionally subdivided by scene boundaries. */ diff --git a/tests/parse-transcript-test.ts b/tests/parse-transcript-test.ts index adea6d6..0251c34 100644 --- a/tests/parse-transcript-test.ts +++ b/tests/parse-transcript-test.ts @@ -9,7 +9,7 @@ function assert(cond: boolean, msg: string) { } { - const words = parseTranscript( + const { words } = parseTranscript( `1 00:00:01,000 --> 00:00:03,000 Hello world @@ -31,7 +31,7 @@ Alice: How are you? } { - const words = parseTranscript( + const { words, speakers } = parseTranscript( `WEBVTT 00:00:00.000 --> 00:00:02.000 @@ -45,11 +45,13 @@ Alice: How are you? assert(words.length === 6, `vtt expected 6 words, got ${words.length}`); assert(words[0].speaker === 0 && words[0].text === "Winter", "vtt voice 0"); assert(words[3].speaker === 1 && words[3].text === "You", "vtt voice 1"); + assert(speakers[0].name === "Ned", "vtt keeps Ned"); + assert(speakers[1].name === "Catelyn", "vtt keeps Catelyn"); console.log("vtt: ok"); } { - const words = parseTranscript( + const { words } = parseTranscript( JSON.stringify({ words: [ { text: "One", start: 0, end: 0.4, speaker: "A" }, diff --git a/tests/serialize-transcript-test.ts b/tests/serialize-transcript-test.ts index 68a4c10..2cc9b45 100644 --- a/tests/serialize-transcript-test.ts +++ b/tests/serialize-transcript-test.ts @@ -45,6 +45,19 @@ const sample: Word[] = [ console.log("srt export: ok"); } +{ + const srt = serializeTranscript(sample, "srt", { + duration: 10, + speakers: [ + { id: 0, name: "Alice" }, + { id: 1, name: "Bob" }, + ], + }); + assert(srt.includes("Alice: Hello world"), `named srt\n${srt}`); + assert(srt.includes("Bob: How are you"), `named srt 2\n${srt}`); + console.log("srt named speakers: ok"); +} + { const vtt = serializeTranscript(sample, "vtt", { duration: 10 }); assert(vtt.startsWith("WEBVTT"), "vtt header"); @@ -61,6 +74,8 @@ const sample: Word[] = [ assert(data.words.length === 6, "json keeps deleted words"); assert(data.words[2].deleted === true && data.words[2].text === "um", "json deleted flag"); assert(data.words[0].speaker === 0, "json speaker"); + assert(Array.isArray(data.speakers), "json speakers list"); + assert(data.speakers[0].name === "Speaker 1", "json default speaker name"); console.log("json export: ok"); } @@ -85,7 +100,7 @@ const sample: Word[] = [ { // JSON round-trip preserves text, times, speakers, deleted. const json = serializeTranscript(sample, "json"); - const back = parseTranscript(json, "roundtrip.json"); + const { words: back } = parseTranscript(json, "roundtrip.json"); assert(back.length === sample.length, "json round-trip length"); for (let i = 0; i < sample.length; i++) { assert(back[i].text === sample[i].text, `json rt text ${i}`); @@ -106,7 +121,7 @@ const sample: Word[] = [ editedTimeline: false, duration: 10, }); - const back = parseTranscript(srt, "roundtrip.srt"); + const { words: back } = parseTranscript(srt, "roundtrip.srt"); assert(back.map((w) => w.text).join(" ") === "Hello world How are you", "srt rt text"); assert(back[0].speaker === 0 && back[2].speaker === 1, "srt rt speakers"); assert(near(back[0].start, 1), `srt rt start ${back[0].start}`); @@ -122,7 +137,7 @@ const sample: Word[] = [ editedTimeline: false, duration: 10, }); - const back = parseTranscript(vtt, "roundtrip.vtt"); + const { words: back } = parseTranscript(vtt, "roundtrip.vtt"); assert(back.map((w) => w.text).join(" ") === "Hello world How are you", "vtt rt text"); assert(back[0].speaker === 0 && back[2].speaker === 1, "vtt rt speakers"); console.log("vtt round-trip: ok"); diff --git a/tests/speakers-test.ts b/tests/speakers-test.ts new file mode 100644 index 0000000..fee85b0 --- /dev/null +++ b/tests/speakers-test.ts @@ -0,0 +1,143 @@ +/** + * Unit tests for speaker rename / reassign / move / merge / remove. + * Run: npx tsx tests/speakers-test.ts + */ +import { + addSpeaker, + defaultSpeakerName, + findSpeakerByName, + moveSpeakerBoundary, + reassignWords, + removeSpeaker, + renameSpeaker, + replaceSpeaker, + speakersFromWords, + speakerLabel, +} from "../lib/speakers"; +import type { SpeakerInfo, Word } from "../lib/types"; + +function assert(cond: boolean, msg: string) { + if (!cond) throw new Error(msg); +} + +function w( + id: number, + text: string, + speaker: number, + start = id, + end = id + 0.5 +): Word { + return { id, text, start, end, speaker, deleted: false }; +} + +{ + assert(defaultSpeakerName(0) === "Speaker 1", "default 0"); + assert(defaultSpeakerName(2) === "Speaker 3", "default 2"); + const speakers = speakersFromWords([w(0, "a", 0), w(1, "b", 2)]); + assert(speakers.length === 2, "derive skips missing ids"); + assert(speakers[0].name === "Speaker 1", "name 0"); + assert(speakers[1].id === 2 && speakers[1].name === "Speaker 3", "name 2"); + // Preserve custom names + unused speakers the user added. + const kept = speakersFromWords( + [w(0, "a", 0)], + [ + { id: 0, name: "Alice" }, + { id: 1, name: "Bob" }, + ] + ); + assert(kept.some((s) => s.id === 1 && s.name === "Bob"), "keep unused"); + assert(speakerLabel(kept, 0) === "Alice", "label lookup"); + console.log("derive: ok"); +} + +{ + let speakers: SpeakerInfo[] = [ + { id: 0, name: "Speaker 1" }, + { id: 1, name: "Speaker 2" }, + ]; + speakers = renameSpeaker(speakers, 1, "Bob"); + assert(speakers[1].name === "Bob", "rename"); + assert(findSpeakerByName(speakers, "bob")?.id === 1, "find by name"); + const added = addSpeaker(speakers, "Carol"); + assert(added.id === 2 && added.speakers[2].name === "Carol", "add named"); + console.log("rename/add: ok"); +} + +{ + const words = [w(0, "Hi", 0), w(1, "there", 0), w(2, "friend", 1)]; + const next = reassignWords(words, [1, 2], 0); + assert(next[1].speaker === 0 && next[2].speaker === 0, "reassign"); + assert(words[2].speaker === 1, "immutable"); + console.log("reassign: ok"); +} + +{ + // A A | B B B → move B start onto second A → A | B B B B + const words = [ + w(0, "one", 0), + w(1, "two", 0), + w(2, "three", 1), + w(3, "four", 1), + w(4, "five", 1), + ]; + const earlier = moveSpeakerBoundary(words, 2, 1); + assert(earlier !== null, "move earlier"); + assert( + earlier!.map((x) => x.speaker).join("") === "01111", + `earlier got ${earlier!.map((x) => x.speaker).join("")}` + ); + + // Move B later within its turn → A A A A | B + const later = moveSpeakerBoundary(words, 2, 4); + assert(later !== null, "move later"); + assert( + later!.map((x) => x.speaker).join("") === "00001", + `later got ${later!.map((x) => x.speaker).join("")}` + ); + + assert(moveSpeakerBoundary(words, 0, 1) === null, "first turn immovable"); + assert(moveSpeakerBoundary(words, 2, 2) === null, "no-op null"); + console.log("move boundary: ok"); +} + +{ + const words = [w(0, "a", 0), w(1, "b", 1), w(2, "c", 1)]; + const speakers: SpeakerInfo[] = [ + { id: 0, name: "Alice" }, + { id: 1, name: "Bob" }, + ]; + const merged = replaceSpeaker(words, speakers, 1, 0); + assert(merged !== null, "merge"); + assert(merged!.words.every((x) => x.speaker === 0), "all alice"); + assert(merged!.speakers.length === 1, "bob removed"); + console.log("replace: ok"); +} + +{ + const words = [ + w(0, "a", 0), + w(1, "b", 1), + w(2, "c", 1), + w(3, "d", 2), + ]; + const speakers: SpeakerInfo[] = [ + { id: 0, name: "A" }, + { id: 1, name: "B" }, + { id: 2, name: "C" }, + ]; + const removed = removeSpeaker(words, speakers, 1); + assert(removed !== null, "remove"); + assert( + removed!.words.map((x) => x.speaker).join("") === "0002", + `remove reassign got ${removed!.words.map((x) => x.speaker).join("")}` + ); + assert(!removed!.speakers.some((s) => s.id === 1), "B gone"); + + // Removing the first speaker assigns to the following one. + const removeFirst = removeSpeaker(words, speakers, 0); + assert(removeFirst !== null, "remove first"); + assert(removeFirst!.words[0].speaker === 1, "first → next"); + console.log("remove: ok"); +} + +console.log("ALL SPEAKER TESTS PASSED");