# Word-Timed Captions — End-to-End Build A complete, runnable path from audio/transcript to animated burn-in captions plus a sidecar SRT/VTT. Code targets Remotion for burn-in and plain JS for the web; the data shapes are framework-agnostic. ## 1. The one data shape Everything normalizes to a flat array of word tokens. Get to this shape and the renderer never cares about the source. ```ts export type Token = { text: string; startMs: number; endMs: number }; export type Page = { startMs: number; endMs: number; tokens: Token[] }; ``` Remotion's `@remotion/captions` uses a compatible `Caption` type: ```ts type Caption = { text: string; startMs: number; endMs: number; timestampMs: number | null; confidence: number | null }; ``` ## 2. Transcribe to word-level timing (Whisper) Word timestamps are non-negotiable for karaoke. Whisper produces them natively. ```bash npx @remotion/install-whisper-cpp # one-time: installs whisper.cpp + a model ``` ```ts import { transcribe, toCaptions } from "@remotion/install-whisper-cpp"; const { transcription } = await transcribe({ inputPath: "voiceover.wav", // 16kHz mono WAV is ideal for whisper.cpp model: "medium.en", // medium.en ≈ 2x faster than large, accurate on clean voice tokenLevelTimestamps: true, // <-- the key flag: per-word, not per-line whisperPath: "./whisper.cpp", }); const { captions } = toCaptions({ transcription }); // → Caption[] ``` Tips: - Feed clean voice audio (denoise/normalize first). Background music wrecks word boundaries. - `large` is most accurate but 2–3x real-time on a laptop. Use `medium.en` unless accuracy is critical. - Cloud alternatives (AssemblyAI, Deepgram, OpenAI Whisper API) also return word timing — map their `words[]` into `Token` the same way. Map any token shape into `Token`: ```ts const toTokens = (caps: Caption[]): Token[] => caps.map(c => ({ text: c.text.trim(), startMs: c.startMs, endMs: c.endMs })); ``` ## 3. Parse an existing SRT into tokens When handed an SRT, parse cues; if cues are sentence-level, re-transcribe for word timing rather than interpolating. ```ts const toMs = (t: string) => { const [h, m, rest] = t.trim().split(":"); const [s, ms] = rest.split(","); return ((+h * 60 + +m) * 60 + +s) * 1000 + +ms; }; export function parseSrt(src: string): Token[] { return src.trim().split(/\n\s*\n/).map(block => { const lines = block.split("\n"); const time = lines.find(l => l.includes("-->"))!; const [a, b] = time.split("-->"); const text = lines.slice(lines.indexOf(time) + 1).join(" ").trim(); return { text, startMs: toMs(a), endMs: toMs(b) }; }); } ``` ## 4. Page the tokens (1–4 words at a time) Either use Remotion's helper or group manually. Manual grouping by gap + max-words keeps full control: ```ts export function paginate(tokens: Token[], maxWords = 3, maxGapMs = 1200): Page[] { const pages: Page[] = []; let cur: Token[] = []; for (const t of tokens) { const span = cur.length ? t.endMs - cur[0].startMs : 0; if (cur.length >= maxWords || span > maxGapMs) { if (cur.length) pages.push(pageOf(cur)); cur = []; } cur.push(t); } if (cur.length) pages.push(pageOf(cur)); return pages; } const pageOf = (ts: Token[]): Page => ({ startMs: ts[0].startMs, endMs: ts.at(-1)!.endMs, tokens: ts }); ``` Remotion equivalent: ```ts import { createTikTokStyleCaptions } from "@remotion/captions"; const { pages } = createTikTokStyleCaptions({ captions, combineTokensWithinMilliseconds: 1200 }); // 200–500ms → word-by-word; 1000–1500ms → short readable phrases ``` ## 5. Remotion component: word-timed with active highlight ```tsx import { AbsoluteFill, Audio, Sequence, useCurrentFrame, useVideoConfig, spring, interpolate, staticFile } from "remotion"; const CAPTION_STYLE: React.CSSProperties = { fontFamily: "Montserrat, Inter, sans-serif", fontWeight: 800, fontSize: 72, // ~8% of 1920px height color: "#fff", textAlign: "center", textTransform: "uppercase", WebkitTextStroke: "5px #000", // heavy outline survives any background paintOrder: "stroke fill", // stroke behind fill, not clipping glyphs textShadow: "0 4px 16px rgba(0,0,0,.55)", lineHeight: 1.1, padding: "0 80px", // keep inside center 80% width }; const Word: React.FC<{ token: Token; active: boolean }> = ({ token, active }) => { const frame = useCurrentFrame(); const { fps } = useVideoConfig(); const enter = (token.startMs / 1000) * fps; const p = spring({ frame: frame - enter, fps, config: { damping: 12, mass: 0.6 } }); return ( {token.text}  ); }; export const Captions: React.FC<{ pages: Page[]; audio: string }> = ({ pages, audio }) => { const { fps } = useVideoConfig(); const f = (ms: number) => Math.round((ms / 1000) * fps); const frame = useCurrentFrame(); return ( ); }; ``` Because the `