Video Lab · captions (word-timed subtitles)

Captions that land on the word: ElevenLabs character alignment into Remotion captions

Rated G

We captioned the same AI host clips four ways and checked each against the silences in the audio. eleven_v4's free word timestamps start the word after a pause a median 37 ms after the sound returns, eleven_v3's a third of a second before it, and Whisper large-v3 runs early on both. One ffmpeg pass fixes v3.

Listen

Narrated by Theo Lindqvist (AI voice, ElevenLabs)

Transcript

Approximate read-along: sentence timing is estimated from the audio length.

Captions that land on the word. ElevenLabs character alignment into Remotion captions. Hi, I'm Theo Lindqvist, an AI host here at the LEJ Video Lab, and this is the narrated version of the post. When ElevenLabs reads a script, it can hand back more than audio. Ask the with-timestamps endpoint, and next to the sound you get a start and end time for every character. Group the characters into words, and you have word-timed captions for free. We wanted to know how good free really is. So we took thirteen clips of real ElevenLabs output from this run. A bit over three and a half minutes of speech, five hundred and ninety-three words, on two models, version three and version four. And we captioned them four ways. One, the ElevenLabs timestamps. Two, the words spread evenly across the clip. Three, a guess that gives longer words more time. And four, Whisper large-v3, running on this machine's CPU. Then we checked all of them against the one thing that doesn't care about anyone's timestamps: where the audio is actually silent. We found the pauses with ffmpeg, then asked two questions. When the sound comes back, does the next word start on time? And while nobody is talking, is a word still lit up? Here's what came out. Version four's timestamps are good enough to ship as they are. After a pause, the next word starts a median of thirty-seven milliseconds after the sound comes back, and not one of forty-eight pauses was more than a tenth of a second early. A word is lit during about one percent of the silence. Version three's timestamps start the next word early after a pause. A median of about a third of a second early, and seventy percent of those starts were more than a tenth of a second early. In Daisy's intro, the word "Today" starts two thirds of a second before she says it. The timestamps give that two-syllable word a full second, with the pause folded into its front. Two lines exist on both models, word for word, same voice, same settings. On version three, the word after a pause started about half a second early. On version four, within ten milliseconds. Version three also held those pauses longer, so we compared pauses of the same length too. On short pauses, version three was still about a tenth of a second early and version four a few hundredths late. On version three's long pauses, more than half a second early. So the model matters, and on version three the error grows with the pause. Even spacing drifts with every pause. The median word was about a third of a second off on version four clips, and more than half a second on version three. On a forty-eight second line, the median word was more than two seconds off. And Whisper is the wrong rescue when you already have the timestamps. It heard almost every word, but it starts them early: about a fifth of a second against version four's timestamps, and about four tenths of a second right after a pause. It also took about two and a third seconds of wall-clock time per second of audio, on eight CPU threads. Against version three it looks better, but only because both of them start early after a pause, so they agree with each other. That's exactly why you check against the audio. Two more details. Version four times word starts in forty millisecond steps, version three doesn't, and inside a word the letters mostly get an even share. So treat the word as the unit, not the letter. And ElevenLabs hangs the silence after a sentence on the punctuation. If a word ends on its last character including the period, a word is lit for more than half of every pause. End it on the last letter instead. So here's the house recipe. Take the ElevenLabs timestamps. End each word on its last letter. Then, for version three audio, find the silences with ffmpeg, and if a word starts inside one, move it to where the sound comes back, minus thirty milliseconds so it lights just ahead of the voice. On our version three clips that moved thirty-six words, by a third of a second at the median, and took the silence with a word lit from fifty-three percent down to five. On version four it moved one word, by ten milliseconds, so it's safe to run on everything. Then render with the Captions template in Remotion. It pages the words by phrase, seven words at most, and starts a new page at a long pause or the end of a sentence. The word being spoken gets the accent color. Paging forgives small timing errors, because only the page flips have to be right. Word highlight shows every error, which is why it needs good timestamps, and why it's what makes captions feel attached to the face. The YouTube caption file comes from the same words, so the site and YouTube match. What we didn't test: forced alignment, where a model is given the script and only has to find where each word is, and Whisper with the script as a prompt. Those are the next tests if your audio comes without timestamps. And the silence check only covers words next to a pause. Word boundaries in the middle of a phrase have no silence to anchor to. On the page there's a short film, the same clip of Daisy captioned four ways, an instructional with Daisy and me, and two players where you can flip between timing sources on a real clip. Daisy and I are AI hosts: AI faces lip-synced on this machine, with AI voices from ElevenLabs. Thanks for listening.

The short film (59 s): one 13 s clip of AI host Daisy Lane captioned four ways, cut as a progression: even spacing, Whisper large-v3, the raw ElevenLabs timestamps paged by phrase, then the house style (timestamps snapped out of silences, word highlight). The lane at the bottom shows each source's words against the reference. Music: ElevenLabs Music, AI-generated.
Prompt and settings

Settings: Remotion 4.0.539, ShortFilm of four CaptionLab shots, 1920x1080, 30 fps; host clip LTX 2.5 ia2v 1280x704 24 fps seed 42 from the on-set still, eleven_v3 audio (cast settings); music bed at volume 0.18

The instructional (175 s), hosted by AI hosts Daisy Lane and Theo Lindqvist: what the endpoint returns, why the pause hides on the punctuation, how even spacing and Whisper drift, the silence check, the snap, and the Remotion component. 49 s on camera, the rest Theo's voice-over. Lip-sync is loose at pauses: counted on score sheets, the mouths close at 11 of 18 pauses (open on 29 of 32 speech points). Music: ElevenLabs Music, AI-generated.
Prompt and settings

Settings: Remotion 4.0.539 Instructional edit, 1920x1080, 30 fps; host segments LTX 2.5 ia2v 1280x704 seed 42 (DAISY-C1 eleven_v3, the rest eleven_v4 at cast settings); house Captions on every spoken block

Toggle caption timing, eleven_v3 clipPick a timing source and a style, press Play, scrub. Daisy's real intro clip and its real timestamps; the readout says how far the current word is off the reference.
Toggle caption timing, eleven_v4 clipTheo's line on eleven_v4, where the raw timestamps need no snap. Compare them with even spacing, the length-weighted guess and Whisper.

Verdict

Use the word timestamps ElevenLabs already returns with the audio, end each word on its last letter, and render them with the house Captions template: on eleven_v4 the word after a pause starts a median 37 ms after the sound comes back, and a word is lit during 1% of the measured silence. On eleven_v3, snap word starts out of silences first (ffmpeg silencedetect), because raw v3 timestamps start the next word a median 346 ms early after a pause. Don't spread words evenly (median 338 to 600 ms off, worst 3.9 s on a 48 s line), and don't reach for Whisper when you have the alignment: large-v3 started words about 0.4 s early after pauses and took 2.3 s of wall-clock time per second of audio on 8 CPU threads here.

Credits and settings

WorkflowElevenLabs with-timestamps alignment into Remotion word captions
MakerElevenLabs (TTS and alignment); Remotion (remotion.dev); OpenAI Whisper via faster-whisper (comparison)
VersionElevenLabs eleven_v4 and eleven_v3 (with-timestamps endpoint, mp3_44100_128); Remotion 4.0.539 Captions template; compared with faster-whisper 1.2.1 large-v3 int8 on CPU
What it doescaptions (word-timed subtitles)
LicenseRemotion License (free for individuals and companies of up to 3 people; Company License above that); ElevenLabs Terms of Service for the generated audio; Whisper large-v3 weights MIT
HardwareDGX Spark: NVIDIA GB10, ARM (aarch64), 20 CPU cores, 128 GB unified memory. Timing analysis, Whisper and Remotion ran on the CPU; the host clips are LTX 2.5 audio-to-video on the GPU through the run's one-job-at-a-time queue.
Speed1.5 s of compute per second of video. Remotion CPU render wall per second of finished 1920x1080 30 fps video: film 87.7 s for 58.8 s, instructional 273.6 s for 175.5 s, plus 10.3 and 24.9 s to encode. The captions themselves cost milliseconds. The AI host clips under them took 17.3 s of GPU per second (LTX 2.5 audio-to-video, 856 s for 49.4 s).
Tested onNov 2, 2026
AI hosts

Daisy Lane (AI host; AI voice)

Theo Lindqvist (AI host; AI voice)

Narration
Theo Lindqvist (AI voice, ElevenLabs)
Music source
Not supplied

What it does well

  • Free with the audio: one with-timestamps call returns the mp3 and a start and end for every character
  • eleven_v4 timestamps match the audio: the word after a pause starts a median 37 ms after the sound returns, none of 48 more than 100 ms early
  • Word highlight on v4 timestamps lights a word during 1% of the measured silence (letter-bounded ends)
  • The same words drive the burned-in captions, the interactive player and the YouTube .srt, so they all match
  • Grouping, snapping and paging run in milliseconds on the CPU; no model, no GPU

Where it breaks

  • eleven_v3 starts the word after a pause a median 346 ms early (70% of 40 pauses more than 100 ms early), and more on longer pauses; "Today" in Daisy's intro starts 667 ms early
  • Ending words on their punctuation hangs them through the pause: a word is lit during 62% (v4) and 82% (v3) of the silence
  • Even spacing: median 338 ms (v4) and 600 ms (v3) off the timestamps, and 2.4 s on a 48 s line
  • Whisper large-v3 (transcribing, not given the script) starts words about 200 ms early against v4 timestamps and about 415 ms early after pauses, mishears words ("queue" as "cue") and takes 2.3 s of wall-clock time per second of audio on 8 CPU threads
  • eleven_v4 word starts come in 40 ms steps (v3's do not), and letters inside a word are mostly an even split: the word is the unit, not the letter
  • Our audio check only covers word edges next to a pause (88 of them); mid-phrase boundaries were not checked against the audio

The short version

When ElevenLabs reads a script it can hand back, next to the audio, a start and end time for every character. Group those characters into words and you have word-timed captions for free. We wanted to know how good "free" is. So we took real ElevenLabs output from this run (13 clips, 218 seconds, 593 words, two models, three cast voices) and captioned it four ways: the ElevenLabs timestamps, words spread evenly across the clip, a length-weighted guess, and Whisper large-v3 running locally. Then we checked all of them against the one thing nobody can argue with: where the audio is actually silent.

Here's what came out:

  • eleven_v4 timestamps are good enough to ship as-is. The word after a pause starts a median 37 ms after the audio gets loud (48 pauses, 8 clips), and none of them more than 100 ms early. A word is lit up during 1% of the measured silence.
  • eleven_v3 timestamps start the next word early after a pause. A median 346 ms before the audio comes back (40 pauses, 5 clips), and 70% of those starts are more than 100 ms early. On Daisy's intro, "Today" starts 667 ms before she says it. One ffmpeg pass fixes it (below).
  • Even spacing drifts with every pause. Median 338 ms off on v4 clips and 600 ms on v3, and on a 48-second line the median word was 2.4 seconds off.
  • Whisper is the wrong rescue when you already have the timestamps. large-v3 heard almost every word but started them early: about 200 ms early against the v4 timestamps, and about 415 ms early right after a pause. It took 2.3 seconds of wall-clock time per second of audio (int8, 8 CPU threads).

The house recipe is four steps. Take the ElevenLabs timestamps, end each word on its last letter, snap any word that starts inside a silence forward to where the sound comes back, and render with the Captions template, paged by phrase with the spoken word in the accent color. The last shot of the short film and the captions in the instructional use that recipe, and the two players let you flip between timing sources on a real clip.

Daisy Lane and Theo Lindqvist are AI hosts: AI-generated faces lip-synced with LTX 2.5 on this machine, voiced by ElevenLabs.

How we tested

The audio. Thirteen clips, all made with bin/tts.py (the with-timestamps endpoint, mp3_44100_128) during this run:

  • 5 on eleven_v3 (102.5 s, 261 words): three test lines in the Jessica voice at stability 0.5 / style 0 (8.1, 20.4 and 48.3 s), Daisy's intro for this post (13.4 s) and Theo's first line on v3 (12.3 s), both at the cast settings.
  • 8 on eleven_v4 (115.8 s, 332 words), all on the cast recipe: Daisy's intro and Theo's first line again, Theo's second line and Daisy's outro, two smoke-test lines (Daisy and Rafael) and the 17.3 and 39.0 s test lines.

So two lines exist on both models, word for word: Daisy's intro (13.36 s on v3, 12.16 s on v4) and Theo's "Picture it like this" line (12.32 s on v3, 12.80 s on v4). That's the cleanest v3 vs v4 comparison we have.

The four timing sources.

  1. ElevenLabs alignment. bin/tts.py walks the alignment arrays and groups characters into whitespace-separated words. A word's start is its first letter and its end is its last letter or digit, not its trailing punctuation (more on why in "Setup and gotchas").
  2. Even spacing. N words spread evenly from 0 to the end of the file. This is what you get from a caption tool that only knows the text and the duration.
  3. Length-weighted guess. bin/words-approx, the run's fallback for audio with no timestamps: word lengths proportional to their letters, with fixed pauses after sentence ends and commas, scaled to fit the file.
  4. Whisper large-v3. faster-whisper 1.2.1 with the Systran large-v3 weights, int8 on the CPU (8 threads, beam 5, word_timestamps=True). It transcribed the audio without being given the script, which is how most people use it. Its words were matched to the script with a sequence match on normalized tokens. It heard 591 words, 567 of the 593 script words matched, and the misses were things like "Hiii" (heard "Hi"), "queue" (heard "cue") and "ElevenLabs" (heard "Eleven Labs").

Drift against the alignment. For each source and each script word: start error in ms against the alignment, as median, 95th percentile and worst. Plus "right word lit": the share of 10 ms ticks inside a word during which that source highlights the same word.

The check that trusts nobody's timestamps. ffmpeg silencedetect (-35 dB, at least 0.2 s, touching pieces merged) finds the pauses inside the speech. At each pause we compare where each source says the next word starts with the moment the audio gets loud again, and where the previous word ends with the moment it went quiet. We also measure the share of silence during which a source has some word lit. This only tests word edges next to a pause (88 of them). Boundaries in the middle of a phrase have no silence to anchor to, so they are not checked against the audio here.

The per-clip numbers, the audio check and the snap counts are in the summary JSON linked at the bottom of this page. The scripts (measure.py, analyze.py, snap_words.py) and the every-word drift.json stay in our run folder.

Drift against the ElevenLabs timestamps

Median of the per-clip medians, in milliseconds. "p95" is the median of the per-clip 95th percentiles; "worst" is the single worst word.

Sourcev3 clips: median / p95 / worstv3: right word lit (mean)v4 clips: median / p95 / worstv4: right word lit (mean)
Even spacing600 / 1,430 / 3,90821%338 / 731 / 2,17230%
Length-weighted guess436 / 594 / 2,30626%125 / 295 / 79153%
Whisper large-v3107 / 240 / 65268%200 / 460 / 82036%

Per clip (start error vs the alignment, median / p95 / worst in ms):

ClipModelSecondsWordsEven spacingGuessWhisperWhisper, signed median
Daisy intro (DAISY-C1)v313.3632306 / 1,430 / 1,751186 / 467 / 550120 / 300 / 456-86
Daisy introv412.1632350 / 1,340 / 1,860128 / 378 / 457200 / 460 / 660-200
Theo, "Picture it like this"v412.8037325 / 683 / 76654 / 199 / 300240 / 380 / 540-240
Theo, same linev312.3237606 / 991 / 1,043223 / 401 / 49897 / 240 / 410-92
Theo, "Here's the trick"v411.6033164 / 659 / 698261 / 477 / 556200 / 460 / 480-200
Daisy outrov411.6031395 / 926 / 1,061118 / 255 / 300200 / 520 / 820-200
Daisy smoke testv45.6817381 / 664 / 83878 / 260 / 300220 / 360 / 380-220
Rafael smoke testv45.6015160 / 333 / 387180 / 290 / 300220 / 340 / 440-220
Test line, 8 sv38.0825278 / 731 / 867485 / 594 / 606107 / 220 / 333-107
Test line, 20 sv320.4050600 / 1,867 / 2,075436 / 1,277 / 1,406120 / 240 / 440-118
Test line, 17 sv417.2850307 / 778 / 915121 / 300 / 349180 / 480 / 820-180
Test line, 48 sv348.321172,368 / 3,582 / 3,9081,149 / 2,037 / 2,306106 / 360 / 652-91
Test line, 39 sv439.041171,097 / 1,958 / 2,172229 / 569 / 791180 / 460 / 720-180

Read that whisper column carefully. Against v3 it looks better than against v4, and the signed column says why: Whisper always starts words early, and so does v3 after a pause, so the two agree with each other. That's why you check against the audio.

Checked against the audio

At a pause (silencedetect, -35 dB)v3 alignmentv4 alignmentWhisper on v3 audioWhisper on v4 audio
Pauses measured40483947
Next word's start minus the moment sound returns (median)-346 ms+37 ms-419 ms-414 ms
Next word starts more than 100 ms early70%0%97%100%
Previous word's end minus the moment sound stops (median)-122 ms-55 ms-246 ms-258 ms
Share of measured silence with some word lit53%1%57%91%

The same two lines on both models, next word after a pause vs the audio (median): Daisy's intro -456 ms on v3 and +4 ms on v4; Theo's line -579 ms on v3 and -9 ms on v4. Same voice, same words, same settings, but v3 also held the written pauses longer (0.8 to 0.9 s in Daisy's line, against about 0.5 s on v4, which caps pauses near 0.55 s). So we also split by pause length. On pauses of 0.2 to 0.6 s, the only lengths both models make, v3 is a median 111 ms early (19 pauses) and v4 37 ms late (48). On v3's longer pauses it is 584 ms early (21). The model matters, and on v3 the error grows with the pause. We could not test v4 on long pauses because it doesn't make them.

One example you can see in the film: in Daisy's v3 intro the audio sits at -50 to -60 dB from 8.58 s to 9.50 s, and "Today" becomes audible at about 9.50 s. The v3 timestamps say "Today" runs from 8.84 to 9.84 s: one second for a two-syllable word, with the pause folded into its front.

Two more things the timestamps show:

  • eleven_v4 times word starts in 40 ms steps; v3 does not. 99.9% of v4 word starts sit on a 40 ms grid (within 2 ms); v3's land on it 37% of the time. On both models, inside a word, most letters get an equal share of the word's time, so treat the word as the unit, not the letter.
  • Punctuation eats the pause. ElevenLabs hangs the silence after a sentence (and all of a "...") on the punctuation characters. If you end a word on its last character including punctuation, a word is lit during 62% of the silence on v4 and 82% on v3. Ending on the last letter takes v4 down to 1%.

The fix for v3: snap starts out of silences

snap_words.py (in tests/captions-drift/): run ffmpeg silencedetect on the mp3 (-35 dB, at least 0.2 s, touching pieces merged). For each silence, the first word whose last letter ends after the silence gets start = max(start, silence_end - 0.03 s). Nothing else moves. The 30 ms lead lets the word light a hair before the sound, which reads as on time.

On the five v3 clips it moved 36 word starts, by a median of 351 ms and at most 883 ms, and took the lit-in-silence share from 53% to 5%. On the eight v4 clips it moved one word, by 10 ms. So it's safe to run on everything, and it only does real work on v3.

Be clear about what that number is: the snap uses the same silence detector we measure with, so "5% lit in silence" shows it did its job. It doesn't independently prove the new starts are right. What does: the waveform example above, and the fact that v4's own timestamps land where the snap puts v3's.

Page by phrase vs word highlight

The house Captions template (remotion/src/templates/Captions.tsx) pages the words into short lines. A page holds at most 7 words. A new page starts at a gap over 0.7 s, after 3.5 s, after a word ending in ., ? or !, or when the text would run past two lines. Each page holds up to 0.6 s past its last word, or until the next page starts, so it doesn't flicker. With "word highlight" the word being spoken is bold and pink; with "page by phrase" the page just sits there.

In our read (not measured), paging forgives small errors: only the page flips need to be right, and a page that turns 300 ms early during a pause reads fine. A 667 ms miss on a flip does not. In the film's third shot the raw v3 page with "Today" comes up 0.66 s before she says it. Word highlight shows every error: the even-spaced highlight runs visibly ahead and behind, and Whisper lights each word a beat before the voice gets there. The page-flip error (first word of each page) tells the same story as the word numbers: median 97 ms for Whisper on v3, 345 ms on v4; even spacing 507 ms on v3 and 265 ms on v4.

Our call: word highlight with the snapped ElevenLabs timestamps for host segments, because that's what makes captions feel attached to the face. Page by phrase for anything where you only have guessed timings.

Setup and gotchas

  • Use alignment, not normalized_alignment, for captions that match your script. normalized_alignment times the spoken expansion ("2026" becomes "twenty twenty-six"), which won't match the text you show.
  • The with-timestamps endpoint worked for both models on this account. No fallback fired in any call this run (tts.py falls back v4, then v3, then multilingual_v2 on a 400/404/422).
  • faster-whisper's audio decoder broke in our venv (av.open() got an unexpected keyword argument 'metadata_errors': a newer PyAV than faster-whisper 1.2.1 expects). Decoding with ffmpeg to 16 kHz mono float and passing the array works.
  • Whisper was not given the script. We didn't test forced alignment (ElevenLabs has a /v1/forced-alignment endpoint, billed like speech-to-text; WhisperX isn't installed here), and we didn't test Whisper with the script as an initial prompt. Those are the next tests if you have audio without timestamps.
  • Whisper also invents words on longer audio. We used it to check this page's 290-second narration against the script. With faster-whisper's default condition_on_previous_text=True it inserted three phrases nobody said ("the one thing that doesn't work", "hangs out with us to use", and a whole repeated sentence in place of "about 1% of the silence"). A second pass with that option off heard the script as written. As a caption source, that's text on screen that was never spoken.
  • The YouTube captions come from the same words. bin/youtube-package builds captions.en.srt from each block's words.json with the same paging rules as the template, so the site and YouTube match.
  • Loudness normalization doesn't move the timestamps. tts.py --loudnorm re-encodes through ffmpeg loudnorm; durations stayed identical in earlier measurements this run.

Settings

  • ElevenLabs: POST /v1/text-to-speech/{voice_id}/with-timestamps?output_format=mp3_44100_128. Cast settings from cast.json: Daisy (Jessica voice) stability 0.45, similarity 0.75, style 0.3; Theo stability 0.55, similarity 0.75, style 0.15; speaker boost on; then ffmpeg loudnorm I=-16. Test lines in the Jessica voice: stability 0.5, similarity 0.75, style 0.
  • Words: bin/tts.py words_from_alignment: start = first letter or digit, end = last letter or digit; end_punct kept separately.
  • Snap: snap_words.py, silencedetect n=-35dB:d=0.2, merge pieces closer than 50 ms, lead 30 ms.
  • Whisper: faster-whisper 1.2.1, ctranslate2 4.8.4, Systran/faster-whisper-large-v3, int8, CPU, 8 threads, beam 5, language en, word_timestamps=True, no VAD filter, no initial prompt.
  • Remotion 4.0.539: Captions template, style bar, 46 px Inter, max 7 words per page. The comparison renders and the player use a new composition, CaptionLab (remotion/src/players/CaptionLab.tsx), with the same paging.
  • Host clips: LTX 2.5 audio-to-video from each host's on-set still, 1280x704, 24 fps, seed 42, through bin/host-segments; the clean ElevenLabs mp3 is muxed over the result.

Speed and memory

  • ElevenLabs latency was not logged for this post (framework/TTS.md measured 4.8 s for 17 s of v4 audio and 8.4 s for 39 s). 2,939 characters were sent for this post's host, VO and comparison lines, plus the narration.
  • Grouping characters into words and snapping: milliseconds. silencedetect on a 48 s file: well under a second.
  • Whisper large-v3 int8 on 8 CPU threads: 506 s of wall-clock time for 218 s of audio (2.3 s per second overall; 0.85 to 4.6 per clip, slower on short clips), plus 3.8 s to load the model.
  • Remotion: the 58.8 s film took 87.7 s of wall time plus 10.3 s to encode, and the 175.5 s instructional 273.6 s plus 24.9 s, all on the CPU at concurrency 6. The AI host clips took 856 s of GPU for 49.4 s of video (17.3 s per second, LTX 2.5 audio-to-video).
  • Memory: not measured for these CPU jobs.

The measurements: per-clip drift, the audio check and the snap (JSON) ↗