CLI · commit 968e42c

subsync

Subtitle toolkit with automatic re-timing

Download subsync.zip
Quickstart
unzip subsync.zip && cd subsync
uv venv .venv && uv pip install -p .venv/bin/python -e '.[dev]'
.venv/bin/python -m pytest -q

subsync

Subtitle toolkit with automatic re-timing. Give it a subtitle that is out of sync (constant offset, 23.976 vs 25 fps drift, or a section cut out) and a correctly timed reference, in any language, or just the video's audio, and it works out the scale and offsets by aligning speech activity. It also converts SRT, WebVTT, ASS/SSA, MicroDVD and SBV, runs manual transforms, lints, diffs, and syncs whole season folders.

Features

Install

uv venv .venv && uv pip install -p .venv/bin/python -e '.[dev]'
source .venv/bin/activate

Python 3.10+. Runtime dependencies: numpy, click, rich, charset-normalizer. ffmpeg is only needed for --audio and audio batch mode; without it you get a clear message and everything else still works.

Quickstart

python examples/generate.py            # regenerate the example files (also writes examples/*.wav, not committed)
cd examples
subsync sync broken_combined.srt --ref reference_other_language.srt -o fixed.srt
subsync sync broken_cut.srt --audio reference.wav -o fixed_audio.srt
subsync batch season --ref-dir season/refs

Usage

Every command that writes a file accepts -o/--output, --format {srt,vtt,ass,sub,sbv}, --fps (MicroDVD), --encoding and --crlf. Without -o the result goes next to the input as name.<tag>.<ext>; the output format follows the extension of -o.

Auto-sync

examples/broken_combined.srt is the reference with a 90 s cut at 20:00, then a 23.976 to 25 fps mismatch, then a shift of -1.7 s. The reference given here is an "other language" version: scrambled text, jittered cue boundaries, 6% of cues missing.

$ subsync sync broken_combined.srt --ref reference_other_language.srt -o out/combined.synced.srt
scale 1.042709 (25->23.976); segment 1 from 0.0s: offset +1.794s; segment 2 from 1148.5s: offset +91.762s
confidence 0.77 (agreement 89.6%, 57 windows, 2 segment(s))
wrote out/combined.synced.srt (286 cues, srt)

Against audio (reference.wav is a synthetic WAV built from the cue timings, with noise-burst distractors):

$ subsync sync broken_cut.srt --audio reference.wav -o out/cut.audio.srt
extracting audio and detecting speech...
scale 1.000000; segment 1 from 0.0s: offset -0.007s; segment 2 from 1199.4s: offset +89.983s
confidence 0.82 (agreement 91.7%, 57 windows, 2 segment(s))
wrote out/cut.audio.srt (286 cues, srt)

--mode offset finds one constant shift only. sync refuses to write when the confidence is below --min-confidence (0.3) unless --force. Segment start times are in the original subtitle's time, and scale 1.042709 is the factor applied to times.

Diff

$ subsync diff broken_combined.srt out/combined.synced.srt --rows 5
286 cue pairs (0 only in A, 0 only in B)
start offset B-A: min +2.815s  median +37.506s  max +162.348s
┏━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━┓
┃ Cue ┃      A start ┃      B start ┃  Δ start ┃    Δ end ┃
┡━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━┩
│   1 │ 00:00:23.893 │ 00:00:26.708 │   +2.815 │   +2.932 │
│  58 │ 00:05:24.281 │ 00:05:39.925 │  +15.644 │  +15.713 │
│ 115 │ 00:11:09.691 │ 00:11:40.088 │  +30.397 │  +30.555 │
│ 172 │ 00:16:38.016 │ 00:17:22.435 │  +44.419 │  +44.498 │
│ 229 │ 00:21:48.235 │ 00:24:15.871 │ +147.636 │ +147.781 │
│ 286 │ 00:27:32.705 │ 00:30:15.053 │ +162.348 │ +162.448 │
└─────┴──────────────┴──────────────┴──────────┴──────────┘
  +162.35s |                                                                     *
  +147.85s |                                                      *** *** ******* 
  +133.34s |                                               ********               
  +118.84s |                                                                      
  +104.34s |                                                                      
   +89.83s |                                                                      
   +75.33s |                                                                      
   +60.83s |                                                                      
   +46.32s |                                            ****                      
   +31.82s |                             ***************                          
   +17.32s |              ***** *********                                         
    +2.81s |********* *****                                                       
           +----------------------------------------------------------------------
            24                                                                1653  (cue start in A, seconds)

The plot shows what the sync did: a ramp (the 25 to 23.976 correction) plus a 90 s step (the cut).

Batch

Subtitles in the folder are matched to a reference by episode key; --ref-dir selects reference subtitles, otherwise the video (or audio) files in the same folder are used.

$ subsync batch season --ref-dir season/refs --dry-run
┏━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━┓
┃ Episode ┃ Subtitle                   ┃ Source                   ┃ Result ┃ Confidence ┃ Timing model ┃
┡━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━┩
│ S01E01  │ Lighthouse.S01E01.720p.srt │ Lighthouse.1x01.en.srt   │ match  │            │              │
│ S01E02  │ Lighthouse.S01E02.720p.srt │ Lighthouse.s01e02.en.srt │ match  │            │              │
│ S01E03  │ Lighthouse.S01E03.720p.srt │ Lighthouse.S01E03.en.srt │ match  │            │              │
└─────────┴────────────────────────────┴──────────────────────────┴────────┴────────────┴──────────────┘
$ subsync batch season --ref-dir season/refs
┏━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Episode ┃ Subtitle              ┃ Source                ┃ Result                ┃ Confidence ┃ Timing model          ┃
┡━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━┩
│ S01E01  │ Lighthouse.S01E01.720 │ Lighthouse.1x01.en.sr │ Lighthouse.S01E01.720 │ 0.76       │ x1.00000, +4.50s      │
│         │ p.srt                 │ t                     │ p.synced.srt          │            │                       │
│ S01E02  │ Lighthouse.S01E02.720 │ Lighthouse.s01e02.en. │ Lighthouse.S01E02.720 │ 0.75       │ x1.04271, +0.03s      │
│         │ p.srt                 │ srt                   │ p.synced.srt          │            │                       │
│ S01E03  │ Lighthouse.S01E03.720 │ Lighthouse.S01E03.en. │ Lighthouse.S01E03.720 │ 0.77       │ x1.00000, -1.97s,     │
│         │ p.srt                 │ srt                   │ p.synced.srt          │            │ +58.03s               │
└─────────┴───────────────────────┴───────────────────────┴───────────────────────┴────────────┴───────────────────────┘
$ subsync batch season    # audio of the Lighthouse.*.wav files
┏━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Episode ┃ Subtitle              ┃ Source                ┃ Result                ┃ Confidence ┃ Timing model          ┃
┡━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━┩
│ S01E01  │ Lighthouse.S01E01.720 │ Lighthouse.S01E01.720 │ Lighthouse.S01E01.720 │ 0.79       │ x1.00000, +4.47s      │
│         │ p.srt                 │ p.wav                 │ p.synced.srt          │            │                       │
│ S01E02  │ Lighthouse.S01E02.720 │ Lighthouse.S01E02.720 │ Lighthouse.S01E02.720 │ 0.81       │ x1.04271, -0.02s      │
│         │ p.srt                 │ p.wav                 │ p.synced.srt          │            │                       │
│ S01E03  │ Lighthouse.S01E03.720 │ Lighthouse.S01E03.720 │ Lighthouse.S01E03.720 │ 0.82       │ x1.00000, -1.98s,     │
│         │ p.srt                 │ p.wav                 │ p.synced.srt          │            │ +57.98s               │
└─────────┴───────────────────────┴───────────────────────┴───────────────────────┴────────────┴───────────────────────┘

Lint and stats

$ subsync stats reference.srt
 format         srt          
 encoding       utf-8        
 cues           300          
 first cue      00:00:26.686 
 last cue       00:33:52.440 
 span           00:33:25.754 
 on screen      00:20:34.427 
 coverage       61.5%        
 words          3398         
 chars          17849        
 mean duration  4.11s        
 longest cue    6.50s        
 mean cps       14.6         
 max cps        17.1         
 lines per cue  1.90         
 longest line   42           
 longest gap    44.6s        
 warnings       0

lint exits 1 when it finds issues, so it works in CI:

$ subsync lint examples/noisy.srt
┏━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Cue ┃ Time     ┃ Rule          ┃ Problem                   ┃ Suggestion                          ┃
┡━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ 1   │ 00:00:01 │ short         │ duration 0.40s (min 0.7s) │ extend to 0.7s (subsync fix)        │
│ 2   │ 00:00:02 │ line-length   │ line of 69 chars (max 42) │ rewrap --max-chars 42               │
│ 2   │ 00:00:02 │ overlap       │ overlaps cue 3 by 0.500s  │ subsync fix trims the earlier cue   │
│ 2   │ 00:00:02 │ reading-speed │ 23.0 chars/sec (max 21)   │ extend to 3.29s or shorten the text │
│ 3   │ 00:00:04 │ tags          │ unbalanced tag(s): i      │ close or remove the tag             │
│ 4   │ 00:00:07 │ empty         │ cue has no text           │ delete the cue                      │
└─────┴──────────┴───────────────┴───────────────────────────┴─────────────────────────────────────┘
6 issue(s) in 4 cues: empty 1, line-length 1, overlap 1, reading-speed 1, short 1, tags 1

Manual transforms

subsync shift movie.srt +2.5s                      # also -300ms, +1m30s
subsync scale movie.srt --from-fps 25 --to-fps 23.976
subsync two-point movie.srt --first 1 0:01:02.5 --second 840 1:55:10   # cue 1 starts 0:01:02.5, cue 840 at 1:55:10
subsync split movie.srt 0:52:00                    # movie.part1.srt, movie.part2.srt (second rebased to 0)
subsync merge cd1.srt cd2.srt --offset 0:52:03.2   # default: second starts when the first ends
subsync cut movie.srt 0:20:00 0:21:30              # drop the range; later cues move back (--no-ripple to keep)
subsync strip-hi movie.srt                         # [MUSIC], (laughs), music notes, SPEAKER: labels
subsync rewrap movie.srt --max-chars 38
subsync fix movie.srt --min-duration 0.8 --min-gap 0.08
subsync convert movie.srt --format vtt             # or -o movie.ass

Times accept 90, 1:02:03.5, 02:03,250, 1m30s, 300ms; offsets take a sign.

Accuracy

examples/evaluate.py builds five different 300-cue ground-truth subtitles (seeded), breaks each of them four ways, syncs against the exact reference, an other-language reference and (two seeds) a synthetic WAV, and compares every cue's start with the ground truth. Error is the absolute start-time difference per cue; "breakpoint within 1 s" means the detected breakpoint lies between the last cue before the cut and the first cue after it, give or take 1 s.

variant source runs median error p95 error cues within 100 ms segments found breakpoint within 1 s mean confidence
broken_offset exact reference 5 0 ms 0 ms 100.0% 5/5 n/a 1.00
broken_offset other-language reference 5 9 ms 9 ms 100.0% 5/5 n/a 0.78
broken_offset audio (synthetic WAV) 2 10 ms 10 ms 100.0% 2/2 n/a 0.82
broken_drift exact reference 5 0 ms 0 ms 100.0% 5/5 n/a 1.00
broken_drift other-language reference 5 10 ms 10 ms 100.0% 5/5 n/a 0.78
broken_drift audio (synthetic WAV) 2 18 ms 18 ms 100.0% 2/2 n/a 0.82
broken_cut exact reference 5 0 ms 0 ms 100.0% 5/5 5/5 1.00
broken_cut other-language reference 5 12 ms 22 ms 99.8% 5/5 4/5 0.78
broken_cut audio (synthetic WAV) 2 19 ms 23 ms 100.0% 2/2 2/2 0.82
broken_combined exact reference 5 1 ms 1 ms 100.0% 5/5 5/5 1.00
broken_combined other-language reference 5 11 ms 17 ms 99.8% 5/5 4/5 0.78
broken_combined audio (synthetic WAV) 2 16 ms 28 ms 100.0% 2/2 2/2 0.82

Reading it: constant offsets and drift are recovered to within 20 ms against every kind of reference. The number of segments is right in every run. Against the other-language reference the breakpoint lands within 1 s in 4 of 5 runs for both split variants; in the missed run one cue next to the cut is attached to the wrong side (hence 99.8% of cues within 100 ms rather than 100%). The synthetic WAV is easy (clean voiced bursts over a quiet noise floor); real film audio with music and effects will be harder, which is why confidence exists. The other-language "translation" is scrambling plus timing jitter, not real translated subtitles, so real pairs of translations will vary more. Nothing here was measured on real films.

Configuration

There is no config file. Defaults you may want to change are command options: --mode, --min-confidence, --max-cps, --max-line, --max-chars, --min-duration, --min-gap. Alignment internals (window 60 s, hop 30 s, DP penalty 0.5) are keyword arguments of subsync.align.align().

Architecture

Module Role
subsync/model.py Cue, Subtitle, markup stripping
subsync/timefmt.py time parsing and per-format formatting
subsync/formats.py parsers and writers for the five formats, format detection
subsync/codec.py bytes to text (encoding detection), load / save
subsync/transforms.py shift, scale, two-point, split, merge, cut, strip-hi, rewrap, fix
subsync/align.py signals, normalised FFT correlation, scale scan, RANSAC, DP partitioning, breakpoint search
subsync/audio.py ffmpeg extraction and the numpy VAD
subsync/lint.py lint rules and stats
subsync/diff.py cue pairing and ASCII plot
subsync/batch.py episode matching and per-episode jobs
subsync/cli.py click commands

Library use:

from subsync import codec, align
sub, ref = codec.load("broken.srt"), codec.load("reference.srt")
fixed, model = align.sync_to_subtitle(sub, ref)
print(model.describe(), model.confidence)
codec.save(fixed, "fixed.srt")

Examples

examples/ holds reference.srt (300 invented cues, about 34 minutes), reference_other_language.srt, four broken variants (broken_offset +3.2 s, broken_drift 25 to 23.976, broken_cut 90 s removed at 20:00, broken_combined), noisy.srt for lint, and season/ with three episodes plus reference subtitles named in different episode styles. reference.wav and the season WAVs are synthetic speech-like audio made by generate.py; they are large (tens of MB) and git-ignored, so run python examples/generate.py to create them (--no-audio skips them). python examples/evaluate.py reproduces the accuracy table (about 2 minutes).

Tests

pytest -q          # 77 tests, about 20 s

They cover round trips for each format, encoding detection, malformed input, every transform, offset recovery to 20 ms, drift recovery, breakpoint detection, VAD on synthetic audio, ffmpeg extraction (skipped without ffmpeg), lint rules, episode matching and CLI end-to-end runs.

Limitations