Translating SRT subtitles with an LLM without breaking the timings
I added a free SRT subtitle translator to my live-captioning app. The naïve version — "here's a subtitle file, translate it" — breaks in ways that are obvious once you've seen them. Here's the design that ended up workin
I added a free SRT subtitle translator to my live-captioning app. The naïve version — "here's a subtitle file, translate it" — breaks in ways that are obvious once you've seen them. Here's the design that ended up working.
Rule 1: the model never sees timestamps
An .srt file is a list of cues:
2
00:00:03,000 --> 00:00:05,200
<i>How are you</i>
doing today?
If you hand the whole file to a model, it will occasionally rewrite a timestamp, renumber cues, merge two short lines or "fix" a typo in -->. Any of those desynchronises the video.
So the file is parsed in the browser and only the text goes to the server:
const TIME = /^\s*(\d{1,2}:\d{2}:\d{2}[,.]\d{1,3})\s*-->\s*(\d{1,2}:\d{2}:\d{2}[,.]\d{1,3})(.*)$/;
function parseSrt(text) {
return text.replace(/^/, '').replace(/\r\n?/g, '\n').split(/\n{2,}/).flatMap(block => {
const rows = block.split('\n');
const i = rows.findIndex(r => TIME.test(r));
if (i === -1) return [];
const [, start, end, extra] = rows[i].match(TIME);
return [{ start: start.replace('.', ','), end: end.replace('.', ','), extra, text: rows.slice(i + 1).join('\n').trim() }];
});
}
Things real files throw at you: a UTF-8 BOM, Windows line endings, . instead of , before milliseconds, position hints after the timestamp (X1:0), and missing or wrong cue numbers. Keep the timestamps byte-for-byte (normalising only the separator), keep extra, and renumber from 1 when you write the file back.
Rule 2: batches with ids, never bare arrays
Translating cue by cue is slow and loses context — dialogue reads badly when each line is translated alone. Translating everything at once hits output limits. Batches of ~40 cues, with neighbouring lines as context, are the sweet spot.
My first batch format was a bare array:
{"lines": ["Hey, are you coming tonight?", "I don't know yet.", "…"]}
with the instruction "return exactly the same number of items". In a test with repeated lines (as real subtitles have — "Okay.", "Thank you.", "What?"), the reply came back shorter than the input: the model had quietly de-duplicated. With a bare array there is no way to know which ten are missing, so every cue after the first gap would shift by one.
Ids fix it:
{"items": [{"id": 0, "text": "Hey, are you coming tonight?"}, {"id": 1, "text": "Okay."}, {"id": 2, "text": "Okay."}]}
and the prompt says: "Return a JSON object {"translations":[{"id","text"}]} with one entry for EVERY input id, even if two lines have identical text." With ids, the same test came back complete — and when an item is missing, you know exactly which one.
Rule 3: assume something will be missing anyway
The server maps results by id and treats anything missing or malformed as not translated:
const out = new Array(lines.length);
lines.forEach((l, i) => { if (!l.trim()) out[i] = l; }); // empty cues stay empty
for (let attempt = 0; attempt < 2; attempt++) {
const missing = out.map((v, i) => (v === undefined ? i : -1)).filter(i => i >= 0);
if (!missing.length) break;
const got = await translateBatch(missing.map(id => ({ id, text: lines[id] })));
for (const id of missing) if (got.has(id)) out[id] = got.get(id);
}
// last resort: translate the stragglers one by one
A second batched attempt with only the missing ids usually closes the gap; one-by-one is the fallback that guarantees a 1:1 mapping with the timestamps.
Rule 4: tell the model what it must not touch
The system prompt for subtitles:
- Translate each item; neighbouring lines are context for each other.
- Keep line breaks inside an item as
\n. - Keep names, numbers and formatting tags like
<i>unchanged. - An empty input stays empty.
- Translate questions and commands; do not answer them. (Subtitles are full of questions. An instruction-tuned model will happily answer "What time is it?" if you let it.)
Use JSON mode (response_format: { type: 'json_object' }); note that some APIs require the word "json" to appear in the messages for that mode to be accepted.
Rule 5: limits that protect you, not annoy users
Translation is cheap, but a free endpoint is an invitation. Mine requires login and has two limits: cues per file and cues per user per day. The batch endpoint also caps items per request and characters per item. These numbers are generous for real subtitles and useless for someone trying to use your API as a free translation backend.
Output: translated or bilingual
Writing the file back is the easy part:
function buildSrt(cues, translations, mode) {
return cues.map((c, i) => {
const tr = translations[i] || '';
const body = mode === 'bilingual' && c.text ? `${tr}\n${c.text}` : (tr || c.text);
return `${i + 1}\n${c.start} --> ${c.end}${c.extra}\n${body}\n`;
}).join('\n');
}
Bilingual subtitles (translation on top, original underneath) turned out to be popular with language learners and with people checking the translation.
Checklist
- Parse in the browser; send text only; keep timestamps verbatim.
- Batch with ids; never trust "same number of items".
- Re-request only the missing ids, then fall back to one-by-one.
- Preserve
\n, tags and empty cues; translate questions, don't answer them. - Put sensible limits on a free endpoint.
The tool is free to try at livetoka.com/tools/srt-translator. If you've hit other subtitle edge cases, I'd like to hear about them in the comments.
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.