Recipes / SubtitlesSign inGet an API key

Subtitles

Captions for lectures, hearings and videos — SRT and WebVTT built from timed segments.

How it works

Each segment's text is split into captions of at most two lines and seven seconds, and the segment's time is shared out by character count. Built-in SRT and WebVTT output and word-level timing are coming soon; this recipe works today and will get more precise when they arrive.

python subtitles.py lecture.mp3 te-IN    # writes lecture.srt and lecture.vtt

Tips

  • Use mode: "native" for broadcast-style captions, codemix for audiences who read English terms in English.
  • For a recording that is already a video, extract the audio track first (ffmpeg -i talk.mp4 -vn -c:a aac talk.m4a); video files are coming soon.
  • For proceedings with several speakers, add diarization: true and prefix captions with the speaker.

The script

subtitles.py
"""Subtitles: SRT and WebVTT from a transcript's timed segments.

    python subtitles.py lecture.mp3 te-IN        # writes lecture.srt and lecture.vtt

Needs TWENTYSIX_API_KEY and TWENTYSIX_BASE_URL. Long segments are split into captions of at most two lines and
`max_s` seconds, with time shared out by character count (word-level timing is coming soon).
"""
import math
import os
import sys

from twentysix import Client


def captions(segments, max_chars=42, max_lines=2, max_s=7.0):
    """-> [(start, end, text)]. A segment becomes as many captions as its length and duration need; words are shared
    out evenly by character count and so is the segment's time."""
    out = []
    for s in segments:
        words = s["text"].split()
        if "start" not in s or not words:
            continue
        text, dur = " ".join(words), max(0.0, s["end"] - s["start"])
        n = min(len(words), max(1, math.ceil(len(text) / (max_chars * max_lines)), math.ceil(dur / max_s)))
        groups, cur, done = [], [], 0
        for w in words:
            cur.append(w)
            done += len(w) + 1
            if len(groups) < n - 1 and done >= len(text) * (len(groups) + 1) / n:
                groups.append(" ".join(cur))
                cur = []
        if cur:
            groups.append(" ".join(cur))
        total, t = sum(len(g) for g in groups), s["start"]
        for g in groups:
            end = t + dur * len(g) / total
            out.append((round(t, 3), round(end, 3), wrap(g, max_chars)))
            t = end
    return out


def wrap(text, width):
    """Break a caption into two lines at the space nearest its middle."""
    if len(text) <= width or " " not in text:
        return text
    mid = len(text) // 2
    cut = min((i for i, ch in enumerate(text) if ch == " "), key=lambda i: abs(i - mid))
    return text[:cut] + "\n" + text[cut + 1:]


def ts(sec, sep):
    ms = int(round(sec * 1000))
    return f"{ms // 3600000:02d}:{ms // 60000 % 60:02d}:{ms // 1000 % 60:02d}{sep}{ms % 1000:03d}"


def to_srt(caps):
    return "\n".join(f"{i}\n{ts(a, ',')} --> {ts(b, ',')}\n{text}\n" for i, (a, b, text) in enumerate(caps, 1))


def to_vtt(caps):
    return "WEBVTT\n\n" + "\n".join(f"{ts(a, '.')} --> {ts(b, '.')}\n{text}\n" for a, b, text in caps)


if __name__ == "__main__":
    path, language = sys.argv[1], (sys.argv[2] if len(sys.argv) > 2 else "hi-IN")
    with Client() as client:
        result = client.transcribe_file(path, language, mode="native")
    caps = captions(result["segments"])
    stem = os.path.splitext(path)[0]
    open(stem + ".srt", "w", encoding="utf-8").write(to_srt(caps))
    open(stem + ".vtt", "w", encoding="utf-8").write(to_vtt(caps))
    print(f"{len(caps)} captions → {stem}.srt, {stem}.vtt")
Scriba is a product of 26AI Private Limited, Pune, India.© 2026 26AI Private Limited