How to Audit Caption Pipelines for FCC Compliance

You need to prove, after the fact, that every caption asset a pipeline emitted was checked against FCC Part 79 compliance — and that the proof itself has not been edited. A pass/fail verdict alone is not enough: 47 CFR § 79.1 is enforced through record-keeping, so an auditor that writes plain log lines is worthless the moment anyone can append, delete, or back-date a row. The exact requirement this page solves is a deterministic batch audit that evaluates each asset against the § 79.1 thresholds — a ±2-frame synchronization budget (±66.7 ms at 29.97 fps) and a reading-rate ceiling near 17 characters per second — and emits a SHA-256 hash-chained ledger in which altering any single record invalidates every record after it.

Minimal working pipeline auditor

import hashlib
import json
from dataclasses import dataclass, asdict
from datetime import datetime, timezone
from decimal import Decimal, ROUND_HALF_UP
from pathlib import Path

import pysrt  # pip install pysrt

# FCC 47 CFR § 79.1 — captions must be accurate, synchronous and complete.
SYNC_TOLERANCE_MS = Decimal("66.7")   # ±2 frames @ 29.97 fps (synchronicity)
MAX_CPS = Decimal("17.0")             # reading-rate ceiling (accuracy/completeness)
GAP_TOLERANCE_MS = SYNC_TOLERANCE_MS  # inter-cue gap proxy for on-grid timing
GENESIS_HASH = "0" * 64               # first link of the audit chain


@dataclass(frozen=True)
class AuditRecord:
    asset: str
    max_cps: str
    max_gap_ms: str
    compliant: bool
    checked_utc: str
    prev_hash: str
    record_hash: str


def _cps(text: str, dur_s: Decimal) -> Decimal:
    chars = len(text.replace("\n", " ").strip())   # newlines are layout, not content
    return Decimal(chars) / dur_s if dur_s > 0 else Decimal(0)


def audit_asset(srt_path: Path, prev_hash: str) -> AuditRecord:
    subs = pysrt.open(str(srt_path), encoding="utf-8-sig")   # tolerate a UTF-8 BOM
    worst_cps, worst_gap = Decimal(0), Decimal(0)
    for i, cue in enumerate(subs):
        dur = Decimal(cue.duration.ordinal) / 1000           # ms ordinal -> seconds
        worst_cps = max(worst_cps, _cps(cue.text, dur))      # § 79.1 reading rate
        if i:                                                # inter-cue gap = sync proxy
            gap = Decimal(cue.start.ordinal - subs[i - 1].end.ordinal)
            worst_gap = max(worst_gap, abs(gap))
    compliant = worst_cps <= MAX_CPS and worst_gap <= GAP_TOLERANCE_MS
    q = lambda v: str(v.quantize(Decimal("0.1"), rounding=ROUND_HALF_UP))
    body = {
        "asset": srt_path.name,
        "max_cps": q(worst_cps),
        "max_gap_ms": q(worst_gap),
        "compliant": compliant,
        "checked_utc": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
        "prev_hash": prev_hash,
    }
    # Each link commits to the canonical JSON of the previous record's hash.
    digest = hashlib.sha256(json.dumps(body, sort_keys=True).encode()).hexdigest()
    return AuditRecord(record_hash=digest, **body)


def audit_pipeline(ingest: Path) -> list[AuditRecord]:
    ledger, prev = [], GENESIS_HASH
    for srt in sorted(ingest.glob("*.srt")):                 # deterministic order
        record = audit_asset(srt, prev)
        ledger.append(record)
        prev = record.record_hash        # tamper-evidence: break one link, break the rest
    return ledger


if __name__ == "__main__":
    for record in audit_pipeline(Path("/var/media/captions/ingest")):
        print(json.dumps(asdict(record)))   # append-only NDJSON, one verdict per line

Code walkthrough

audit_asset opens each file with pysrt.open(..., encoding="utf-8-sig"). The utf-8-sig codec strips a leading byte-order mark if one is present, which is the single most common cause of a corrupted first cue index and a phantom failure on an otherwise compliant asset. Iterating the SubRipFile yields SubRipItem objects whose .duration, .start, and .end are real pysrt SubRipTime values; reading .ordinal returns integer milliseconds, so all timing math stays exact instead of routing through fragile string parsing.

Two § 79.1 dimensions are measured per cue. The reading rate (_cps) divides the visible character count — newlines collapsed to spaces because they are line layout, not content — by the cue’s on-screen duration in seconds; the worst value across the asset is what the verdict gates on. The synchronization proxy is the inter-cue gap: the distance between one cue’s end and the next cue’s start. On a frame-locked timeline these boundaries should be tight, so a gap exceeding the ±2-frame budget flags cues that have drifted off the frame grid. For media with a companion video file you would replace this proxy with true PTS comparison; the dedicated detector for that lives under automated sync drift detection, and this auditor is deliberately structured so its per-cue loop can call that detector instead.

Every arithmetic value is a Decimal quantized with ROUND_HALF_UP. IEEE 754 binary floats accumulate error in frame-accurate timing, and a verdict that flips on a rounding artifact is not defensible audit evidence; Decimal makes the same input always produce the same hash.

The hash chain is the part that turns a log into evidence. Each record’s body includes the prev_hash of the record before it, and record_hash is the SHA-256 of that body serialized with json.dumps(..., sort_keys=True) — canonical key ordering guarantees the digest is reproducible on any machine. Because every link commits to its predecessor, editing one historical verdict changes its hash, which breaks the prev_hash of the next record, and so on to the end of the ledger. This is what satisfies the record-keeping spirit of § 79.1: you can hand a regulator the ledger and a single re-computation proves nothing was altered or back-dated after the fact. audit_pipeline walks the ingest directory in sorted (deterministic) order so the chain is reproducible, and main emits newline-delimited JSON — an append-only format that streams straight into WORM-compliant storage.

Threshold reference

Check Limit Frame / unit equivalent Source clause
Synchronization budget ±66.7 ms ±2 frames @ 29.97 fps FCC 47 CFR § 79.1 (synchronous)
Inter-cue gap (sync proxy) ≤ 66.7 ms ±2 frames Derived from § 79.1
Reading rate ≤ 17 CPS ~180 wpm § 79.1 (accurate / complete)
Cue completeness 100% every cue parsed § 79.1 (complete)
Verdict hash SHA-256 64 hex chars Tamper-evidence (record-keeping)
One edited record breaks every downstream link in the SHA-256 audit chain Three verdict cards are stacked vertically. Each card holds an asset filename, a compliant=true badge, a prev_hash field, the verdict fields max_cps and max_gap_ms, and a record_hash field. The first card's prev_hash is the GENESIS value 0000…0000. The record_hash of each card is carried by an arrow into the prev_hash of the next card. The first link matches (accent). In the second card the max_cps body field is edited from 9.4 to 22.6, so its record_hash recomputes from b8e0…c1a2 to 7d44…f0ab. The third card still references the old b8e0…c1a2, so its prev_hash no longer matches the recomputed hash and the link is broken in a warning colour. The compliant verdict is unchanged, illustrating that the chain detects tampering of the record itself, not the pass/fail result. SHA-256 hash-chained audit ledger · one edit, every downstream link breaks clip_001.srt compliant ✓ prev_hash 0000…0000 (GENESIS) max_cps 12.6 max_gap_ms 41.0 record_hash a17f…3e9c clip_002.srt compliant ✓ prev_hash a17f…3e9c ✓ max_cps 22.6 max_gap_ms 38.0 record_hash b8e0…c1a2 → 7d44…f0ab clip_003.srt compliant ✓ prev_hash b8e0…c1a2 ≠ 7d44…f0ab max_cps 15.1 max_gap_ms 52.0 record_hash 5c20…ba61 record_hash → prev_hash link intact ✓ stored hash ≠ recomputed chain breaks here first link of the chain: prev_hash seeded with GENESIS 0000…0000 edited after the fact: max_cps body field 9.4 → 22.6 record_hash recomputes Re-hashing the ledger top-to-bottom localizes the first altered record — the verdict can still read compliant.

Edge cases & known gotchas

  • Non-SRT sources in the same ingest directory: *.srt glob silently skips SCC and WebVTT assets, so a mixed folder reports a falsely clean audit. Route SCC through parsing SCC with Python libraries and WebVTT through WebVTT cue extraction and validation into the same per-cue verdict shape before chaining.
  • Drop-frame vs non-drop timebase: the ±66.7 ms budget assumes 29.97 fps drop-frame. Auditing a 25 fps (PAL) or 60 fps asset against that constant manufactures phantom sync failures; read the frame rate per asset rather than hard-coding it.
  • Cues that are off the frame grid: SRT exported from a non-frame-accurate editor lands cue edges between frames, inflating the inter-cue gap. Run SRT timestamp normalization first so the gap check measures real drift, not quantization noise.
  • Zero-duration or overlapping cues: a cue with duration == 0 would divide by zero; the guard returns 0 CPS, but overlapping cues (next start < previous end) yield a negative gap that abs() hides — assert monotonic ordering before trusting the verdict.
  • Ledger continuity across runs: seeding every batch from GENESIS_HASH creates an isolated chain per run. To prove an unbroken history, persist the last record_hash and pass it in as prev_hash for the next run so the chain spans the whole asset library.

Where this plugs in

This auditor is the evidence-producing layer of the FCC Part 79 Compliance Checklist: the checklist defines the gates an asset must pass at ingest, and this script records that the gates ran in a form a regulator can verify. Drop it in after parsing has normalized every format to a common cue model, fan it out at library scale with the queue and backpressure patterns in async batch caption processing, and stream its NDJSON output into the append-only store described in secure caption pipeline design.

Part of: Broadcast Captioning Architecture & Compliance.