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) |
Edge cases & known gotchas
- Non-SRT sources in the same ingest directory:
*.srtglob 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 == 0would divide by zero; the guard returns0CPS, but overlapping cues (nextstart< previousend) yield a negative gap thatabs()hides — assert monotonic ordering before trusting the verdict. - Ledger continuity across runs: seeding every batch from
GENESIS_HASHcreates an isolated chain per run. To prove an unbroken history, persist the lastrecord_hashand pass it in asprev_hashfor 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.
Related
- Detecting sync drift in automated QC pipelines — Replace this page’s inter-cue gap proxy with true ffprobe PTS comparison for media-backed assets.
- How to enforce FCC character rate limits programmatically — The reading-rate gate this auditor records, with the full CPS derivation and per-jurisdiction limits.
- FCC Part 79 Compliance Checklist — The parent reference defining every threshold this auditor checks against.