Content & Marketing

Generate SRT Subtitles with Python and AI

Turn a Whisper transcript into a caption file players accept: segment timestamps, SRT timecodes, two-line cues, a validator, and a translated subtitle track.

By the end of this guide you will have a Python script that takes an audio or video file, asks the Whisper API for a transcript with timestamps, and writes a captions.srt file that YouTube, Vimeo, VLC, Premiere and every social platform will accept without complaint. It also splits over-long lines so viewers can actually read them, and produces a second file translated into another language that keeps the original timings. Budget about forty minutes for the first run.

This is one guide in AI Transcription and Content Repurposing with Python, the section that covers turning spoken audio into text and then into everything else. If you have never called the transcription endpoint before, read Transcribe Audio with the Whisper API and Python first — this guide assumes you already have a working transcription call and picks up from there.

Prerequisites

You need Python 3.10 or newer in a virtual environment (an isolated folder holding this project's packages, so upgrades here cannot break another project). If that is new to you, Create a Python Virtual Environment for AI walks through it.

python -m venv .venv
source .venv/bin/activate        # Windows: .venv\Scripts\activate
pip install "openai>=1.40" "python-dotenv>=1.0"
pip freeze > requirements.txt

Nothing else is required — the caption logic is plain Python, no subtitle library involved. Put your key in a .env file beside your script:

OPENAI_API_KEY=sk-your-key-here

Then keep that file out of version control before you write another line:

echo ".env" >> .gitignore

You also need an audio file the API accepts: mp3, mp4, m4a, wav, webm and a few others, under the endpoint's upload size limit. If your recording is a long video, extract the audio track with ffmpeg first — the audio is a fraction of the size and the captions come out identical.

Step 1 — Ask Whisper for segments, not just text

The default transcription response hands you one wall of text with no timing information, which is useless for captions. Set response_format="verbose_json" and request segment-level granularity, and the API returns a list of segments instead: each one has a start time, an end time (both in seconds, as decimals) and the text spoken in between. Those three fields are the raw material for every cue you will write.

Save the segments to disk as soon as you get them. Transcription costs money and takes real time on a long file, and you will re-run the formatting code many times while you tune line lengths.

import json
import os
from pathlib import Path

from dotenv import load_dotenv
from openai import OpenAI

load_dotenv()  # reads .env, which is listed in .gitignore

client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
audio_path = Path("episode.mp3")

with audio_path.open("rb") as audio_file:
    result = client.audio.transcriptions.create(
        model="whisper-1",
        file=audio_file,
        response_format="verbose_json",
        timestamp_granularities=["segment"],
    )

data = result.model_dump()
segments = [
    {"start": s["start"], "end": s["end"], "text": s["text"].strip()}
    for s in data["segments"]
]

Path("segments.json").write_text(
    json.dumps(segments, indent=2, ensure_ascii=False), encoding="utf-8"
)
print(f"{len(segments)} segments, audio ends at {segments[-1]['end']:.2f}s")

A segment is roughly a sentence or a breath group, so a thirty-minute episode typically yields a few hundred of them. That is the shape of the whole job: segments in, cues out, with two transformations in between — one that fixes the time format and one that fixes the line lengths.

From an audio file to a validated SRT caption file A six-stage flow: the audio file is sent to the Whisper API with verbose_json, which returns a segment list; the segments are split into readable cues, their times are formatted as SRT timecodes, and the numbered blocks are written to captions.srt. Audio or video episode.mp3 Whisper request verbose_json Segment list start, end, text Split long cues 42 chars per line Format the time HH:MM:SS,mmm captions.srt numbered blocks
The API supplies timings; everything after the segment list is ordinary Python string work that you can re-run offline as often as you like.

Step 2 — Convert seconds into SRT timecodes

Whisper reports 64.12. An SRT file wants 00:01:04,120. The conversion is arithmetic, but two details trip people up. The separator before the milliseconds is a comma, not a period — a period is WebVTT syntax and many players reject the file or ignore every cue. And each field is zero-padded to a fixed width: two digits for hours, minutes and seconds, three for milliseconds, always, even at the very start of the video.

Work in whole milliseconds rather than floats. Rounding once, at the top of the function, avoids the drift you get when you multiply and divide decimals repeatedly down the file.

def to_timecode(seconds: float) -> str:
    """Convert 64.12 into the SRT timecode 00:01:04,120."""
    seconds = max(0.0, float(seconds))
    total_ms = int(round(seconds * 1000))
    hours, remainder = divmod(total_ms, 3_600_000)
    minutes, remainder = divmod(remainder, 60_000)
    whole_seconds, millis = divmod(remainder, 1000)
    return f"{hours:02d}:{minutes:02d}:{whole_seconds:02d},{millis:03d}"


def from_timecode(stamp: str) -> float:
    """Convert 00:01:04,120 back into 64.12 seconds."""
    hours, minutes, rest = stamp.split(":")
    whole_seconds, millis = rest.split(",")
    return int(hours) * 3600 + int(minutes) * 60 + int(whole_seconds) + int(millis) / 1000


if __name__ == "__main__":
    for value in (0, 4.5, 64.12, 3725.987):
        print(value, "->", to_timecode(value))

Run that file on its own and you should see 0 -> 00:00:00,000, 4.5 -> 00:00:04,500, 64.12 -> 00:01:04,120 and 3725.987 -> 01:02:05,987. The from_timecode twin is not needed to write a file, but you will use it in Step 4 to check your own output, and it is what any tool that reads captions back does internally.

Those timecodes sit in a strict four-part block. Getting the block's shape exactly right matters more than it looks: parsers are old, terse and unforgiving, and a missing blank line at the end of a cue is enough to make a player stop reading at that point in the file.

The five lines that make up one SRT cue An annotated breakdown of a single SRT block: the cue number, the start and end timecode line joined by an arrow, up to two lines of caption text, and the blank line that closes the cue. One SRT cue What it means 12 Cue number counts up from 1 00:01:04,120 --> 00:01:07,480 Start and end comma before ms Whisper gives you Caption line 1 42 chars at most the timestamps Caption line 2 never a third line (blank line) Blank line closes the cue
Every cue is number, timecode line, one or two text lines, blank line — in that order, with no extra whitespace anywhere else.

Step 3 — Split long segments and re-time the pieces

Whisper segments follow speech, not screen space. A speaker who talks for eleven seconds without pausing produces one segment with two hundred characters in it, and dumping that into a cue gives you a wall of text that covers half the frame and vanishes before anyone finishes reading.

The convention broadcasters use is roughly 42 characters per line, two lines maximum — about 84 characters per caption. When a segment exceeds that, break the text at word boundaries into chunks that fit, then divide the segment's duration between the chunks in proportion to their character counts. A chunk holding a third of the characters gets a third of the time. It is an approximation, but it tracks natural speech closely enough that nobody notices, and it guarantees the cues stay inside the original segment's window so they never drift out of sync with the audio.

Deciding how to break one Whisper segment into cues A decision tree keyed on the character length of a segment: 42 or fewer becomes a single line, 43 to 84 wraps onto two lines in the same cue, and anything longer is split into several cues whose durations are shared out by character count. Segment length? characters in text 42 or fewer one caption line 43 to 84 chars wrap onto two lines More than 84 split into two cues Emit one line timings unchanged Keep the timings one cue, two lines Re-time the split by character share
Only the third branch changes any timings, and even then the new cues stay inside the original segment's start and end.

Two small helpers do the work. wrap_two_lines fills a line greedily up to 42 characters and starts a new one; chunk_text does the same thing at the 84-character cue limit. split_segment then glues them together and shares out the duration.

MAX_LINE = 42
MAX_LINES = 2
MAX_CUE = MAX_LINE * MAX_LINES  # 84 characters in one caption


def pack(text: str, limit: int) -> list[str]:
    """Greedily fill pieces of at most `limit` characters, breaking only at spaces."""
    pieces, current = [], ""
    for word in text.split():
        candidate = f"{current} {word}".strip()
        if len(candidate) <= limit or not current:
            current = candidate
        else:
            pieces.append(current)
            current = word
    if current:
        pieces.append(current)
    return pieces


def wrap_two_lines(text: str) -> list[str]:
    return pack(text, MAX_LINE)[:MAX_LINES]


def split_segment(segment: dict) -> list[dict]:
    """Turn one Whisper segment into one or more readable, correctly timed cues."""
    chunks = pack(segment["text"], MAX_CUE)
    start, end = float(segment["start"]), float(segment["end"])

    if len(chunks) <= 1:
        return [{"start": start, "end": end, "lines": wrap_two_lines(segment["text"])}]

    total_chars = sum(len(chunk) for chunk in chunks)
    duration = end - start
    cues, cursor = [], start
    for position, chunk in enumerate(chunks, start=1):
        share = len(chunk) / total_chars
        stop = end if position == len(chunks) else cursor + duration * share
        cues.append({"start": cursor, "end": stop, "lines": wrap_two_lines(chunk)})
        cursor = stop
    return cues


cues = [cue for segment in segments for cue in split_segment(segment)]
print(f"{len(segments)} segments became {len(cues)} cues")

Forcing the final chunk to end exactly at the segment's end stops rounding error accumulating across a long file. The not current clause in pack guards against a single word longer than the limit — a URL read aloud, say — which would otherwise append an empty piece and produce an invalid cue. Chunking text to fit a size budget is a pattern you will meet again in Split Long Text into Chunks for AI APIs, where the limit is tokens rather than screen width.

Step 4 — Write the file, then read it back to prove it works

Rendering is now trivial: number each cue from 1, join the timecodes with a space-arrow-space separator, put the text lines underneath, and separate blocks with a blank line. End the file with a newline.

The important half of this step is the second function. Never trust a caption file you have not parsed. A validator that reads your own output back catches numbering gaps, reversed timings, overlapping cues and stray third lines in a second, long before you upload anything.

import re
from pathlib import Path

TIMECODE = r"\d{2}:\d{2}:\d{2},\d{3}"
CUE_RE = re.compile(rf"^(\d+)\n({TIMECODE}) --> ({TIMECODE})\n(.+)$", re.S)


def render_srt(cues: list[dict]) -> str:
    blocks = []
    for index, cue in enumerate(cues, start=1):
        timing = f"{to_timecode(cue['start'])} --> {to_timecode(cue['end'])}"
        body = "\n".join(cue["lines"])
        blocks.append(f"{index}\n{timing}\n{body}\n")
    return "\n".join(blocks)


def validate_srt(path: Path) -> int:
    raw = path.read_text(encoding="utf-8").strip("\n")
    blocks = re.split(r"\n\s*\n", raw)
    previous_end = 0.0
    for position, block in enumerate(blocks, start=1):
        match = CUE_RE.match(block)
        if match is None:
            raise ValueError(f"Block {position} is not a valid SRT cue:\n{block}")
        number, start, end, body = match.groups()
        if int(number) != position:
            raise ValueError(f"Cue numbers must count up: expected {position}, found {number}")
        start_s, end_s = from_timecode(start), from_timecode(end)
        if end_s <= start_s:
            raise ValueError(f"Cue {position} ends at or before it starts")
        if start_s < previous_end:
            raise ValueError(f"Cue {position} overlaps the previous cue")
        if len(body.splitlines()) > MAX_LINES:
            raise ValueError(f"Cue {position} has more than {MAX_LINES} lines")
        previous_end = end_s
    return len(blocks)


output = Path("captions.srt")
output.write_text(render_srt(cues), encoding="utf-8")
print(f"wrote {validate_srt(output)} valid cues to {output}")

Always pass encoding="utf-8" on both the write and the read. Caption text is full of accented characters, curly quotes and em dashes, and on Windows the default encoding will mangle or reject them. Open the finished file in VLC alongside the video for a final human check; if the first caption appears at the right moment, the rest of the file is almost certainly fine.

Step 5 — Translate the captions without touching the timings

A translated subtitle track is the same cue list with different words. The timings do not move, because the speaker still says the same thing at the same moment. So translate only the lines of each cue, copy start and end across untouched, and re-wrap the result to 42 characters — translations change length, and German or Spanish output can easily need two lines where English needed one.

The risk is a model that helpfully merges two short cues into one flowing sentence, which silently shifts every caption after it. Prevent that by sending numbered items and demanding the same ids back as JSON. Batching forty cues per request keeps the context small enough that the model stays disciplined and keeps the number of calls low; see Estimate OpenAI API Costs with Python if you want to price a full episode before you run it.

import json


def translate_cues(cues: list[dict], target_language: str, batch_size: int = 40) -> list[dict]:
    """Translate cue text into `target_language`, keeping every start and end time."""
    instructions = (
        "You translate video subtitles. Reply with JSON shaped as "
        '{"items": [{"id": <same id>, "text": "<translation>"}]}. '
        "Translate every item, reuse the ids exactly, keep the original order, "
        "and never merge, split, drop or reorder items."
    )
    translated: dict[int, str] = {}

    for start in range(0, len(cues), batch_size):
        batch = [
            {"id": index, "text": " ".join(cue["lines"])}
            for index, cue in enumerate(cues[start:start + batch_size], start=start)
        ]
        response = client.chat.completions.create(
            model="gpt-4o-mini",
            temperature=0,
            response_format={"type": "json_object"},
            messages=[
                {"role": "system", "content": instructions},
                {"role": "user", "content": json.dumps(
                    {"target_language": target_language, "items": batch}, ensure_ascii=False)},
            ],
        )
        for item in json.loads(response.choices[0].message.content)["items"]:
            translated[int(item["id"])] = item["text"]

    missing = [i for i in range(len(cues)) if i not in translated]
    if missing:
        raise ValueError(f"The model skipped {len(missing)} cues, first at index {missing[0]}")

    return [
        {"start": cue["start"], "end": cue["end"], "lines": wrap_two_lines(translated[index])}
        for index, cue in enumerate(cues)
    ]


spanish = translate_cues(cues, "Spanish")
spanish_file = Path("captions.es.srt")
spanish_file.write_text(render_srt(spanish), encoding="utf-8")
print(f"wrote {validate_srt(spanish_file)} Spanish cues")

temperature=0 matters here — you want the same translation every run, not creative variation, and What Is Temperature in an LLM API? explains why that setting behaves the way it does. The missing check turns a silently truncated batch into a loud failure, which is exactly what you want before you publish a video to an audience who cannot hear it. For more on shaping replies you can parse, read Write System Prompts that Control Output Format.

SRT vs WebVTT at a glance

Some players want WebVTT (.vtt) instead. The formats are close cousins, and converting between them is a handful of string replacements rather than a rewrite.

DetailSRTWebVTT
Milliseconds separatorcomma, 00:01:04,120period, 00:01:04.120
File headernonefirst line must be WEBVTT
Cue numbersrequired, counting from 1optional
Typical useuploads to video platformsHTML5 <track> in a browser

To produce a .vtt from what you already have, prepend WEBVTT and a blank line, then replace the comma in each timecode with a period. Keep the SRT as your source of truth, since it is the format every upload form accepts.

Troubleshooting

  • KeyError: 'segments' — the response has no timing data because response_format was left at its default. Add response_format="verbose_json" and timestamp_granularities=["segment"] to the transcription call.
  • ValueError: Cue 7 overlaps the previous cue — your proportional split let one cue's end drift past the next cue's start, usually after rounding. Make the last chunk end exactly at the segment's end, as the Step 3 code does with its position == len(chunks) check.
  • UnicodeDecodeError: 'charmap' codec can't decode byte 0x9d — you read or wrote the file without an explicit encoding on Windows. Pass encoding="utf-8" to every read_text and write_text call.
  • json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0) — the translation model wrapped its JSON in prose or a code fence. Keep response_format={"type": "json_object"} set, and if it still happens see Fix JSONDecodeError with AI API Responses in Python. Long batches also invite rate-limit errors, so retry with a short pause rather than hammering the endpoint.

When to use this vs. alternatives

  • Use this script when you control the files. Batch subtitling a back catalogue, generating captions for a client's videos, or shipping the same episode in four languages is exactly what a script is for — the per-file cost is small and the marginal effort after the first run is zero.
  • Use a platform's built-in captions when you upload one video occasionally. YouTube and most editors will auto-caption for free. Their accuracy is comparable, and if you are not translating, archiving or reusing the text elsewhere, a script adds work you do not need.
  • Use a paid captioning service when accuracy is contractual. Legal, medical and broadcast captions carry compliance requirements that no automatic transcript meets on its own. Generate a draft with this script, then send it for human review rather than starting from a blank page.

The transcript you produced along the way is worth more than the captions. The same cue list feeds a written article — see Turn a Podcast Episode into a Blog Post — or a short recap, covered in Summarize a YouTube Video with Python. Once the script runs cleanly on your machine, put it on a schedule so new uploads get captioned without you: Schedule Python AI Jobs with GitHub Actions shows how. Back to AI Transcription and Content Repurposing with Python.

Frequently asked questions

What is an SRT file and why do video platforms want one?

SRT is a plain text caption format. Each caption is a numbered block holding a start and end timecode and one or two lines of text. Almost every player, editor and social platform reads it, so an SRT file is the safest way to attach captions to a video you did not encode yourself.

How do I get timestamps out of the Whisper API?

Ask for verbose_json instead of the default text response, and add timestamp_granularities set to segment. The reply then contains a segments list, and each segment carries a start time, an end time and the words spoken between them, all in seconds as decimal numbers.

Why do subtitles use a comma before the milliseconds?

The SRT format was written before WebVTT existed and it separates whole seconds from milliseconds with a comma, as in 00:01:04,120. WebVTT uses a period in the same place. Writing a period into an SRT file is the single most common reason a player silently refuses to show your captions.

How long should one subtitle stay on screen?

Aim for one to six seconds per caption and no more than about 42 characters per line across two lines. Anything longer and viewers cannot finish reading before the caption changes. If a Whisper segment exceeds that, split it into several cues and share the segment's duration between them.

Can I translate subtitles without breaking the timings?

Yes. Translate only the text of each cue and copy the start and end times across untouched. Send the cues as numbered items and ask the model to return the same ids, so nothing is merged or reordered. The translated file then lines up with the audio exactly like the original.