Ofcom Code on Subtitling Standards

For UK linear and on-demand services, the Ofcom Code on Television Access Services is not a prose policy to be read once and filed — it is a set of numeric boundaries that a validator must enforce on every subtitle block before the asset reaches a playout encoder or an OTT origin. The engineering gap this page closes is the distance between Ofcom’s quantified requirements (reading speed, dwell time, line geometry, synchronisation) and a deterministic Python check that fails closed when any one of them is breached. Treating the Code as a manual checklist scales to a handful of assets; engineering it as code is the only thing that scales to a VOD back-catalogue. This is the UK-jurisdiction enforcement arm of the broader Broadcast Captioning Architecture & Compliance control layer, and it shares the canonical cue model produced upstream by the SRT, SCC & WebVTT parsing workflows.

The quantified contract is unforgiving to state: zero blocks over the reading-speed ceiling (160 wpm pre-recorded, 180 wpm live), zero blocks dwelling under 1.0 s or over 7.0 s, zero lines wider than 37 characters, no more than two lines per block, and zero synchronisation drift beyond ±2.0 s for pre-recorded content or ±3.0 s for live. Any single failure marks the block non-compliant, and a non-compliant block blocks delivery.

Ofcom Code on Subtitling Standards enforced as a fail-closed validator The normalised cue model enters a stateless validate stage that fans out into six parallel Ofcom rule checks — min dwell (≥ 1.0 s), max dwell (≤ 7.0 s), reading speed (≤ 160 wpm pre-recorded / 180 wpm live), character rate (≤ 15 / 17 cps), line geometry (≤ 2 lines × 37 chars) and sync drift (≤ ±2.0 / 3.0 s) — each carrying its Ofcom threshold. The checks converge on a verdict: all-clear exits 0 to package/mux; any critical breach exits 1 to quarantine with a JSON violation report. Ofcom Code as a fail-closed validator One canonical cue model, six parallel rule checks, one binding verdict — any critical breach blocks delivery. Canonical cue start_ms end_ms text audio_onset validate stateless per block Min dwell Max dwell Reading speed Char rate Line geometry Sync drift ≥ 1.0 s ≤ 7.0 s ≤ 160 / 180 wpm ≤ 15 / 17 cps ≤ 2 × 37 chars ≤ ±2.0 / 3.0 s verdict fail closed on critical exit 0 → Package / mux → playout / OTT exit 1 → Quarantine + JSON violation report

Problem Framing: Where Ofcom Compliance Actually Fails

Ofcom expresses its requirements as viewer-experience outcomes — subtitles that are readable, well-timed, and synchronous — but every one of those outcomes reduces to a measurable property of a subtitle block. The failures that trigger real rejections are not exotic: a presenter ad-libs and the live caption block carries 24 words across 6.5 seconds, quietly breaching the 180 wpm ceiling; an automated reflow merges two cues and produces a 41-character line; a drop-frame-to-25fps conversion accumulates offset until a pre-recorded block sits 2.3 s adrift of its audio onset. None of these are visible to a human spot-check of the first ten minutes, and all of them are trivially detectable by a validator that runs over every block.

Three properties make Ofcom enforcement tractable in code. First, it is deterministic — the same cue bytes against the same jurisdiction profile always produce the same verdict, so a green result is reproducible evidence for an Ofcom information request. Second, it is parameterised — the thresholds are configurable values, not hard-coded constants, so a broadcaster can apply a tighter 140 wpm reading speed for children’s programming without forking the engine. Third, it is auditable — every failure carries the block index, the measured value, the threshold, and the rule that was breached, so the report is admissible rather than anecdotal. These mirror the design constraints applied across automated QC validation and reporting, narrowed here to a single question: does this block satisfy the Ofcom Code?

Pipeline Stage & Prerequisites

The Ofcom validator executes after caption generation and normalisation but strictly before packaging or mux. At that point the subtitle file (typically EBU-STL or SRT inbound, WebVTT or EBU-TT-D outbound) is final, but the delivery container has not been written, so a failure costs a rerun rather than a recall. The validator should run in parallel with audio/video encoding to preserve pipeline velocity, while being declared a required predecessor of every downstream delivery step — the same gating discipline used in CI/CD gating for caption builds.

Crucially, the validator consumes a normalised cue model; it does not re-implement parsing. Inbound EBU-STL, SRT, and SCC are first transcoded into a unified intermediate representation — WebVTT for OTT or EBU-TT-D for broadcast playout — using the format strategies in SCC vs SRT vs WebVTT architecture. UK delivery runs on the 25 fps PAL grid, so timecodes must be quantised to the 40 ms frame boundary before any reading-speed or dwell maths runs; the frame arithmetic behind that is documented in Ofcom subtitle timing requirements explained, the calibration reference for the thresholds enforced below.

Required tooling:

Tool / Library Version Role in the validator
Python ≥ 3.9 Runtime; dataclasses and walrus operator used below
pysrt ≥ 1.1.2 SRT cue ingest → block model
webvtt-py ≥ 0.4.6 WebVTT cue ingest and re-emit for OTT
numpy ≥ 1.21 Vectorised reading-speed/drift maths on long assets
ffprobe (FFmpeg) ≥ 4.4 Reference audio onset / PTS for sync checks (no full decode)

Pin these in the job lockfile and install from pre-built wheels — compiling on an ephemeral runner is the most common cause of a slow-but-green pipeline.

Step-by-Step Implementation

Step 1 — Load and normalise blocks into the canonical model

Read the inbound artifact into a format-agnostic block model so every downstream rule is independent of the source format. The loader below handles SRT and WebVTT; SCC ingest routes through the state-machine decode in parsing SCC with Python libraries first.

from dataclasses import dataclass, field
from typing import List, Optional
import pysrt
import webvtt

@dataclass
class SubtitleBlock:
    start_ms: int
    end_ms: int
    text: str                       # preserves intra-block line breaks ("\n")
    audio_onset_ms: int = 0         # reference audio timestamp for sync validation

def _tc_to_ms(t) -> int:
    # pysrt SubRipTime → integer milliseconds, quantised to the PAL 25 fps grid (40 ms)
    ms = ((t.hours * 60 + t.minutes) * 60 + t.seconds) * 1000 + t.milliseconds
    return round(ms / 40) * 40       # Ofcom UK delivery runs on the 25 fps frame grid

def load_blocks(path: str) -> List[SubtitleBlock]:
    """Load SRT or WebVTT into the canonical block model."""
    if path.lower().endswith(".srt"):
        return [
            SubtitleBlock(_tc_to_ms(c.start), _tc_to_ms(c.end), c.text)
            for c in pysrt.open(path, encoding="utf-8")
        ]
    if path.lower().endswith(".vtt"):
        return [
            SubtitleBlock(
                int(_vtt_seconds(c.start) * 1000),
                int(_vtt_seconds(c.end) * 1000),
                c.text,
            )
            for c in webvtt.read(path)
        ]
    raise ValueError(f"Unsupported subtitle format: {path!r}")

def _vtt_seconds(stamp: str) -> float:
    # WebVTT "HH:MM:SS.mmm" → seconds (W3C WebVTT cue timing)
    hh, mm, ss = stamp.split(":")
    return int(hh) * 3600 + int(mm) * 60 + float(ss)

Step 2 — Assert every block against the Ofcom rule set

The validator is stateless: it takes a list of blocks and a jurisdiction profile and returns one structured violation per failed check. Each comparison cites the Ofcom Code requirement it enforces.

@dataclass
class ComplianceViolation:
    block_index: int
    rule: str
    measured_value: float
    threshold: float
    severity: str                   # "critical" or "warning"

def validate_ofcom(
    blocks: List[SubtitleBlock],
    is_live: bool = False,
    max_cps: float = 15.0,          # 15 cps SD, 17 cps HD — configurable per channel
    wpm_limit: Optional[float] = None,
    sync_tolerance_sec: Optional[float] = None,
) -> List[ComplianceViolation]:
    """Validate blocks against the Ofcom Code on Television Access Services."""
    violations: List[ComplianceViolation] = []

    # Ofcom Best Practice: 180 wpm live / 160 wpm pre-recorded reading speed
    wpm_cap  = wpm_limit if wpm_limit is not None else (180.0 if is_live else 160.0)
    # Ofcom synchronisation tolerance: ±3.0 s live, ±2.0 s pre-recorded
    sync_tol = sync_tolerance_sec if sync_tolerance_sec is not None else (3.0 if is_live else 2.0)
    min_dur_ms = 1000               # Ofcom: minimum 1.0 s on-screen dwell per block
    max_dur_ms = 7000               # Ofcom: maximum 7.0 s on-screen dwell per block
    max_chars  = 37                 # Ofcom Code: maximum 37 characters per line
    max_lines  = 2                  # Ofcom Code: maximum 2 lines per block

    for i, block in enumerate(blocks):
        duration_sec = (block.end_ms - block.start_ms) / 1000.0
        word_count   = len(block.text.split())
        char_count   = len(block.text.replace("\n", "").replace(" ", ""))

        if block.end_ms - block.start_ms < min_dur_ms:               # min dwell
            violations.append(ComplianceViolation(
                i, "MIN_DURATION", round(duration_sec, 3), min_dur_ms / 1000.0, "critical"))

        if block.end_ms - block.start_ms > max_dur_ms:               # max dwell
            violations.append(ComplianceViolation(
                i, "MAX_DURATION", round(duration_sec, 3), max_dur_ms / 1000.0, "warning"))

        if duration_sec > 0:
            if (wpm := (word_count / duration_sec) * 60.0) > wpm_cap:  # reading speed
                violations.append(ComplianceViolation(
                    i, "MAX_WPM", round(wpm, 1), wpm_cap, "warning"))
            if (cps := char_count / duration_sec) > max_cps:          # characters/sec
                violations.append(ComplianceViolation(
                    i, "MAX_CPS", round(cps, 2), max_cps, "warning"))

        lines = [ln for ln in block.text.splitlines() if ln.strip()]
        if len(lines) > max_lines:                                    # line count
            violations.append(ComplianceViolation(
                i, "MAX_LINES", float(len(lines)), float(max_lines), "warning"))
        for line in lines:
            if len(line) > max_chars:                                 # line width
                violations.append(ComplianceViolation(
                    i, "MAX_CHARS_PER_LINE", float(len(line)), float(max_chars), "warning"))

        if block.audio_onset_ms > 0:                                  # sync drift
            if (drift := abs((block.start_ms - block.audio_onset_ms) / 1000.0)) > sync_tol:
                violations.append(ComplianceViolation(
                    i, "SYNC_DRIFT", round(drift, 2), sync_tol, "critical"))

    return violations

Step 3 — Detect cumulative drift across the whole programme

Per-block sync checks catch gross errors, but the failure mode that escapes them is cumulative offset from a frame-rate conversion. Comparing the block timeline to a reference audio onset track with numpy surfaces a slow walk before it crosses the tolerance, the same smoothed approach used in automated sync drift detection.

import numpy as np

def cumulative_drift_ms(blocks: List[SubtitleBlock]) -> np.ndarray:
    """Signed per-block drift (block start - audio onset) in milliseconds."""
    starts = np.array([b.start_ms for b in blocks], dtype=np.float64)
    onsets = np.array([b.audio_onset_ms for b in blocks], dtype=np.float64)
    drift  = starts - onsets
    # smooth over a 5-block window so transient jitter is not read as drift
    kernel = np.ones(5) / 5.0
    return np.convolve(drift, kernel, mode="same")

Step 4 — Emit the verdict and a structured report

Serialise violations to JSON so the verdict survives the job. A non-zero exit blocks delivery; the report routes to the audit layer described in scheduled QC report generation.

import json, sys

def emit_report(path: str, violations: List[ComplianceViolation]) -> int:
    critical = [v for v in violations if v.severity == "critical"]
    report = {
        "asset": path,
        "jurisdiction": "GB-Ofcom",
        "total_violations": len(violations),
        "critical": len(critical),
        "violations": [v.__dict__ for v in violations],
    }
    print(json.dumps(report, indent=2))
    return 1 if critical else 0     # exit 1 blocks delivery on any critical breach

if __name__ == "__main__":
    blocks = load_blocks(sys.argv[1])
    sys.exit(emit_report(sys.argv[1], validate_ofcom(blocks, is_live="--live" in sys.argv)))

Threshold Reference Table

Every limit the validator enforces, with its source in the Ofcom framework. Expose each as a configurable value so tighter house standards can be applied without breaching the regulatory floor.

Parameter Pre-recorded Live / near-live Source
Reading speed 160 wpm 180 wpm Ofcom Best Practice in subtitling
Characters per second 15 cps (SD) / 17 cps (HD) 17 cps Ofcom / ITC guidance
Minimum dwell 1.0 s 1.0 s Ofcom Code on Access Services
Maximum dwell 7.0 s 7.0 s Ofcom Code on Access Services
Lines per block 2 2 Ofcom Code on Access Services
Characters per line 37 37 Ofcom Code on Access Services
Synchronisation tolerance ±2.0 s ±3.0 s Ofcom Code on Access Services
Frame grid 40 ms (25 fps PAL) 40 ms (25 fps PAL) EBU N19 / UK delivery

For the equivalent US figures and the parameterisation pattern that lets one engine serve both, keep the FCC Part 79 compliance checklist alongside this table; the authoritative source for the values above is the Ofcom Code on Television Access Services.

Verification & Test Pattern

Confirm the validator before pointing it at production assets by asserting that hand-built blocks trip exactly the rules they should. Build one fixture per clause so a regression names the broken rule directly.

import pytest

def rules(blocks, **kw):
    return {v.rule for v in validate_ofcom(blocks, **kw)}

def test_clean_block_passes():
    b = SubtitleBlock(0, 3000, "A short, well paced line.\nSecond line here.")
    assert validate_ofcom([b]) == []

def test_short_dwell_is_critical():
    b = SubtitleBlock(0, 600, "Too brief.")     # 0.6 s < 1.0 s floor
    v = validate_ofcom([b])
    assert any(x.rule == "MIN_DURATION" and x.severity == "critical" for x in v)

def test_overspeed_prerecorded_trips_wpm():
    b = SubtitleBlock(0, 2000, " ".join(["word"] * 12))   # 360 wpm
    assert "MAX_WPM" in rules([b], is_live=False)

def test_overwide_line_and_three_lines():
    b = SubtitleBlock(0, 4000, "x" * 41 + "\nb\nc")        # 41 chars, 3 lines
    found = rules([b])
    assert {"MAX_CHARS_PER_LINE", "MAX_LINES"} <= found

def test_sync_drift_prerecorded():
    b = SubtitleBlock(2300, 5300, "Drifted.", audio_onset_ms=0)  # 2.3 s > 2.0 s
    assert "SYNC_DRIFT" in rules([b])

Run this suite as its own step before the gate touches real artifacts; the drift-threshold fixtures here follow the same fixture-per-clause discipline reused across the QC pages.

Troubleshooting / Failure Modes

Reading-speed ceiling breached only on live blocks : Root cause: a live profile was run with the 160 wpm pre-recorded ceiling because is_live defaulted to False. Fix: route the flag from the playout schedule, not a CLI default, so near-live inserts pick up the 180 wpm allowance.

Phantom 2.3 s drift on otherwise clean pre-recorded files : Root cause: a 23.976/29.97 fps source converted to 25 fps PAL without re-basing timecodes accumulates offset across the programme. Fix: quantise to the 40 ms grid and re-base at parse time via SRT timestamp normalization before the validator runs.

Line-width failures on WebVTT but not the source SRT : Root cause: WebVTT cue text counts markup or non-breaking entities toward the 37-character limit. Fix: strip cue tags and decode entities during WebVTT cue extraction and validation so the count reflects rendered glyphs.

CPS passes but reading speed fails (or vice-versa) : Root cause: cps and wpm measure different things — dense short words inflate wpm while long words inflate cps. Fix: keep both checks; they are complementary, and tuning one does not satisfy the other.

Every block over 7 s flagged on a karaoke/lyric asset : Root cause: sustained on-screen lyrics legitimately exceed the dwell ceiling. Fix: treat MAX_DURATION as a warning, not a critical, and allow a documented per-genre override rather than disabling the rule.

Validator green locally, red on the runner : Root cause: a different pysrt/webvtt-py version changes millisecond rounding or entity handling. Fix: pin exact versions in the lockfile and install from wheels.

Operational Notes

At single-file scale this validator is trivial; at multi-language, multi-format VOD scale it becomes a throughput problem. Move from one-file invocation to batch orchestration with a bounded worker pool — size workers to roughly min(cpu_count, memory_ceiling / peak_asset_RSS) so a matrix of jobs cannot collectively exhaust the runner. Read artifacts from object storage or stdin rather than copying onto runner disk, and validate in generator-driven windows so memory scales with the cue window, not the asset duration; the streaming pattern in async batch caption processing is the reference for that I/O shape.

Keep the validator stateless so it scales horizontally without coordination, and load the jurisdiction profile (thresholds, fps grid, reading-speed ceiling) by destination market so a single ingest stream can emit Ofcom-compliant and FCC-compliant manifests from one pass. The JSON verdict from Step 4 must outlive the job: route it to scheduled QC report generation for roll-up, and retain quarantined assets for the legally required period — the tamper-evidence design for that archive lives in secure caption pipeline design.

Part of: Broadcast Captioning Architecture & Compliance — the regulatory-thresholds-as-code reference for broadcast and OTT caption pipelines.