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.
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.
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.
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.
| Detail | SRT | WebVTT |
|---|---|---|
| Milliseconds separator | comma, 00:01:04,120 | period, 00:01:04.120 |
| File header | none | first line must be WEBVTT |
| Cue numbers | required, counting from 1 | optional |
| Typical use | uploads to video platforms | HTML5 <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 becauseresponse_formatwas left at its default. Addresponse_format="verbose_json"andtimestamp_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'send, as the Step 3 code does with itsposition == len(chunks)check.UnicodeDecodeError: 'charmap' codec can't decode byte 0x9d— you read or wrote the file without an explicit encoding on Windows. Passencoding="utf-8"to everyread_textandwrite_textcall.json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)— the translation model wrapped its JSON in prose or a code fence. Keepresponse_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.
Related guides
- Transcribe Audio with the Whisper API and Python — the transcription call this guide builds on, including file size limits.
- Turn a Podcast Episode into a Blog Post — reuse the same transcript as long-form written content.
- Split Long Text into Chunks for AI APIs — the same packing logic applied to token budgets instead of caption width.
- Create YouTube Thumbnails with DALL-E 3 and Python — finish the upload with artwork generated from the same episode.
- Fix the 429 Rate-Limit Error in Python — handle the throttling you will hit when translating a long file.