Broadcast Captioning Architecture & Compliance

Closed captioning in modern broadcast and OTT environments is a timecode-locked, compliance-bound data stream that must survive frame-accurate synchronization, multi-format transcoding, and regulatory audit. This reference maps the regulatory thresholds set by the FCC, Ofcom, SMPTE, and the W3C directly onto pipeline topology, so that compliance is enforced in code at ingest, validate, mux, and playout rather than verified by hand after distribution. The sections below carry working Python you can drop into a transcode wrapper, the threshold tables those validators read from, and the failure modes that trigger most real-world caption rejections.

Regulatory & engineering context

Caption infrastructure exists at the intersection of three independent rule sets, and each one creates a hard engineering constraint rather than a stylistic preference. In the United States, FCC Part 79 compliance (47 CFR § 79.1) requires that captions be accurate, synchronous, complete, and properly placed. The principle-based language hides concrete numbers: pre-recorded programming is held to a 99% character-accuracy expectation, live captioning must reach the viewer within roughly two seconds of the spoken audio, and the industry-accepted synchronization budget is ±2 video frames — about ±66.7 ms at 29.97 fps. Emergency information carried under 47 CFR § 79.2 escalates this further: alert text must be both aural and visual and cannot be dropped by a downstream QC gate.

In the United Kingdom, the Ofcom code on subtitling standards imposes a different shape of constraint built around human reading speed: a minimum on-screen dwell of around one second, a maximum of seven, reading speeds of 160–180 words per minute for adult programming, and a synchronization tolerance near ±0.2 s for pre-recorded content. Canada’s CRTC and the EBU’s subtitling guidelines add regional variants of the same dimensions, which is why a portable validator must parameterize thresholds rather than hard-code a single jurisdiction.

The transport layer is governed by SMPTE and the W3C. SMPTE ST 12-1 defines the drop-frame timecode model that makes 29.97 fps arithmetic non-linear; SMPTE ST 2110-40 defines how ancillary caption data rides over PTP-synchronized IP; CEA-608 and CEA-708 (now standardized as ANSI/CTA-608-E and CTA-708-E) define the byte-level caption payload itself; and the WebVTT specification and TTML/IMSC1 define the cue model used for OTT delivery. The engineering job of this page is to translate all of these into deterministic, testable thresholds — the same discipline applied across automated QC validation and reporting and the SRT, SCC & WebVTT parsing workflows that feed it.

Caption pipeline stage map

Every compliance rule attaches to a specific stage of the end-to-end pipeline, and knowing where a check belongs is what keeps validation cheap and deterministic. Caption data enters at ingest, is parsed into a canonical cue model, is validated against the active jurisdiction’s thresholds, is muxed or packaged into the delivery container, and is finally verified at playout against a live audio reference. A violation caught at ingest costs nothing; the same violation caught at playout is a regulatory incident.

Caption compliance pipeline with per-stage governing clauses A left-to-right flow: Ingest (SMPTE ST 2110-40) feeds Parse (CTA-608/708 to a canonical cue model), which feeds Validate (FCC 47 CFR 79.1 sync and Ofcom dwell/CPS thresholds). Cues that pass continue to Mux/Package (W3C WebVTT and TTML/IMSC1) and then Playout/CDN (checked against the FCC two-second live-latency window). Cues that fail validation branch downward to a Quarantine/reject box. Caption compliance pipeline — every rule enforced at its stage A violation caught at ingest costs nothing; the same violation caught at playout is a regulatory incident. pass Ingest Parse Validate Mux / Package Playout / CDN SDI · ST 2110 · file SMPTE ST 2110-40 SCC / SRT / VTT CTA-608 / 708 → cues sync · CPS · placement FCC §79.1 · Ofcom embed · HLS / DASH W3C WebVTT / TTML latency vs live audio FCC §79.1 ≤ 2 s fail Quarantine / reject tampered · malformed · out-of-tolerance
Each compliance rule binds to one pipeline stage; the QC gate quarantines failing cues before they reach the mux.

The canonical cue model is the contract that holds the stages together: regardless of whether a cue arrived as a hex SCC pair, an SRT block, or a WebVTT cue, it is normalized to {start, end, text, region, jurisdiction} before validation runs. That normalization is the subject of SRT timestamp normalization and WebVTT cue extraction and validation; this page assumes you already hold a list of canonical cues and focuses on what to enforce against them.

Format & standard overview

The first architectural decision is which caption format owns which segment of the pipeline. The three formats below are not interchangeable — each carries a different timing model and a different ceiling on what compliance metadata it can express. A full decision walkthrough lives in the SCC vs SRT vs WebVTT architecture guide; the table is the quick-reference.

Property SCC (CEA-608) SRT WebVTT (CEA-708 → OTT)
Underlying standard CTA-608-E, line 21 / ATSC ANC de facto (no formal spec) W3C WebVTT 1.0
Timecode model SMPTE ST 12-1 drop / non-drop HH:MM:SS,mmm wall-clock HH:MM:SS.mmm media-time
Frame coupling Frame-locked (29.97 DF) Frame-agnostic Frame-agnostic
Positioning 32×15 cell grid, row/col none region + line/position/align
Styling 8 colors, italics, underline basic tags CSS-class cues, ::cue
Max chars/line 32 unbounded (32 conventional) 32 conventional
Primary delivery SDI / ATSC 1.0 / cable editorial interchange HLS / DASH / ATSC 3.0

The recurring failure across format boundaries is timing drift: SCC’s frame-locked drop-frame timecode does not map cleanly onto SRT/WebVTT media-time, so naive conversion accumulates error. The conversion-without-loss technique is detailed in converting SCC to SRT without timing loss, and the binary decoding it depends on is covered in parsing SCC with Python libraries.

FCC timing, accuracy & placement enforcement

Engineering problem: FCC Part 79 says captions must be synchronous and properly placed, but a transcode wrapper needs a numeric pass/fail, not a principle. The two enforceable numbers are the ±2-frame synchronization budget and the placement rule that captions may not obscure on-screen graphics or exceed the practical line ceiling. Both must be computed in the source frame rate, because drop-frame arithmetic makes millisecond rounding lossy.

from fractions import Fraction
from dataclasses import dataclass

@dataclass
class Cue:
    start_ms: float
    end_ms: float
    text: str

# SMPTE ST 12-1 — 29.97 fps drop-frame; one frame ≈ 33.367 ms
FRAME_RATE = Fraction(30000, 1001)
FRAME_MS = float(1 / FRAME_RATE * 1000)
# FCC 47 CFR § 79.1 — industry-accepted ±2 frame synchronization budget
SYNC_TOLERANCE_MS = 2 * FRAME_MS  # ≈ 66.73 ms

def check_sync(cue_start_ms: float, audio_onset_ms: float) -> bool:
    drift = abs(cue_start_ms - audio_onset_ms)
    return drift <= SYNC_TOLERANCE_MS  # FCC § 79.1 synchronous requirement

def check_placement(cue: Cue) -> list[str]:
    issues = []
    # CTA-608-E / CTA-708-E — 32 cells per row, 15 rows; 4 rows is the
    # practical readability ceiling cited in FCC § 79.1 placement guidance
    for ln in cue.text.splitlines():
        if len(ln) > 32:
            issues.append(f"row exceeds 32 cells: {len(ln)}")
    if len(cue.text.splitlines()) > 4:
        issues.append("exceeds 4-row placement ceiling")
    return issues

The architectural rationale is that synchronization must be measured against a real audio onset, not against the previous cue. In a production pipeline the audio_onset_ms reference comes from speech-onset detection or an embedded SMPTE timecode track extracted with ffprobe; the standalone technique is automated sync drift detection, and the full ingest audit checklist is how to audit caption pipelines for FCC compliance. Because the ±2-frame budget is frame-rate dependent, the constant must be recomputed for 25 fps (PAL), 23.976 fps (film), and 59.94 fps sources rather than assumed at NTSC.

Ofcom dwell time & reading-speed validation

Engineering problem: Ofcom constrains how long a caption may stay on screen and how fast a viewer can be expected to read it. A cue that is technically synchronous can still fail because it flashes for under a second or packs more characters per second than a reader can absorb. The validator therefore needs both a dwell-duration check and a characters-per-second (CPS) check, with the reading-speed ceiling parameterized by audience.

import pysrt  # pip install pysrt — real SubRip parser used in production

# Ofcom subtitling standards — dwell window and reading-speed ceilings
OFCOM_MIN_DWELL_S = 1.0           # minimum on-screen time
OFCOM_MAX_DWELL_S = 7.0           # maximum on-screen time
ADULT_WPM = 180                   # ~17 CPS for adult programming
CHILDREN_WPM = 120                # slower reading speed for children's content

def validate_ofcom(path: str, audience: str = "adult") -> list[dict]:
    cps_ceiling = (ADULT_WPM if audience == "adult" else CHILDREN_WPM) * 5 / 60
    out = []
    for sub in pysrt.open(path, encoding="utf-8"):
        dwell = (sub.end.ordinal - sub.start.ordinal) / 1000.0  # ms → s
        visible = len("".join(sub.text_without_tags.split()))
        cps = visible / dwell if dwell else float("inf")
        problems = []
        if dwell < OFCOM_MIN_DWELL_S:
            problems.append(f"dwell {dwell:.2f}s < 1s")     # Ofcom min dwell
        if dwell > OFCOM_MAX_DWELL_S:
            problems.append(f"dwell {dwell:.2f}s > 7s")     # Ofcom max dwell
        if cps > cps_ceiling:
            problems.append(f"cps {cps:.1f} > {cps_ceiling:.1f}")  # reading speed
        if problems:
            out.append({"index": sub.index, "issues": problems})
    return out

The rationale for converting words-per-minute into a CPS ceiling (wpm × 5 / 60, using the standard five-character word) is that captions are validated at the character level, not the word level — the same canonical metric used by the FCC-oriented enforcing character-rate limits in QC module, which lets one CPS engine serve both jurisdictions. The dwell-window logic and its interaction with shot-change snapping are expanded in Ofcom subtitle timing requirements explained. Note that pysrt returns millisecond ordinals, so dwell is derived in integer milliseconds before any floating-point division — this avoids the rounding drift that creeps in when durations are reconstructed from HH:MM:SS,mmm strings.

Format topology: SCC, SRT & WebVTT transport

Engineering problem: Caption payloads must move from baseband SDI or SMPTE ST 2110 IP into file-based OTT delivery without timing degradation or metadata loss. CEA-608/708 carry control codes and window positioning that have no direct equivalent in SRT, and the conversion to WebVTT or TTML must preserve frame-accurate alignment while remapping positioning into the cue/region model. The hardest part is reading the raw SCC byte pairs correctly before any timing math happens.

import struct
import codecs

# CTA-608-E — SCC carries 2-byte EIA-608 pairs as big-endian hex; parity
# is odd on the wire and must be stripped before ASCII interpretation.
def decode_scc_pair(hexword: str) -> tuple[int, int]:
    raw = struct.unpack(">H", bytes.fromhex(hexword))[0]
    hi = (raw >> 8) & 0x7F   # strip odd-parity bit (CTA-608-E §)
    lo = raw & 0x7F
    return hi, lo

def is_control_code(hi: int, lo: int) -> bool:
    # EIA-608 control codes occupy 0x10–0x1F in the high byte
    return 0x10 <= hi <= 0x1F

def scc_text(pairs: list[str]) -> str:
    chars = []
    for word in pairs:
        hi, lo = decode_scc_pair(word)
        if is_control_code(hi, lo):
            continue  # PAC / mid-row codes handled by the state machine
        for b in (hi, lo):
            if 0x20 <= b <= 0x7F:
                chars.append(codecs.decode(bytes([b]), "ascii"))
    return "".join(chars)

The architectural rationale is that SCC is a binary transport wrapper, not text, so it must be parsed as a state machine over byte pairs — Preamble Address Codes set row and style, mid-row codes change style mid-line, and only the remaining pairs are printable characters. Treating the file as text corrupts positioning and produces the orphaned-control-code failures listed below. Once decoded into the canonical cue model, the same cues serialize to WebVTT regions or TTML spatial expressions; the trade-offs of each target are the subject of the SCC vs SRT vs WebVTT architecture comparison, and the encoding pitfalls of the read step are handled in fixing UTF-8 encoding errors in SCC files. When cues finally land in an HLS or DASH manifest, the mapping WebVTT cues to broadcast timelines technique reconciles media-time against the program’s start-of-timecode.

Secure pipeline integrity & emergency overrides

Engineering problem: Caption pipelines run in high-availability environments where packet loss, file corruption, or unauthorized modification can produce silent compliance failures. The architecture must therefore prove chain-of-custody — every node verifies that the payload it received is the payload that was signed upstream — and it must reserve a guaranteed-delivery path for Emergency Alert System text that bypasses normal QC throttling without desynchronizing the program.

import hashlib
import hmac

def sign_payload(payload: bytes, key: bytes) -> str:
    # Chain-of-custody manifest: HMAC-SHA256 over the raw caption bytes
    return hmac.new(key, payload, hashlib.sha256).hexdigest()

def verify_payload(payload: bytes, signature: str, key: bytes) -> bool:
    expected = sign_payload(payload, key)
    return hmac.compare_digest(expected, signature)  # constant-time check

def route_cue(cue_text: str, payload: bytes, sig: str, key: bytes) -> str:
    # FCC 47 CFR § 79.2 — emergency information must never be dropped;
    # EAS text is routed even if the standard QC gate would quarantine it.
    if cue_text.startswith("EAS:"):
        return "priority-playout"
    if not verify_payload(payload, sig, key):
        return "quarantine"  # tampered or corrupted — reject before mux
    return "qc-gate"

The rationale is that integrity and priority are orthogonal concerns that must not be collapsed: a tampered payload is quarantined, but a genuine emergency cue is fast-pathed past throttling while still carrying its mandatory compliance markers. Constant-time comparison (hmac.compare_digest) prevents timing side-channels on the signature check. The full gateway design — including idempotent ingest, replay protection, and immutable audit logging — is detailed in secure caption pipeline design and the worked implementation in building a compliant caption ingestion gateway.

Threshold reference

Every numeric limit a validator enforces is collected here so that the same constants are not re-derived in prose. These values drive the code blocks above and the telemetry below.

Threshold Value Source clause
Sync tolerance (NTSC) ±2 frames ≈ ±66.73 ms FCC 47 CFR § 79.1
Live caption latency ≤ ~2 s FCC 47 CFR § 79.1
Pre-recorded accuracy ≥ 99% characters FCC 47 CFR § 79.1
Min dwell time 1.0 s Ofcom subtitling code
Max dwell time 7.0 s Ofcom subtitling code
Reading speed (adult) 160–180 wpm (~17 CPS) Ofcom subtitling code
Reading speed (children) 120 wpm (~10 CPS) Ofcom subtitling code
Frame duration (29.97) 33.367 ms SMPTE ST 12-1
Frame duration (25.0) 40.000 ms SMPTE ST 12-1
Max chars per row 32 cells CTA-608-E
Practical row ceiling 4 rows FCC § 79.1 placement
EAS delivery mandatory, non-droppable FCC 47 CFR § 79.2

Unified validator

The per-domain checks compose into a single stream validator that records a structured violation for every cue, so the gate decision and the audit trail come from one pass.

from dataclasses import dataclass, field
from datetime import timedelta
import re

@dataclass
class BroadcastCaptionValidator:
    reference_ms: float = 0.0
    sync_tolerance_ms: float = 66.73   # FCC § 79.1 — ±2 frames @ 29.97
    min_dwell_s: float = 1.0           # Ofcom min dwell
    max_dwell_s: float = 7.0           # Ofcom max dwell
    max_cps: float = 17.0              # Ofcom adult reading speed
    max_rows: int = 4                  # FCC § 79.1 placement ceiling
    violations: list = field(default_factory=list)

    def validate(self, cues: list[Cue]) -> dict:
        self.violations = []
        for i, c in enumerate(cues):
            dwell = (c.end_ms - c.start_ms) / 1000.0
            visible = len(re.sub(r"\s+", "", c.text))
            cps = visible / dwell if dwell > 0 else float("inf")
            checks = {
                "sync": abs(c.start_ms - self.reference_ms) <= self.sync_tolerance_ms,
                "min_dwell": dwell >= self.min_dwell_s,
                "max_dwell": dwell <= self.max_dwell_s,
                "cps": cps <= self.max_cps,
                "rows": len(c.text.splitlines()) <= self.max_rows,
            }
            for rule, ok in checks.items():
                if not ok:
                    self.violations.append({"cue": i, "rule": rule})
        total = len(cues) or 1
        return {
            "passed": total - len({v["cue"] for v in self.violations}),
            "failed": len({v["cue"] for v in self.violations}),
            "violations": self.violations,
        }

This class is the unit that the broader CI/CD gating for caption builds pipeline invokes per asset, and the same violations list feeds the telemetry writer in the next section. For frame-accurate drop-frame compensation in the dwell math, the standard-library Python datetime module and fractions.Fraction keep the 30000/1001 ratio exact instead of approximating 29.97.

Production failure modes & gotchas

The same handful of defects account for most caption rejections in production. Each is cheap to detect once you know the signature.

  • Drop-frame wraparound. Treating 29.97 fps as 30 fps accumulates ~3.6 s of error per hour, pushing cues outside the ±2-frame budget late in a program. Detection: compare computed vs embedded end-of-program timecode. Fix: use Fraction(30000, 1001) and SMPTE ST 12-1 drop-frame counting; never multiply frames by 33 ms.
  • Orphaned control codes. A truncated SCC file ends mid-pair, leaving a PAC or mid-row code with no terminating text; downstream decoders render garbage or drop the row. Detection: assert an even pair count and a balanced control/printable ratio. Fix: validate during parsing SCC with Python libraries before conversion.
  • BOM / encoding mismatch. A UTF-8 BOM or Latin-1 mojibake at the head of an SRT/WebVTT file corrupts the first cue and can silently shift CPS counts. Detection: sniff with charset_normalizer before parse. Fix: normalize to BOM-less UTF-8 at ingest.
  • Reading-speed pass that fails human review. A cue inside the dwell window can still exceed the CPS ceiling because dwell and character count were validated independently. Detection: compute CPS as a single derived metric, not two separate gates. Fix: the unified validator above.
  • Region collision on conversion. CEA-708 window positioning maps to overlapping WebVTT regions, obscuring graphics in violation of placement rules. Detection: check for region overlap after WebVTT cue extraction and validation. Fix: clamp positions to a safe-area grid during mux.
  • Latency creep on live ingest. Buffer accumulation in the encode chain pushes live captions past the ~2 s FCC window without any single stage looking wrong. Detection: measure end-to-end latency against a live audio reference, not per-stage. Fix: monitor with automated sync drift detection.
  • Dropped emergency text. A QC gate quarantines an EAS cue as malformed because it does not match normal formatting. Detection: tag EAS cues before the gate. Fix: the § 79.2 fast-path routing shown above.

Compliance telemetry & audit trail

A regulator audit asks for evidence, not assurances, so every validation pass must emit a structured record that maps each violation back to its governing clause. Writing newline-delimited JSON (and Parquet for batch scale) makes the audit trail queryable and immutable rather than buried in log text.

import json
from datetime import datetime, timezone

CLAUSE = {
    "sync": "FCC 47 CFR § 79.1",
    "min_dwell": "Ofcom subtitling code",
    "max_dwell": "Ofcom subtitling code",
    "cps": "Ofcom reading-speed limit",
    "rows": "FCC § 79.1 placement",
}

def emit_telemetry(asset_id: str, result: dict, fh) -> None:
    ts = datetime.now(timezone.utc).isoformat()
    for v in result["violations"]:
        record = {
            "ts": ts,
            "asset": asset_id,
            "cue": v["cue"],
            "rule": v["rule"],
            "clause": CLAUSE.get(v["rule"], "unmapped"),
        }
        fh.write(json.dumps(record) + "\n")  # NDJSON — one violation per line

Each record is self-describing: the clause field lets an auditor filter directly on a regulation, and the append-only NDJSON stream is trivially loaded into pandas or written to Parquet for retention. The downstream aggregation, dashboards, and retention scheduling are covered in scheduled QC report generation and the worked example in generating daily QC reports with Python. At fleet scale, this writer is the sink for async batch caption processing, where thousands of assets validate concurrently and stream violations to a single partitioned telemetry store.

Part of: Broadcast Media Closed Captioning & QC Automation — the production reference for broadcast and OTT caption engineering.