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.
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.
Related
- Ofcom subtitle timing requirements explained — the 25 fps grid and 40 ms quantisation maths behind these thresholds.
- FCC Part 79 compliance checklist — the US thresholds to run in parallel for transatlantic delivery.
- SCC vs SRT vs WebVTT architecture — format selection and the canonical cue model this validator consumes.
- Secure caption pipeline design — gateway and audit-trail design for quarantined, non-compliant assets.
- Automated sync drift detection — the smoothed PTS alignment the cumulative-drift check depends on.
Part of: Broadcast Captioning Architecture & Compliance — the regulatory-thresholds-as-code reference for broadcast and OTT caption pipelines.