Bekzod Erkinov
Listen to Article
Loading...Table of contents · 9 sections
Word Timestamps to SRT in Python: A Tested Converter
Speech-to-text APIs hand you a list of words with start and end times, and a subtitle player wants cues with line limits and clean HH:MM:SS,mmm timestamps. This post gives you one standard-library Python file (120 lines, Python 3.12.10) that turns the first into SRT and WebVTT, plus its tests, and shows the rounding bug that produces 00:00:60,000.
The ElevenLabs link below is the only affiliate link in this post; see our disclosure.
No audio was transcribed here. The sample is synthetic: I wrote 40 words and generated the timings with a script. Nothing in this post says anything about transcription accuracy.
What the input looks like
Providers differ, so check yours. ElevenLabs returns word-level timestamps, and its speech-to-text convert reference documents these fields on each item of the words list (I read that page on 2026-10-12):
text: "The word or sound that was transcribed."type:word,spacingoraudio_eventstartandend: "The start time of the word or sound in seconds." Both are optional and nullable.logprob,speaker_id,characters,channel_index
I did not call the live API, so the converter was only run against that documented shape. For another provider, adapt clean_words() and leave the rest alone. clean_words() skips spacing and audio_event entries and entries with a null start or end. Skipping an untimed real word silently loses text from the captions; if your data has those, count them and log the number.
The naive formatter fails near a minute mark
Most snippets split into h/m/s, round the seconds to three places, and format. That is naive_timestamp() in the script below. Output from running it next to fmt():
59.9996 | naive: 00:00:60,000 | fixed: 00:01:00,000
3599.9996 | naive: 00:59:60,000 | fixed: 01:00:00,000
12.3456 | naive: 00:00:12,346 | fixed: 00:00:12,346
The minutes come from the unrounded value and the seconds from the rounded one, so the carry never happens. The WebVTT spec defines the seconds field as "Two ASCII digits, representing the seconds as a base ten integer in the range 0 ≤ seconds ≤ 59", so 60 is not a valid value there. I did not test how individual players react to it.
The fix: round once, then use integer math
fmt() converts to whole milliseconds first, and every field above carries correctly. The sep argument is the only difference between SRT (,) and WebVTT (.); the same spec says the millisecond separator is a full stop and the file must start with WEBVTT.
A second gotcha turned up when one of my own tests failed. I'd asserted that 1.2345 formats as .235, and my first version, using plain round(t * 1000), returned .234. Python's round() sends halves to the even neighbour. Real output:
>>> round(1234.5), round(1235.5), round(1.2345 * 1000)
(1234, 1236, 1234)
So fmt() goes through Decimal(str(t)) (the shortest decimal form of the float) with ROUND_HALF_UP, which gives behaviour I can state in a test.
The third-party srt library has a timedelta_to_srt_timestamp helper. Use it if you already parse and compose SRT with that library; I did not test it here.
The full script
Save as subs.py. Cues are grouped by four rules, checked in this order: line length (more than max_lines wrapped lines closes the cue), duration (max_dur, 6 s), a pause of pause seconds (0.7) between words, and a sentence-ending word (. ? ! …). Two-line cues are rebalanced so the second line isn't a stub, and fix_overlaps() trims a cue's end back to the next cue's start.
"""Word timestamps -> SRT / WebVTT. Standard library only."""
import json
from decimal import Decimal, ROUND_HALF_UP
import sys
import textwrap
from dataclasses import dataclass
SENTENCE_END = (".", "?", "!", "…")
@dataclass
class Cue:
start: float
end: float
text: str
def naive_timestamp(t):
"""The version that looks right and isn't."""
h, rem = divmod(int(t), 3600)
m = rem // 60
sec = round(t % 60, 3)
return f"{h:02d}:{m:02d}:{sec:06.3f}".replace(".", ",")
def fmt(t, sep=","):
"""Round once, to whole milliseconds, then split with integer math."""
# not round(): Python rounds halves to even, so round(1.2345 * 1000) is 1234
total_ms = int((Decimal(str(max(t, 0))) * 1000).quantize(Decimal(1), ROUND_HALF_UP))
h, rem = divmod(total_ms, 3_600_000)
m, rem = divmod(rem, 60_000)
s, ms = divmod(rem, 1000)
return f"{h:02d}:{m:02d}:{s:02d}{sep}{ms:03d}"
def wrap_lines(text, max_chars):
"""Greedy wrap, then rebalance two-line cues so no line is a lone orphan word."""
lines = textwrap.wrap(text, width=max_chars)
if len(lines) != 2:
return lines
words = text.split()
best = min(
(max(len(" ".join(words[:k])), len(" ".join(words[k:]))), k)
for k in range(1, len(words))
)
k = best[1]
return [" ".join(words[:k]), " ".join(words[k:])]
def clean_words(raw):
"""Keep timed 'word' entries; drop spacing/audio_event and untimed items."""
out = []
for w in raw:
if w.get("type", "word") != "word":
continue
if w.get("start") is None or w.get("end") is None:
continue
out.append((w["text"].strip(), float(w["start"]), float(w["end"])))
return out
def build_cues(raw, max_chars=42, max_lines=2, max_dur=6.0, pause=0.7):
words = clean_words(raw)
cues, cur = [], []
def flush():
if cur:
text = " ".join(t for t, _, _ in cur)
lines = wrap_lines(text, max_chars)
cues.append(Cue(cur[0][1], cur[-1][2], "\n".join(lines)))
cur.clear()
for i, (text, start, end) in enumerate(words):
if cur:
joined = " ".join(t for t, _, _ in cur) + " " + text
too_long = len(textwrap.wrap(joined, width=max_chars)) > max_lines
too_slow = end - cur[0][1] > max_dur
gap = start - cur[-1][2] >= pause
if too_long or too_slow or gap:
flush()
cur.append((text, start, end))
if text.endswith(SENTENCE_END):
flush()
flush()
return fix_overlaps(cues)
def fix_overlaps(cues):
"""Trim a cue's end back to the next cue's start if they overlap."""
for a, b in zip(cues, cues[1:]):
if a.end > b.start:
a.end = b.start
return cues
def chars_per_second(cue):
"""Reading speed: characters (line break counted as a space) per second."""
return len(cue.text) / (cue.end - cue.start)
def to_srt(cues):
blocks = [f"{i}\n{fmt(c.start)} --> {fmt(c.end)}\n{c.text}"
for i, c in enumerate(cues, 1)]
return "\n\n".join(blocks) + "\n"
def to_vtt(cues):
blocks = [f"{fmt(c.start, '.')} --> {fmt(c.end, '.')}\n{c.text}" for c in cues]
return "WEBVTT\n\n" + "\n\n".join(blocks) + "\n"
if __name__ == "__main__":
data = json.load(open(sys.argv[1], encoding="utf-8"))
max_chars = int(sys.argv[2]) if len(sys.argv) > 2 else 42
cues = build_cues(data["words"], max_chars=max_chars)
open("out.srt", "w", encoding="utf-8").write(to_srt(cues))
open("out.vtt", "w", encoding="utf-8").write(to_vtt(cues))
print(len(cues), "cues")
for c in cues:
print(f"{c.end - c.start:5.2f}s {len(c.text):3d} chars {chars_per_second(c):5.1f} chars/s")
Here is the line balancing on the third cue at 32 characters, real output:
>>> textwrap.wrap('Most converters break at the worst moment,', 32)
['Most converters break at the', 'worst moment,']
>>> subs.wrap_lines('Most converters break at the worst moment,', 32)
['Most converters break', 'at the worst moment,']
fix_overlaps() only guarantees what it checks: a single word longer than max_dur still becomes its own over-long cue.
Real output on the synthetic sample
make_sample.py writes 40 words with a 1.2 s pause before "right", and shifts the timeline so the last word ends at exactly 59.9996 s, to hit the bug:
"""SYNTHETIC 40-word sample in the documented words[] shape. Not a transcription."""
import json
text = ("Welcome back to the show. Today we are fixing subtitles. "
"Most converters break at the worst moment, "
"right when a cue lands near a minute mark. "
"So we round once and test the edges. Then we ship it for real.")
toks = text.split()
assert len(toks) == 40
words, t = [], 0.0
for i, tok in enumerate(toks):
if i == 17: t += 1.2 # deliberate pause before "right"
dur = 0.15 + 0.03 * len(tok)
words.append({"text": tok, "start": t, "end": t + dur, "type": "word", "logprob": 0.0})
t += dur + 0.05
shift = 59.9996 - words[-1]["end"] # make the last word end at 59.9996 s
for w in words:
w["start"] = round(w["start"] + shift, 4); w["end"] = round(w["end"] + shift, 4)
json.dump({"language_code": "eng", "text": text, "words": words}, open("sample.json", "w"), indent=1)
print(len(words), "words, first start", words[0]["start"], "last end", words[-1]["end"])
I ran the converter with max_chars=32 so the line breaking shows:
$ python make_sample.py
40 words, first start 45.8696 last end 59.9996
$ python subs.py sample.json 32
6 cues
1.58s 25 chars 15.8 chars/s
1.73s 30 chars 17.3 chars/s
2.43s 42 chars 17.3 chars/s
2.77s 42 chars 15.2 chars/s
2.42s 36 chars 14.9 chars/s
1.75s 25 chars 14.3 chars/s
out.srt:
1
00:00:45,870 --> 00:00:47,450
Welcome back to the show.
2
00:00:47,500 --> 00:00:49,230
Today we are fixing subtitles.
3
00:00:49,280 --> 00:00:51,710
Most converters break
at the worst moment,
4
00:00:52,960 --> 00:00:55,730
right when a cue lands
near a minute mark.
5
00:00:55,780 --> 00:00:58,200
So we round once
and test the edges.
6
00:00:58,250 --> 00:01:00,000
Then we ship it for real.
The cue before the pause ends at 51,710 and the next starts at 52,960, so the silence stays a gap instead of a held caption. The last cue ends at 00:01:00,000, where the naive formatter printed 60,000. The first lines of out.vtt:
WEBVTT
00:00:45.870 --> 00:00:47.450
Welcome back to the show.
Checking reading speed
Line limits say nothing about whether a viewer can read a cue in time, so the CLI prints characters per second per cue (the chars/s column above, from chars_per_second(), tested). Those values are a property of my generated timings, not of real speech. For a ceiling, the Netflix English Timed Text Style Guide says "Adult programs: Up to 20 characters per second" and "42 characters per line" (I read it on 2026-10-12); your platform may differ. If cues on your own transcript exceed your ceiling, lower max_chars or max_dur so the text spreads over more cues.
Tests
Save as test_subs.py next to subs.py and sample.json. Python 3.12.10 on this machine; python -m unittest reported Ran 13 tests ... OK.
import json, unittest
from subs import Cue, fmt, naive_timestamp, build_cues, fix_overlaps, to_srt, to_vtt, clean_words, chars_per_second
def W(text, s, e, type="word"):
return {"text": text, "start": s, "end": e, "type": type, "logprob": 0.0}
class Timestamps(unittest.TestCase):
def test_naive_is_broken(self):
self.assertEqual(naive_timestamp(59.9996), "00:00:60,000")
def test_rounding_carries(self):
self.assertEqual(fmt(59.9996), "00:01:00,000")
self.assertEqual(fmt(3599.9996), "01:00:00,000")
def test_basic(self):
self.assertEqual(fmt(0), "00:00:00,000")
self.assertEqual(fmt(3661.5), "01:01:01,500")
self.assertEqual(fmt(1.2345, "."), "00:00:01.235")
def test_ms_never_1000(self):
for i in range(0, 200000, 7):
t = i / 1000 + 0.0004999
self.assertNotIn(",1000", fmt(t))
self.assertLessEqual(int(fmt(t)[-3:]), 999)
class Cues(unittest.TestCase):
def test_sentence_break(self):
c = build_cues([W("Hi.", 0, .3), W("Bye.", .35, .6)])
self.assertEqual([x.text for x in c], ["Hi.", "Bye."])
def test_pause_break(self):
c = build_cues([W("one", 0, .3), W("two", 2.0, 2.3)], pause=0.7)
self.assertEqual(len(c), 2)
def test_max_duration(self):
ws = [W(f"w{i}", i * .5, i * .5 + .4) for i in range(30)]
for c in build_cues(ws, max_dur=3.0, max_chars=200):
self.assertLessEqual(c.end - c.start, 3.0 + 1e-9)
def test_line_limits(self):
ws = [W("word", i * .3, i * .3 + .25) for i in range(40)]
for c in build_cues(ws, max_chars=20, max_dur=60):
lines = c.text.split("\n")
self.assertLessEqual(len(lines), 2)
self.assertTrue(all(len(l) <= 20 for l in lines))
def test_no_overlap(self):
cues = fix_overlaps([Cue(0, 5, "a"), Cue(3, 6, "b"), Cue(6, 7, "c")])
for a, b in zip(cues, cues[1:]):
self.assertLessEqual(a.end, b.start)
def test_skips_spacing_and_untimed(self):
raw = [W("a", 0, .2), W(" ", .2, .3, "spacing"), W("laugh", 1, 2, "audio_event"),
{"text": "x", "start": None, "end": None, "type": "word"}, W("b", .3, .5)]
self.assertEqual([w[0] for w in clean_words(raw)], ["a", "b"])
class Speed(unittest.TestCase):
def test_chars_per_second(self):
self.assertAlmostEqual(chars_per_second(Cue(0, 2, "ab cd")), 2.5)
class Output(unittest.TestCase):
def test_srt_vtt(self):
cues = [Cue(0, 1.5, "hello"), Cue(59.9996, 61, "x")]
srt, vtt = to_srt(cues), to_vtt(cues)
self.assertIn("1\n00:00:00,000 --> 00:00:01,500\nhello", srt)
self.assertIn("2\n00:01:00,000 --> 00:01:01,000\nx", srt)
self.assertTrue(vtt.startswith("WEBVTT\n\n"))
self.assertIn("00:00:00.000 --> 00:00:01.500", vtt)
def test_sample_end_to_end(self):
with open("sample.json") as f:
words = json.load(f)["words"]
cues = build_cues(words, max_chars=32)
self.assertEqual(cues[-1].end, 59.9996)
self.assertIn("00:01:00,000", to_srt(cues))
if __name__ == "__main__":
unittest.main()
The fmt sweep tests every 7 ms up to 200 s with a 0.4999 ms offset and checks the milliseconds never print as 1000.
When to reach for something else
This script handles timestamps, line limits, pauses and two output formats. It does nothing about the words themselves: speaker labels (the docs list speaker_id), punctuation fixes and non-English line-break rules are yours to add. Decision rule: if you run files through a pipeline, keep the script, since you can test it; if you need captions for a handful of videos and don't want to maintain code, use an editor. AssemblyAI's tutorial shows the same word-timing-to-captions flow with a different provider.
What to do next
- Dump one real response from your provider and compare its
wordsfields toclean_words(); change only that function. - Tune
max_chars,max_durandpauseagainst a real transcript pasted in as a fixture. - Add a test using a word whose
endis after the next word'sstart, taken from your own data. - Load the
.vttin a browser<track>element and the.srtin your video editor before shipping.
Keep reading
Orbit Determination Program (ODP) vs ORBIT14 vs ESOC: What Public Papers Say, Plus a Python Batch Fit
10 min · 245 views
AI TutorialsReal-World Examples of AI Integration in Web Development
19 min · 162 views
AI Tutorials5 Essential Developer Tools Worth Installing, Measured on a Real Repo
7 min · 151 views
Bekzod Erkinov
AuthorFounder of NextGenBeing. Software engineer working with Laravel, Python, and cloud infrastructure. Writes about patterns that actually hold up in production. Based in Tashkent, Uzbekistan.
Get the AI-Assisted Developer's Field Guide
The workflow, prompts, and tools I use to ship faster with AI — free when you subscribe. Plus new deep-dives in your inbox. No spam, unsubscribe anytime.
Comments (0)
Please log in to leave a comment.
Log In