As of mid-2026 the Mojo ecosystem has no library for reading subtitle or transcript files. mojo-captions fills that gap: a liberal SRT/WebVTT parser, two serializers, and a handful of transcript utilities. I built it to work with Chain of Thought's own episode transcripts: pulling quotes for show notes, picking the timestamp window for a clip, and turning an SRT or VTT export into a clean transcript.
There is no Python stdlib equivalent for subtitles — the closest third-party
library is webvtt-py. The rough mapping:
Python (webvtt-py, closest) |
mojo-captions |
|---|---|
vtt = webvtt.read("f.vtt") |
var caps = parse_captions(source) |
for c in vtt: c.text |
for cue in caps.cues: cue.text |
c.start / c.end |
cue.start_ms / cue.end_ms |
vtt.save_as_srt(...) |
to_srt(caps) (also to_vtt(caps)) |
mojo-captions parses both SRT and WebVTT (detected automatically), and adds
transcript helpers with no webvtt-py parallel: plain_text(caps),
cues_between(caps, start_ms, end_ms), and duration_ms(caps).
- Auto-detection: a leading
WEBVTTheader means WebVTT, anything else parses as SubRip. - Timestamps: SRT comma (
00:01:02,345) and VTT dot (00:01:02.345) millisecond separators, with optional hours, accepted in either format. - Speakers: WebVTT voice spans (
<v Name>text</v>, including<v.class Name>annotations) and the plainSpeaker Name: textconvention on a cue's first line, guarded to a 48-byte prefix so it doesn't eat markup or an ordinary sentence's colon. - WebVTT extras: NOTE, STYLE, and REGION blocks skipped entirely; cue
settings after the timing arrow (
position:,align:, ...) dropped; numeric cue identifiers preserved, non-numeric ones replaced by document position. - CRLF and LF line endings, and a UTF-8 BOM on either format.
- Liberal parsing: a cue block with no timing line or an unparseable timestamp is skipped, never fatal. An empty document just returns zero cues.
- Glued-cue recovery: two cues packed together with no blank-line separator are still split correctly instead of the first cue swallowing the second's index, timing, and text.
- Round-trip serialization:
to_srtandto_vttreconstruct a document fromCaptions, with a representability guard: a speaker too long for theName:prefix, or containing:,<, or>, is left out of the SRT text (kept on the in-memoryCue) rather than risk an unparseable or corrupted re-parse. - Transcript utilities:
plain_textfor a timestamp-free transcript,cues_betweenfor the cues overlapping a time window,duration_msfor the document's total length.
- Style inline markup beyond voice spans.
<b>,<i>, karaoke timing tags, and similar are left verbatim in cue text in v0.1. - Normalize encodings. UTF-16 and Latin-1 documents aren't transcoded yet; pass UTF-8 text in.
- Model VTT positioning. Cue settings (
position:,line:,align:, ...) are dropped during parsing rather than represented as data.
With pixi:
pixi install
pixi run testOr with uv:
uv venv
uv pip install mojo --index https://whl.modular.com/nightly/simple/ --prerelease allow
.venv/bin/mojo run -I src test/test_captions.mojoRequires a Mojo nightly (>=1.0.0b3).
from captions import parse_captions, cues_between, plain_text
def main() raises:
var caps = parse_captions(open("episode.srt", "r").read())
print(caps) # Captions(srt, 512 cues)
for cue in caps.cues:
print(cue.start_ms, cue.speaker, cue.text)
# Cues covering the second minute of the episode, for a clip:
var clip = cues_between(caps, 60_000, 120_000)
print(len(clip))
print(plain_text(caps)) # transcript, no timestampsCue fields: index, start_ms, end_ms, speaker, text. Empty
string means the field was absent, mirroring mojo-feed's model conventions.
pixi run test29 tests cover format detection, both timestamp separators, voice-span and
colon-convention speaker extraction, NOTE/STYLE/REGION handling, cue
settings, CRLF/BOM, glued cues, and round-trip serialization through both
fixture and hand-built documents. Fuzz-tested (test/fuzz_runner.mojo)
against 1,300+ mutated documents (byte flips, truncations, and structural
splices) with zero crashes and zero hangs: malformed input either parses
liberally or is skipped, never fatal.
Eleven pure-Mojo libraries that mirror familiar Python stdlib and PyPI APIs, filling gaps in the native Mojo ecosystem:
- mojo-xml — general-purpose XML
parsing, an ElementTree-shaped DOM (Python's
xml.etree.ElementTree) - mojo-feed — RSS, Atom, and
JSON Feed parsing (Python's
feedparser) - mojo-html — HTML parsing and article extraction (Python's readability)
- mojo-markdown —
CommonMark markdown parsing (Python's
markdown) - mojo-unicodedata —
Unicode normalization and case folding (Python's
unicodedata) - mojo-diff — text diffing
(Python's
difflib) - mojo-template — a
Jinja-flavored template engine (Python's
jinja2) - mojo-tar — tar archive
reading and writing (Python's
tarfile) - mojo-redis — a Redis
client (Python's
redis-py) - mojo-url — URL parsing
and encoding (Python's
urllib.parse)
Issues and PRs welcome, especially real-world caption files that parse
wrong (attach the file or a snippet) and edge cases in the speaker
conventions. Run pixi run test before sending a PR.
Built by Conor Bronsdon — host of Chain of Thought, a podcast about AI agents, infrastructure, and engineering. This library exists to parse that show's own episode transcripts. Find me on X or LinkedIn.
This is an independent personal project, not affiliated with, sponsored by, or endorsed by any company. All views expressed are my own.
Licensed under the MIT License.
