Developer Guide

How to Create SRT and VTT Subtitle Files from a Transcript

Captions are the highest-return thing you can add to a video, and generating them is mostly a formatting exercise once you have a timestamped transcript. Here is how to go from an audio file to valid SRT and VTT, including the parts of the spec that quietly break players.

What you get back, and what you need

Our API returns two things when a job completes. transcription is plain text with no timing. diarization is the one you want: a speaker-labelled transcript with a start and end time on every segment, one segment per line.

The diarization format
SPEAKER_00 [00:00:01.000 - 00:00:05.240]: Thanks for coming in today.
SPEAKER_01 [00:00:05.900 - 00:00:11.480]: No problem at all, happy to help.
SPEAKER_00 [00:00:12.010 - 00:00:19.750]: Let's start with how you first heard about us.

That is segment-level timing, not word-level. For captions this is exactly what you need — captions are displayed as blocks of text, not word by word. Word-level timing only matters for karaoke-style highlighting, which we do not support.

Parsing the segments

One regular expression handles the whole format. Note that milliseconds are separated by a full stop, which matters shortly.

Python — parse diarization into segments
import re

LINE = re.compile(
    r"^(?P<speaker>SPEAKER_\d+) "
    r"\[(?P<start>\d{2}:\d{2}:\d{2}\.\d{3}) - "
    r"(?P<end>\d{2}:\d{2}:\d{2}\.\d{3})\]: "
    r"(?P<text>.*)$"
)


def parse_segments(diarization: str) -> list[dict]:
    """Turn the diarization string into a list of timed segments."""
    segments = []
    for line in diarization.splitlines():
        match = LINE.match(line.strip())
        if match:
            segments.append(match.groupdict())
    return segments

Writing SRT

SRT wants a 1-based counter, a timecode line using a comma before the milliseconds, the text, and a blank line between entries. The comma is the single most common mistake — SRT uses 00:00:01,000 while our output and VTT both use a full stop.

Python — emit SRT
def to_srt(segments: list[dict], include_speakers: bool = True) -> str:
    blocks = []
    for index, seg in enumerate(segments, start=1):
        # SRT timecodes use a comma before the milliseconds.
        start = seg["start"].replace(".", ",")
        end = seg["end"].replace(".", ",")
        text = seg["text"]
        if include_speakers:
            text = f"{friendly(seg['speaker'])}: {text}"
        blocks.append(f"{index}\n{start} --> {end}\n{text}\n")
    return "\n".join(blocks)


def friendly(speaker: str) -> str:
    """SPEAKER_00 -> Speaker 1. Map to real names where you know them."""
    names = {"SPEAKER_00": "Interviewer", "SPEAKER_01": "Participant"}
    if speaker in names:
        return names[speaker]
    return f"Speaker {int(speaker.rsplit('_', 1)[1]) + 1}"

Writing VTT

WebVTT is what HTML5 video wants. It keeps the full stop before milliseconds, requires a WEBVTT header line, and the counter is optional.

Python — emit WebVTT
def to_vtt(segments: list[dict], include_speakers: bool = True) -> str:
    lines = ["WEBVTT", ""]
    for seg in segments:
        text = seg["text"]
        if include_speakers:
            # VTT understands voice spans, which players can style per speaker.
            text = f"<v {friendly(seg['speaker'])}>{text}</v>"
        lines.append(f"{seg['start']} --> {seg['end']}")
        lines.append(text)
        lines.append("")
    return "\n".join(lines)

Use voice spans in VTT

The <v Speaker> syntax is part of the WebVTT spec and lets you style each speaker differently with CSS, or hide the label entirely. It is strictly better than prefixing the name into the caption text, which players cannot distinguish from dialogue.

The formatting rules that actually matter

Valid subtitle files are easy. Readable ones take a bit more care, and this is where automated captions usually fall down.

Line length. Broadcast convention is around 32 to 42 characters per line, at most two lines on screen. A transcript segment can easily run 200 characters, which will either overflow the player or be shrunk to nothing.

Duration. A caption should stay up long enough to read — roughly 20 characters per second is a common reading-rate target — and at least about a second regardless. Very short segments flash past unreadably.

Splitting sensibly. If you must break a long segment, break at punctuation, then at a conjunction, then at a space. Never mid-word.

Python — split over-long segments across cues
MAX_CHARS = 84  # two lines of ~42


def split_segment(seg: dict, max_chars: int = MAX_CHARS) -> list[dict]:
    """Split one long segment into several cues sharing its time span."""
    text = seg["text"]
    if len(text) <= max_chars:
        return [seg]

    # Prefer sentence boundaries, fall back to word boundaries.
    parts, current = [], ""
    for token in re.split(r"(?<=[.!?,])\s+", text):
        if current and len(current) + len(token) + 1 > max_chars:
            parts.append(current)
            current = token
        else:
            current = f"{current} {token}".strip()
    if current:
        parts.append(current)

    start, end = to_seconds(seg["start"]), to_seconds(seg["end"])
    step = (end - start) / len(parts)
    return [
        {
            "speaker": seg["speaker"],
            "start": to_timecode(start + i * step),
            "end": to_timecode(start + (i + 1) * step),
            "text": part,
        }
        for i, part in enumerate(parts)
    ]


def to_seconds(tc: str) -> float:
    h, m, s = tc.split(":")
    return int(h) * 3600 + int(m) * 60 + float(s)


def to_timecode(seconds: float) -> str:
    h, rem = divmod(seconds, 3600)
    m, s = divmod(rem, 60)
    return f"{int(h):02d}:{int(m):02d}:{s:06.3f}"

Dividing the time span evenly across the parts is an approximation — it assumes an even speaking rate within the segment. For captions this is almost always close enough, and it is what most automated tools do.

Putting it together

End to end
import requests, time

HEADERS = {"X-API-Key": "sk_live_..."}
BASE = "https://api.icana.ai/api/v1"

with open("episode.mp3", "rb") as f:
    job = requests.post(
        f"{BASE}/transcribe",
        headers=HEADERS,
        files={"file": f},
        data={"num_speakers": 2},
    ).json()

while True:
    result = requests.get(f"{BASE}/jobs/{job['id']}", headers=HEADERS).json()
    if result["status"] in ("completed", "failed"):
        break
    time.sleep(5)

segments = parse_segments(result["diarization"])
cues = [c for seg in segments for c in split_segment(seg)]

open("episode.srt", "w").write(to_srt(cues))
open("episode.vtt", "w").write(to_vtt(cues))

Which format to use where

SRT is the universal one. YouTube, Vimeo, LinkedIn, most social platforms, and every desktop player accept it. If you are uploading a caption file somewhere, upload SRT unless told otherwise.

VTT is for the web. The HTML5 <track> element requires it, and it supports positioning, styling, and the voice spans above. If you are serving video from your own site, use VTT.

Generating both takes one extra line, so there is little reason to choose.

Always read them before publishing

Automated captions are good enough to publish after a skim, not without one. Proper nouns are the usual problem — seed the prompt parameter with names appearing in the audio before transcribing, and it removes most of them.

Frequently asked questions

What is the difference between SRT and VTT?

SRT is the older, near-universal format accepted by YouTube, Vimeo, social platforms and desktop players. WebVTT is the web standard required by the HTML5 track element, and it supports positioning, CSS styling and speaker voice spans. The main syntax difference is that SRT uses a comma before milliseconds (00:00:01,000) while VTT uses a full stop (00:00:01.000).

Does the API return word-level timestamps?

No — timing is segment-level. Each diarization line carries a start and end time for that segment of speech. This is exactly what caption formats need, since captions display blocks of text rather than individual words. Word-level timing is only required for karaoke-style highlighting.

How long should each caption be?

Broadcast convention is roughly 32 to 42 characters per line with at most two lines on screen, held long enough to read at around 20 characters per second and for at least about a second. Raw transcript segments frequently exceed this, so splitting long segments across several cues is usually necessary.

Can I include speaker names in the captions?

Yes. In VTT, use voice spans — text — which players can style per speaker or hide entirely. In SRT there is no speaker concept, so you prefix the name into the caption text. Map SPEAKER_00 and SPEAKER_01 to real names in your own code, since the API returns generic labels.

How much does it cost to caption a video?

Transcription is $0.02 AUD per minute of audio, so a 30-minute video costs 60 cents and a one-hour video costs $1.20. Converting the transcript to SRT and VTT is just formatting and costs nothing. New accounts get 90 minutes free with no credit card required.

Do I need captions for accessibility compliance in Australia?

Whether you are required to is a question for your own legal adviser. The standard people measure against is WCAG, which treats captions for prerecorded video as a Level A criterion — its minimum conformance level. Australian government and publicly funded bodies commonly work to WCAG 2.1 AA by policy.

Caption your next video for 60 cents

90 minutes of transcription free, no credit card required. Timestamped, speaker-labelled output ready to convert to SRT or VTT.