DEV Community

anders lee
anders lee

Posted on

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 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?
Enter fullscreen mode Exit fullscreen mode

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() }];
  });
}
Enter fullscreen mode Exit fullscreen mode

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.", "…"]}
Enter fullscreen mode Exit fullscreen mode

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."}]}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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');
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)