SRT Timestamp Normalization

SRT timestamp normalization is the deterministic temporal gate that sits between raw subtitle ingest and any compliance check that assumes frame-accurate cues. The compliance gap it closes is specific: SRT (SubRip) timestamps carry millisecond precision with no notion of a frame grid, so a file authored in a NLE, exported from a speech-to-text engine, or round-tripped through a vendor tool routinely lands cue edges between video frames. Downstream that surfaces as decoder flicker, overlapping cues the muxer rejects, and synchronization that already burns part of the ±2-frame budget before FCC Part 79 compliance ever runs. Normalization removes that ambiguity by quantizing every start and end to the nearest frame boundary for the target rate (33.367 ms at 29.97 fps, 40.000 ms at 25 fps), forbidding cue overlap at a hard 0 ms tolerance, and preserving a minimum two-frame gap so consecutive cues clear the decoder cleanly.

This step is part of the SRT, SCC & WebVTT parsing workflows track. It runs after a file has been parsed into a cue model and before it is validated, converted, or embedded — every threshold below is read from the reference table so the same routine re-targets another frame rate by swapping one config value rather than editing arithmetic.

Snapping off-grid SRT cue edges to a frame grid and resolving overlap into a 2-frame gap A raw SRT timeline has two cues whose start and end edges fall between the ticks of a 29.97 fps frame grid and overlap by a few milliseconds. A normalization pass rounds each edge to the nearest frame, eliminating the overlap and forcing a two-frame inter-cue gap, so the normalized timeline has every edge aligned to a tick. The frame pitch is 33.367 ms and the FCC sync tolerance is plus or minus two frames. RAW SRT — edges land between frame ticks Cue 1 Cue 2 overlap (0 ms tolerance) snap → grid + 2-frame gap NORMALIZED — every edge on a frame Cue 1 Cue 2 2-frame gap (66.7 ms) snap error ≤ ½ frame ±2-frame FCC budget (133 ms) 1 frame = 33.367 ms (29.97 fps) raw (off-grid) frame-snapped rejected overlap frame grid

Pipeline stage & prerequisites

Normalization executes at the post-parse, pre-validate boundary. Upstream, the SRT has already been decoded to a cue list — either parsed directly or produced by converting SCC to SRT without timing loss, which is the most common source of off-grid timecodes because SCC frame counts get translated into milliseconds and inherit rounding error. Downstream, the normalized cues feed the static acceptance gates in FCC Part 79 compliance, cross-format parity against WebVTT cue extraction and validation, and continuous measurement by automated sync drift detection. Placing the gate here is what keeps it cheap: an off-grid edge fixed at ingest is a config-driven snap; the same edge caught at playout is a re-render.

The target frame rate is not guessed — read it from the companion media with ffprobe so the grid matches the actual timebase rather than a hard-coded default.

Tool / library Version (tested) Role in normalization
pysrt 1.1.2 Parse/serialize SRT, expose SubRipTime.ordinal in ms
ffprobe (FFmpeg) 6.0+ Read the exact r_frame_rate rational of the target video
numpy 1.26+ Vectorized snapping and overlap resolution at batch scale
Python 3.10+ fractions.Fraction for exact NTSC rationals, dataclasses

Step-by-step implementation

Step 1 — Build an exact frame grid from the source rate

The single most common normalization bug is treating 29.97 fps as 30 fps, which accumulates ~3.6 s of drift per hour. Derive frame duration from the exact SMPTE rational, never from a rounded 33.367 literal.

from fractions import Fraction

# SMPTE ST 12-1 — broadcast rates as exact rationals (never rounded floats)
FRAME_RATES = {
    "23.976": Fraction(24000, 1001),
    "24":     Fraction(24, 1),
    "25":     Fraction(25, 1),       # EBU / PAL
    "29.97":  Fraction(30000, 1001), # NTSC
    "30":     Fraction(30, 1),
    "59.94":  Fraction(60000, 1001),
    "60":     Fraction(60, 1),
}

def frame_ms(fps: Fraction) -> float:
    # Milliseconds per frame; one frame is the smallest legal time quantum
    return float(Fraction(1000, 1) / fps)

Pull the rate from the companion video instead of assuming one, so the grid matches the timebase the cues will actually be muxed against:

import subprocess, json
from fractions import Fraction

def probe_frame_rate(media_path: str) -> Fraction:
    # ffprobe reads stream metadata only — no decode — to get the exact rational
    out = subprocess.run(
        ["ffprobe", "-v", "error", "-select_streams", "v:0",
         "-show_entries", "stream=r_frame_rate", "-of", "json", media_path],
        capture_output=True, text=True, check=True,
    )
    num, den = json.loads(out.stdout)["streams"][0]["r_frame_rate"].split("/")
    return Fraction(int(num), int(den))   # e.g. 30000/1001 for NTSC

Step 2 — Parse cues with pysrt

pysrt handles the comma decimal separator, BOM-prefixed files, and index renumbering, and exposes each timestamp as SubRipTime whose .ordinal attribute is the absolute offset in milliseconds — the integer form every later step operates on.

import pysrt

def load_cues_ms(path: str):
    # utf-8-sig drops a leading BOM so the first index parses cleanly
    subs = pysrt.open(path, encoding="utf-8-sig")
    # .ordinal is total milliseconds; integer math avoids float drift
    return [(item.index, item.start.ordinal, item.end.ordinal, item.text)
            for item in subs], subs

Step 3 — Snap each cue edge to the frame grid

Round each edge to the nearest frame, then convert back to milliseconds. Rounding (not truncation) keeps the maximum introduced error to half a frame, well inside the FCC 47 CFR § 79.1(j) ±2-frame sync budget.

def snap_ms(t_ms: int, fpms: float) -> int:
    # Snap an absolute ms offset to the nearest frame boundary
    frame_index = round(t_ms / fpms)        # nearest whole frame
    return int(round(frame_index * fpms))   # back to ms, half-frame max error

Step 4 — Enforce minimum duration and the inter-cue gap

EBU Tech 3350 and ATSC A/53 prohibit overlapping cues outright (0 ms tolerance), and consumer decoders need a clear gap between cues to avoid flicker. Enforce a minimum on-screen duration and a two-frame gap after snapping.

MIN_GAP_FRAMES = 2          # decoder flicker guard between consecutive cues
MIN_DURATION_FRAMES = 15    # ~0.5 s readable floor at 30 fps grid

def enforce_duration(start_ms: int, end_ms: int, fpms: float) -> tuple[int, int]:
    min_dur = int(round(MIN_DURATION_FRAMES * fpms))
    if end_ms - start_ms < min_dur:        # too brief to read -> extend tail
        end_ms = start_ms + min_dur
    return start_ms, end_ms

Step 5 — Resolve overlaps in a single forward pass and write back

Walk the cues in order, carrying the previous cue’s end. If a snapped start lands before that end, push it forward by the required gap; re-apply the duration floor if the push collapsed the cue. Then write the integer milliseconds straight back into the pysrt objects so the serializer emits canonical HH:MM:SS,mmm timecodes.

import pysrt

def normalize_file(in_path: str, out_path: str, fps: Fraction) -> dict:
    fpms = frame_ms(fps)
    gap = int(round(MIN_GAP_FRAMES * fpms))   # 0 ms overlap tolerance + flicker guard
    subs = pysrt.open(in_path, encoding="utf-8-sig")

    prev_end = 0
    adjustments = 0
    for item in subs:
        start = snap_ms(item.start.ordinal, fpms)   # frame-snap both edges
        end   = snap_ms(item.end.ordinal, fpms)

        if start < prev_end + gap:                  # EBU 3350 — no overlap, keep gap
            start = prev_end + gap
        start, end = enforce_duration(start, end, fpms)

        if (start, end) != (item.start.ordinal, item.end.ordinal):
            adjustments += 1
        # SubRipItem accepts ms via the .ordinal setter on its time objects
        item.start.ordinal = start
        item.end.ordinal = end
        prev_end = end

    subs.clean_indexes()        # renumber 1..N after any reordering
    subs.save(out_path, encoding="utf-8")
    return {"cues": len(subs), "adjusted": adjustments}

At batch scale the per-cue loop is the bottleneck; the same logic vectorizes cleanly. Load edges into a numpy array, snap with one division, and resolve overlaps with a running maximum:

import numpy as np

def snap_array(edges_ms: np.ndarray, fpms: float) -> np.ndarray:
    # Vectorized equivalent of snap_ms over an (N, 2) start/end array
    return np.rint(np.rint(edges_ms / fpms) * fpms).astype(np.int64)

Threshold reference table

Every limit the routine reads lives here, not in prose, so a config swap re-points the same code at another frame rate or jurisdiction.

Parameter Value Spec clause Notes
Frame duration (23.976) 41.708 ms SMPTE ST 12-1 Fraction(24000, 1001)
Frame duration (25) 40.000 ms EBU Tech 3264 PAL / EBU integer rate
Frame duration (29.97) 33.3667 ms SMPTE ST 12-1 Fraction(30000, 1001), never 33.367 literal
Frame duration (59.94) 16.6833 ms SMPTE ST 12-1 Fraction(60000, 1001)
Sync tolerance ±2 frames (±66.7 ms @ 29.97) 47 CFR § 79.1(j) Snapping introduces ≤ ½ frame error
Cue overlap tolerance 0 ms EBU Tech 3350 / ATSC A/53 Hard prohibition on overlapping cues
Minimum inter-cue gap 2 frames (66.7 ms @ 29.97) EBU Tech 3350 Decoder flicker guard
Minimum display duration ~0.5 s (15 frames @ 30) Ofcom / EBU guidance Readability floor
Maximum display duration ~7 s EBU / Ofcom guidance Flag cues held beyond this

Verification & test pattern

Lock each threshold behind a pytest fixture so a config change that loosens the grid fails CI instead of shipping off-grid cues. Test the boundary on both sides — an edge already on a frame must be unchanged, and an edge a millisecond off must snap.

import pytest
from fractions import Fraction
from normalize import frame_ms, snap_ms, MIN_GAP_FRAMES

@pytest.fixture
def ntsc_fpms():
    return frame_ms(Fraction(30000, 1001))   # ~33.3667 ms

def test_on_grid_edge_is_stable(ntsc_fpms):
    on_grid = int(round(100 * ntsc_fpms))    # exactly frame 100
    assert snap_ms(on_grid, ntsc_fpms) == on_grid

def test_off_grid_edge_snaps_to_nearest(ntsc_fpms):
    off = int(round(100 * ntsc_fpms)) + 5    # 5 ms past frame 100
    snapped = snap_ms(off, ntsc_fpms)
    assert abs(snapped - off) <= ntsc_fpms / 2   # ≤ half-frame error

def test_consecutive_cues_keep_two_frame_gap(ntsc_fpms):
    gap = int(round(MIN_GAP_FRAMES * ntsc_fpms))
    # cue B starting 1 ms after cue A ends must be pushed out by `gap`
    prev_end = int(round(100 * ntsc_fpms))
    start = prev_end + 1
    if start < prev_end + gap:
        start = prev_end + gap
    assert start - prev_end >= gap

For a full-file assertion, re-parse the output and confirm every edge is an exact multiple of the frame pitch and no two cues touch:

import pysrt
from fractions import Fraction
from normalize import frame_ms

def assert_normalized(path: str, fps: Fraction):
    fpms = frame_ms(fps)
    subs = pysrt.open(path, encoding="utf-8")
    prev_end = -1
    for item in subs:
        for edge in (item.start.ordinal, item.end.ordinal):
            assert abs(edge - round(edge / fpms) * fpms) < 1.0   # on-grid
        assert item.start.ordinal > prev_end                     # no overlap
        prev_end = item.end.ordinal

Troubleshooting / failure modes

Cumulative drift of ~3.6 s per hour after normalization : Root cause: the grid was built from 30.0 (or a rounded 33.367) instead of the exact NTSC rational. Fix: source frame duration from Fraction(30000, 1001) via probe_frame_rate, and keep all arithmetic on integer milliseconds until the final serialize.

First cue fails to parse / index is off by one : Root cause: a UTF-8 BOM glued to the first index, a known SubRip export artifact. Fix: open with encoding="utf-8-sig"; for deeper encoding repair see fixing UTF-8 encoding errors in SCC files, whose detection logic applies to SRT too.

Snapped cues now overlap even though the source did not : Root cause: two edges rounded toward the same frame, collapsing the gap below 0 ms. Fix: run the forward-pass gap enforcement (Step 5) after snapping, never before — snapping can create overlaps that only a post-pass resolves.

Output timecodes use a period instead of a comma : Root cause: the writer emitted WebVTT-style . separators into an .srt file. Fix: serialize through pysrt (subs.save), which always emits the SubRip HH:MM:SS,mmm form; reserve the period separator for WebVTT deliverables.

A 25 fps caption file drifts against a 23.976 fps mezzanine : Root cause: normalizing to the wrong grid because the rate was assumed, not probed. Fix: always read r_frame_rate from the target video; mismatched timebases are a conversion problem covered in SCC vs SRT vs WebVTT architecture.

Very short cues vanish on consumer decoders : Root cause: a sub-frame or single-frame cue snapped to zero or below the readable floor. Fix: apply the minimum-duration extension in Step 4; flag any cue whose original span was under one frame for editorial review rather than silently extending it.

Operational notes

Normalization is CPU-light and I/O-bound: the cost is reading and writing files and the one ffprobe probe per asset, not the arithmetic. Size a worker pool to roughly the physical core count and give each worker one asset at a time; a feature-length SRT is tens of thousands of cues but each cue is a few integers and a short string, so per-worker resident memory stays trivial. When normalizing a whole library against one known rate, batch the probe step — probe each distinct companion video once and cache the Fraction, rather than re-probing per cue file. For multi-file batch runs that fan normalization out across queues, the concurrency and backpressure patterns are covered in async batch caption processing.

Keep the routine stateless and idempotent: normalizing an already-normalized file must be a no-op (the adjusted count returns 0), which lets the QC layer re-run assets after an upstream fix without special-casing. Emit the per-file {"cues", "adjusted"} summary plus the largest single edge adjustment to a structured log; any adjustment exceeding one frame is a signal of source corruption or a frame-rate mismatch worth surfacing to automated sync drift detection rather than silently absorbing.

Part of: SRT, SCC & WebVTT Parsing Workflows.