Automated QC Validation & Reporting

Automated quality control for broadcast closed captioning has moved from a discretionary post-production courtesy to a non-negotiable regulatory and operational control. Modern distribution pipelines ingest, transcode, and route captioned assets across terrestrial, cable, satellite, and OTT endpoints at volumes that make manual spot-checking economically impossible. For broadcast engineers, captioning vendors, and Python automation builders, a QC validation layer must behave as a stateless, auditable gatekeeper: it parses legacy and modern caption formats, reconstructs temporal event graphs, enforces jurisdictional thresholds frame by frame, and emits machine-readable telemetry that maps every violation back to the clause that created it.

This guide is the reference hub for that control layer. It covers the regulatory baselines that define the thresholds, where validation executes in the end-to-end pipeline, the formats you have to support, and a working Python pattern for each of the four core checks — synchronization drift, reading-rate enforcement, build gating, and scheduled reporting. Lead with the code, then read the rationale.

The QC validation gate in the caption pipeline Captions flow from ingest through parse into a central QC validation gate. The gate fans out into four validation dimensions — sync drift, reading rate, syntax and encoding, and regulatory thresholds — and only passing assets continue to mux and playout. Every verdict is emitted downward as telemetry into a write-once audit store. Ingest SCC · SRT · VTT Parse canonical cues QC validation gate deterministic · stateless Mux Playout / package linear · OTT ABR Sync drift Reading rate Syntax & encoding Regulatory thresholds Telemetry + audit log WORM store · clause-tagged · hashed

Regulatory & Engineering Context

Compliance in closed captioning is governed by explicit statutory and technical frameworks, and each one resolves to a number a validator can assert against. In the United States, FCC 47 CFR § 79.1 establishes the four pillars of caption quality — accuracy, synchronicity, completeness, and placement — and the operational detail is enumerated in the FCC Part 79 compliance checklist. Internationally, the Ofcom Code on subtitling standards and ETSI EN 301 775 impose parallel accessibility duties, and Canada’s CRTC and the EU’s EN 301 549 add their own reading-rate and latency ceilings. These are not editorial guidelines; they are mathematically verifiable thresholds that must be enforced at the frame and event level.

Technical standards turn the regulation into a wire format. SMPTE ST 334-1 defines the carriage and timing of CEA-608/708 ancillary data inside MPEG-2 and SDI streams; the W3C WebVTT specification and IMSC1/TTML define the same for IP-native delivery. A production validation engine treats these specs as executable schemas: every caption cue — roll-up, paint-on, or pop-on — is evaluated against a deterministic rule set whose conditions trace directly back to a regulatory clause. The engineering constraint that follows is unforgiving: the validator must be deterministic (the same input always yields the same verdict), stateless (no cross-asset context that could leak between jobs), and fully auditable (every pass and fail is logged with its clause reference), because the output is evidence in an FCC or Ofcom inquiry.

Pipeline Stage Map

QC validation is not a monolithic step; it is a gate that sits at a specific point in the caption lifecycle. Captions are ingested, parsed and normalized into a canonical cue model, validated against the rule set, and only then muxed into the delivery container and pushed to playout or adaptive-bitrate packaging. The validator consumes the normalized cue model plus a media reference (via ffprobe) and never triggers a full decode. Failures branch the asset into a quarantine or remediation path rather than letting it reach the mux stage.

QC validation gate placement in the caption lifecycle Ingested SCC, SRT, WebVTT and TTML assets are parsed into a canonical cue model, then enter the QC validation gate, which runs four parallel checks: temporal alignment, CPS and WPM reading rate, format and charset, and regulatory clause thresholds. A pass decision routes the asset to mux and then playout or ABR packaging; a fail routes it to quarantine and remediation. Both the pass decision and quarantine emit records to a write-once telemetry and audit store. Ingest SCC / SRT / VTT / TTML Parse & normalize canonical cue model QC validation gate Temporal alignment PTS drift CPS / WPM rate reading speed Format & charset syntax & encoding Regulatory clause thresholds Pass? fail pass Quarantine / remediation Mux → container Playout / ABR package Telemetry + audit log · WORM store every verdict logged

The normalization step is shared with the broader parsing workflow: the same canonical cue model produced for QC is the one described in SRT, SCC & WebVTT parsing workflows. When throughput climbs into thousands of assets per day, the gate is fanned out with async batch caption processing so that parse, validate, and report run as bounded-concurrency workers rather than a serial loop.

Format & Standard Overview

A validator has to accept whatever the upstream chain emits, which in practice means at least four caption representations with very different timing models and capabilities. The decision logic for which format belongs where is covered in depth in the SCC vs SRT vs WebVTT architecture guide; the table below is the quick reference the validator’s parser dispatch keys off.

Format Standard Timing model Native styling/positioning Primary distribution QC focus
SCC CEA-608 / SMPTE ST 334-1 29.97 fps drop-frame timecode Yes (control codes) Terrestrial, cable, satellite Control-code integrity, drop-frame math
SRT De facto (SubRip) HH:MM:SS,mmm wall-clock No OTT ingest, interchange Frame quantization, overlap, CPS
WebVTT W3C WebVTT HH:MM:SS.mmm wall-clock Yes (CSS/regions) HLS/DASH streaming Cue syntax, region bounds, BOM
TTML / IMSC1 W3C TTML2 / IMSC1.1 media / smpte tick Yes (XML styling) OTT, archival, exchange Namespace resolution, tick rate

The threshold values these formats are checked against are summarized in one place so they are not buried in prose:

Constraint Threshold Source
Sync drift, linear broadcast ≤ 100 ms (≈ ±2–3 frames @ 29.97) FCC 47 CFR § 79.1
Sync drift, streaming / VOD ≤ 150 ms Industry / Ofcom guidance
Reading rate (English) 160–180 WPM target, 200–250 WPM hard ceiling FCC / Ofcom ITC legacy
Characters per second (CPS) ≤ 17 CPS sustained (≈ 20 CPS peak) EBU-TT-D / BBC subtitle guidelines
Characters per line, CEA-608 32 CEA-608
Maximum rows 4 (practical placement limit) CEA-608 / safe-area practice
Minimum display duration 1.0–1.5 s FCC / Ofcom

Temporal Alignment & Sync Drift Detection

The most insidious failure vector is temporal: when caption presentation timestamps diverge from the audio/video PTS, the result is a direct synchronicity violation under FCC 47 CFR § 79.1. The engineering problem is that drift is rarely linear — encoder timestamp wrapping, variable frame rate injection, and timebase mismatches between the caption file and the mezzanine all produce non-monotonic offsets — so start/end spot checks miss it. The validator must sample across the whole runtime and track cumulative deviation.

import subprocess, json
import numpy as np
import pysrt

def video_pts_seconds(path: str) -> np.ndarray:
    """Extract frame PTS from the reference media without a full decode."""
    out = subprocess.run(
        ["ffprobe", "-select_streams", "v:0", "-show_entries",
         "frame=pts_time", "-of", "json", "-read_intervals", "%+#5000", path],
        capture_output=True, text=True, check=True,
    ).stdout
    frames = json.loads(out).get("frames", [])
    return np.array([float(f["pts_time"]) for f in frames if "pts_time" in f])

def max_sync_drift(srt_path: str, media_path: str) -> float:
    """Return worst-case caption onset drift in milliseconds."""
    cues = pysrt.open(srt_path)
    pts = video_pts_seconds(media_path)
    worst = 0.0
    for cue in cues:
        onset = cue.start.ordinal / 1000.0          # cue start in seconds
        nearest = pts[np.argmin(np.abs(pts - onset))]  # closest video frame PTS
        worst = max(worst, abs(onset - nearest) * 1000.0)
    return worst

drift_ms = max_sync_drift("asset.srt", "asset.mp4")
# FCC 47 CFR § 79.1 — linear broadcast synchronicity tolerance is 100 ms
assert drift_ms <= 100.0, f"Sync drift {drift_ms:.1f} ms exceeds FCC tolerance"

The architectural point is that drift detection belongs in the post-parse, pre-mux stage where the media and caption artifact are co-located but neither has been transcoded yet. A robust implementation widens this into a rolling correlation window with exponential smoothing so GOP-boundary jitter does not masquerade as systematic drift, and it distinguishes intentional lip-sync offsets from genuine error by establishing a baseline alignment window first. The full algorithm — window sizing, smoothing coefficients, and severity banding — is documented in Automated sync drift detection.

Reading Speed & Character-Rate Enforcement

Reading-rate ceilings exist so that viewers with cognitive or visual impairments can actually read the captions before they disappear. The constraint is rigid: production validators enforce a sustained reading rate well under the 200–250 WPM hard ceiling, and the more precise modern metric is characters per second (CPS), capped around 17 CPS sustained per EBU-TT-D and BBC subtitle guidance. The engineering problem is tokenization correctness — control characters, mid-sentence line breaks, and punctuation-dense segments all distort a naive word count, and overlapping cues distort the active display window.

import re
import pysrt

CONTROL = re.compile(r"[�-]")

def cps_and_wpm(text: str, start_ms: int, end_ms: int):
    """Compute CPS and WPM for a single cue over its display window."""
    clean = CONTROL.sub("", text.replace("\n", " ")).strip()
    seconds = max((end_ms - start_ms) / 1000.0, 1e-6)  # guard zero-length cues
    chars = len(clean.replace(" ", ""))                # exclude spaces from CPS
    words = len(clean.split())
    return chars / seconds, (words / seconds) * 60.0

def check_reading_rate(srt_path: str):
    violations = []
    for cue in pysrt.open(srt_path):
        cps, wpm = cps_and_wpm(cue.text, cue.start.ordinal, cue.end.ordinal)
        # EBU-TT-D / BBC — 17 CPS sustained reading-rate ceiling
        # FCC/Ofcom — 200 WPM practical accessibility ceiling
        if cps > 17.0 or wpm > 200.0:
            violations.append((cue.index, round(cps, 1), round(wpm)))
    return violations

print(check_reading_rate("asset.srt"))

The rationale is that reading-rate enforcement is a remediation trigger, not just a flag: dense blocks are candidates for cue splitting or display-window extension, and the validator should surface the exact offending cue indices so an editor (or an automated re-timer) can act without re-reading the whole file. Crucially, enforcement must not silently truncate dialogue, which would create an accuracy violation while fixing a rate one. The line-length, minimum-duration, and CPS interplay — and how to extend windows without colliding with the next cue — is the subject of Enforcing character rate limits in QC.

CI/CD Gating for Caption Builds

When captions are produced as build artifacts — generated, normalized, and committed alongside media manifests — QC belongs in the same CI/CD pipeline that gates code. The engineering problem is making the validator return a clean, deterministic exit contract: a single non-zero exit on any hard failure so the pipeline blocks the merge or deploy, with structured detail for the developer. This turns “we’ll QC it before air” into “it cannot reach air unless it passes.”

import sys, json

def gate(report: dict) -> int:
    """CI/CD gate: non-zero exit blocks the build on any hard failure."""
    hard = [v for v in report["violations"] if v["severity"] == "error"]
    soft = [v for v in report["violations"] if v["severity"] == "warning"]
    print(json.dumps({"errors": len(hard), "warnings": len(soft)}))
    for v in hard:
        # Each failure cites the clause it breaks — audit-ready CI logs
        print(f"::error::cue {v['cue']} {v['clause']}: {v['detail']}",
              file=sys.stderr)
    return 1 if hard else 0      # FCC § 79.1 hard fail must block deployment

if __name__ == "__main__":
    with open(sys.argv[1]) as fh:
        sys.exit(gate(json.load(fh)))

Architecturally, the gate must stay stateless and fast: each job takes one asset plus its caption track, runs the rule set, and exits without retaining context, which is what lets the same container run in a GitHub Actions matrix, a GitLab pipeline, or a pre-merge hook. The ::error:: workflow-command syntax above surfaces failures inline in the CI UI; the broader pattern — Dockerized validators, pytest fixtures that assert against known-bad fixtures, and threshold parameterization — is detailed in CI/CD gating for caption builds. The same exit contract underpins the secure caption pipeline design gate that rejects tampered or unsigned payloads before they are ever validated.

Scheduled QC Reporting & Distribution

Per-asset gating answers “can this ship?”; scheduled reporting answers “what is the state of the catalogue?” Compliance officers, vendor portals, and engineering dashboards need rolled-up, time-boxed summaries — daily, weekly, per-asset — delivered without a human assembling them. The engineering problem is aggregation over a window without rescanning every artifact, which is why telemetry is emitted at validation time and reporting reads from that store.

import json, datetime, collections, pathlib

def daily_summary(telemetry_dir: str, day: datetime.date) -> dict:
    """Aggregate one day of QC telemetry into a distributable summary."""
    counts = collections.Counter()
    assets = set()
    for path in pathlib.Path(telemetry_dir).glob(f"{day.isoformat()}*.json"):
        record = json.loads(path.read_text())
        assets.add(record["asset_id"])
        for v in record["violations"]:
            counts[v["clause"]] += 1       # group failures by regulatory clause
    return {
        "date": day.isoformat(),
        "assets_validated": len(assets),
        "violations_by_clause": dict(counts),
        "generated_at": datetime.datetime.utcnow().isoformat() + "Z",
    }

summary = daily_summary("/var/qc/telemetry", datetime.date(2026, 6, 28))
print(json.dumps(summary, indent=2))

The rationale for grouping by regulatory clause rather than by asset is that it produces the exact shape an auditor asks for — “show me every § 79.1 synchronicity failure this quarter” — and it lets engineering see which clause is the dominant failure mode and fix it upstream. Scheduling this with a cron-triggered batch runner, applying backpressure so a report job never starves the validation workers, and shaping Parquet telemetry for analytics are covered in Scheduled QC report generation.

Failure Modes & Gotchas

The same handful of production failures recur across nearly every caption QC deployment. Detecting them explicitly — rather than letting them surface as silent drift or a failed audit — is the difference between a validator and a rubber stamp.

  • Drop-frame timecode wraparound. SCC uses 29.97 fps drop-frame, which skips frame numbers 00 and 01 at each minute boundary except every tenth minute. Treating it as non-drop accumulates ~3.6 s of error per hour. Detect by validating the ;/: separator and recomputing total frames with the drop-frame formula; remediate during SRT timestamp normalization.
  • BOM and encoding mismatches. A UTF-8 BOM at the head of a WebVTT or SRT file breaks the WEBVTT signature check and corrupts the first cue. Detect with charset_normalizer before parsing; strip the BOM rather than guessing the codec. This is the root cause behind most “first caption missing” tickets.
  • Orphaned control codes. SCC pop-on captions that lose their End-of-Caption pairing leave a buffer that never flushes, producing phantom captions or decoder lockups. Detect with a state machine that asserts paired control codes, as described in parsing SCC with Python libraries.
  • Overlapping cue windows. Two cues whose display windows intersect cause flicker and break CPS math (the active window becomes ambiguous). Detect by asserting cue[n].end <= cue[n+1].start; remediate by clamping to a non-negative gap.
  • Variable frame rate (VFR) sources. A single nominal frame rate assumed against a VFR mezzanine makes every PTS comparison wrong. Detect via ffprobe avg_frame_rate vs r_frame_rate divergence; pin the timebase explicitly before drift checks.
  • Floating-point timestamp drift. Accumulating cue offsets in floats compounds rounding across a long-form asset. Use integer millisecond or frame arithmetic for all timing math; reserve floats for the final reported delta only.
  • Unclosed WebVTT tags and percentage positioning. Malformed style/region blocks render on modern players but break legacy hardware decoders. Detect during WebVTT cue extraction & validation and apply fallback positioning rather than passing the cue through.

Compliance Telemetry & Audit Trail

Validation output is evidence, so it must be machine-readable, human-auditable, and structured for downstream aggregation. Every record maps each violation to its cue, its timestamp, its severity, and — most importantly — the regulatory clause it breaks, so the daily report and any future audit can be assembled by query rather than by re-reading logs.

import json, hashlib, datetime

def emit_telemetry(asset_id: str, violations: list[dict]) -> str:
    """Emit a hashed, append-only QC record for WORM-compliant storage."""
    record = {
        "asset_id": asset_id,
        "validated_at": datetime.datetime.utcnow().isoformat() + "Z",
        "violations": violations,   # each: {cue, t_ms, severity, clause, detail}
        "verdict": "fail" if any(v["severity"] == "error"
                                 for v in violations) else "pass",
    }
    body = json.dumps(record, sort_keys=True).encode("utf-8")
    # Chain-of-custody: SHA-256 over the canonical body for tamper evidence
    record["sha256"] = hashlib.sha256(body).hexdigest()
    return json.dumps(record)

print(emit_telemetry("EP10427", [
    {"cue": 42, "t_ms": 91320, "severity": "error",
     "clause": "FCC 47 CFR § 79.1", "detail": "sync drift 118 ms"},
]))

For analytics at catalogue scale, the same records are written in columnar Parquet partitioned by date and clause, which keeps per-clause audit queries cheap. Whichever serialization is used, the artifacts — raw logs, parsed event graphs, and final verdicts — must be retained per jurisdictional mandate and stored in a WORM-compliant repository so the chain of custody is unbroken when an FCC or Ofcom inquiry arrives. The cryptographic hash above is what makes a stored record provably untampered.

Frequently Asked Questions

Where in the pipeline should QC validation run? After parsing and normalization but before the mux stage, while the caption artifact and the media reference are co-located and neither has been transcoded. Running it post-mux means a failure has already been baked into the delivery container.

Why CPS instead of WPM? WPM is the legacy regulatory unit and is still asserted as a ceiling, but CPS (characters per second) is language-agnostic and far less distorted by word length, so modern validators enforce both and treat CPS as the precise control.

Can the validator auto-fix violations? It can trigger remediation — frame-quantizing timestamps, splitting dense cues, clamping overlaps — but it must never silently truncate dialogue, because trading an accuracy violation for a rate fix breaks § 79.1 completeness. Auto-fixes should be logged and re-validated, not applied blind.

What makes a QC result audit-ready? Determinism (same input, same verdict), an explicit clause reference on every violation, and tamper-evident storage. A pass/fail with no clause mapping and no hash is not evidence.

Part of: Closed Captioning & QC Automation — the broadcast captioning engineering reference.