FCC Part 79 Compliance Checklist
FCC Part 79 (47 CFR § 79.1) reads as four plain-English principles — captions must be accurate, synchronous, complete, and properly placed — but a pre-mux validator cannot act on principles. It needs numbers. The compliance gap this page closes is the translation of those four principles into deterministic, machine-checkable thresholds that run on every ingest before an asset reaches the multiplexer: a synchronization budget of ±2 video frames (±66.7 ms at 29.97 fps), a character-fidelity floor of ≥99%, a cue-completeness rate of 100%, and a 10% title-safe margin on placement. Each check below maps to a specific clause, emits a structured verdict you can archive as audit evidence, and either passes the asset to mux or quarantines it with the exact failing frame range attached.
This checklist is the United States enforcement layer of the broader Broadcast Captioning Architecture & Compliance framework. Where the Ofcom code on subtitling standards parameterizes the same dimensions for the UK, the validators here hard-target the FCC clause set — but they read every limit from the threshold reference table below so the same code can be re-pointed at another jurisdiction by swapping a config row.
Pipeline stage & prerequisites
FCC validation executes at the post-ingest, pre-mux boundary: after raw caption files (SCC, SRT, WebVTT, or TTML) have been parsed into a canonical cue model, and before the cues are embedded into CEA-608/708 ancillary data or packaged for OTT. Placing the gate here is what makes it cheap — a violation caught at ingest costs a re-render, the same violation caught at playout is a reportable regulatory incident under § 79.1(j).
The validators assume upstream normalization has already happened. SRT sources should have passed through SRT timestamp normalization, SCC sources through parsing SCC with Python libraries, and WebVTT through WebVTT cue extraction and validation, so that by the time this checklist runs every cue is a uniform {start_ms, end_ms, text, region} record. Continuous sync measurement against a live audio reference is handled separately by automated sync drift detection; this page enforces the static, per-asset acceptance criteria.
Required tooling:
| Tool / library | Version (tested) | Role in the checklist |
|---|---|---|
ffprobe (FFmpeg) |
6.0+ | Read stream timebase and packet PTS for the audio onset reference |
pysrt |
1.1.2 | Parse SRT cue onsets into structured timestamps |
numpy |
1.26+ | Vectorized drift arithmetic and bounding-box collision checks |
webvtt-py |
0.4.6+ | Parse WebVTT cues and line/position placement cue settings |
pytest |
8.0+ | Threshold regression fixtures for each gate |
| Python | 3.10+ | fractions for exact NTSC rationals, dataclasses, pattern matching |
Step-by-step implementation
Step 1 — Enforce the synchronization tolerance
Part 79 requires captions to appear when the corresponding audio is spoken. The industry-accepted budget for the offset is ±2 frames; at 29.97 fps that is ±66.7 ms. Compute frame duration from the exact NTSC rational rather than a rounded 33.367 literal, or the tolerance drifts over long programs.
from fractions import Fraction
from dataclasses import dataclass
# SMPTE ST 12-1 — 29.97 fps NTSC expressed as an exact rational (30000/1001)
NTSC_FPS = Fraction(30000, 1001)
FRAME_MS = float(1 / NTSC_FPS * 1000) # ~33.3667 ms per frame
SYNC_TOLERANCE_MS = 2 * FRAME_MS # FCC 47 CFR § 79.1(j) — ±2 frame sync budget
@dataclass
class SyncVerdict:
drift_ms: float
frames_drift: float
compliant: bool
def validate_sync(cue_start_ms: float, audio_onset_ms: float) -> SyncVerdict:
drift = cue_start_ms - audio_onset_ms
frames = abs(drift) / FRAME_MS
return SyncVerdict(
drift_ms=round(drift, 3),
frames_drift=round(frames, 2),
compliant=abs(drift) <= SYNC_TOLERANCE_MS, # § 79.1(j) "synchronous"
)
# Cue is 80 ms ahead of audio onset -> exceeds ±2 frames -> FLAG
print(validate_sync(10004.500, 10004.420))
The audio_onset_ms reference comes from ffprobe, which reads packet presentation timestamps without a full decode, keeping the check cheap enough to run on every asset:
import subprocess, json
def first_audio_pts_ms(media_path: str) -> float:
# ffprobe reads packet PTS only — no decode — to anchor the audio timebase
out = subprocess.run(
["ffprobe", "-v", "error", "-select_streams", "a:0",
"-show_entries", "packet=pts_time", "-read_intervals", "%+#1",
"-of", "json", media_path],
capture_output=True, text=True, check=True,
)
pts = json.loads(out.stdout)["packets"][0]["pts_time"]
return float(pts) * 1000.0
Step 2 — Validate accuracy and cue completeness
Section 79.1 requires captions to match spoken content with minimal omission and to run for the full duration of the program. In engineering terms that is a character-fidelity floor of ≥99% against a reference transcript and a cue-completeness rate of 100% — no gaps longer than a configured silence threshold across the runtime.
import numpy as np
CHAR_FIDELITY_FLOOR = 0.99 # § 79.1(j) "accurate" — ≥99% pre-recorded character fidelity
MAX_GAP_MS = 5000.0 # § 79.1(j) "complete" — no uncaptioned span beyond expected silence
def character_fidelity(reference: str, captured: str) -> float:
# Levenshtein ratio via numpy DP table; 1.0 == identical
a, b = reference, captured
dp = np.arange(len(b) + 1)
for i, ca in enumerate(a, 1):
prev, dp[0] = dp[0], i
for j, cb in enumerate(b, 1):
cur = dp[j]
dp[j] = min(dp[j] + 1, dp[j - 1] + 1, prev + (ca != cb))
prev = cur
dist = int(dp[-1])
return 1.0 - dist / max(len(a), len(b), 1)
def completeness(cues, program_end_ms: float) -> dict:
# cues: sorted list of (start_ms, end_ms); flag any gap > MAX_GAP_MS
gaps = []
cursor = 0.0
for start, end in cues:
if start - cursor > MAX_GAP_MS:
gaps.append((round(cursor, 1), round(start, 1)))
cursor = max(cursor, end)
if program_end_ms - cursor > MAX_GAP_MS:
gaps.append((round(cursor, 1), round(program_end_ms, 1)))
return {"gaps": gaps, "compliant": len(gaps) == 0} # 100% coverage required
Step 3 — Check structural integrity and the 32-character line limit
CEA-608 renders on a 32×15 character grid; a line over 32 characters either truncates or overflows the decoder buffer. Stray C0 control bytes (0x00–0x1F, excluding the legal 0x09/0x0A) corrupt roll-up state machines. Validate both before any format translation.
import re
MAX_CHARS_PER_LINE = 32 # ANSI/CTA-608-E — 32×15 caption grid line width
ILLEGAL_C0 = re.compile(r"[\x00-\x08\x0B\x0C\x0E-\x1F]") # C0 controls minus TAB/LF
def validate_structure(cue_text: str) -> dict:
violations = []
for idx, line in enumerate(cue_text.splitlines(), 1):
stripped = line.strip()
if len(stripped) > MAX_CHARS_PER_LINE: # § 79.1 "properly placed" — grid width
violations.append(f"line {idx}: {len(stripped)} > {MAX_CHARS_PER_LINE} chars")
if ILLEGAL_C0.search(line): # orphaned control code -> decoder corruption
violations.append(f"line {idx}: illegal C0 control byte")
return {"violations": violations, "compliant": not violations}
Step 4 — Enforce safe-area placement
Captions must stay inside the title-safe area so consumer overscan and lower-third graphics never clip them. The accepted margin is 10% of the active picture on all four edges. For WebVTT-sourced cues, the line/position cue settings carry placement; for CEA-708, window attributes do. Reduce both to a normalized 0–1 bounding box and test it against the safe-area mask.
SAFE_MARGIN = 0.10 # SMPTE RP 218 / § 79.1 "properly placed" — 10% title-safe inset
def in_safe_area(x: float, y: float, w: float, h: float) -> bool:
# x,y,w,h normalized 0..1 over the active picture
lo, hi = SAFE_MARGIN, 1.0 - SAFE_MARGIN
return (x >= lo) and (y >= lo) and (x + w <= hi) and (y + h <= hi)
def validate_placement(cue_boxes) -> dict:
failures = [i for i, b in enumerate(cue_boxes) if not in_safe_area(*b)]
return {"out_of_bounds": failures, "compliant": not failures}
Step 5 — Aggregate into a routing verdict
The four gates fold into one machine-readable record that the media asset management (MAM) system consumes over REST or gRPC. A single failing gate sets pipeline_action to quarantine and records the failing frame range for the audit trail required by § 79.1(j)(3).
import json
def build_verdict(asset_id, sync, fidelity, completeness_res, structure, placement):
checks = {
"sync_drift_ms": sync.drift_ms,
"sync_compliant": sync.compliant,
"char_fidelity": round(fidelity, 4),
"cue_completeness": completeness_res["compliant"],
"structure_ok": structure["compliant"],
"safe_area_ok": placement["compliant"],
}
passed = (sync.compliant and fidelity >= CHAR_FIDELITY_FLOOR
and completeness_res["compliant"] and structure["compliant"]
and placement["compliant"])
return json.dumps({
"asset_id": asset_id,
"compliance_status": "PASS" if passed else "FAIL",
"checks": checks,
"pipeline_action": "ROUTE_TO_MUX" if passed else "QUARANTINE",
}, indent=2)
Translation between formats — WebVTT/TTML in, CEA-608/708 out — is the stage where most of these gates actually trip, because fractional-frame rounding and lossy style mapping silently break sync and placement. The structural detail of that conversion is covered in SCC vs SRT vs WebVTT architecture, and the hardened ingest boundary that runs these checks under zero-trust isolation is described in secure caption pipeline design.
Threshold reference table
Every limit the validators read lives here, not in prose, so a config swap re-points the same code at a different timebase or jurisdiction.
| Parameter | FCC value | Spec clause | Notes |
|---|---|---|---|
| Sync tolerance | ±2 frames (±66.7 ms @ 29.97) | 47 CFR § 79.1(j) | Tightens to ±1 frame on 59.94/60 fps progressive |
| Frame duration (29.97) | 33.3667 ms | SMPTE ST 12-1 | Use Fraction(30000,1001), never a rounded literal |
| Character fidelity (pre-recorded) | ≥ 99% | 47 CFR § 79.1(j) | Measured vs reference transcript |
| Live caption latency | ≤ ~2 s | 47 CFR § 79.1(j) | Live/near-live “synchronous” expectation |
| Cue completeness | 100% (no gap > 5 s) | 47 CFR § 79.1(j) | Excludes genuine program silence |
| Max characters per line | 32 | ANSI/CTA-608-E | CEA-608 32×15 grid |
| Caption grid | 32 cols × 15 rows | ANSI/CTA-608-E | Row/column addressing for 608 |
| Title-safe margin | 10% per edge | SMPTE RP 218 | Applies to 608 rows and 708 windows |
| Emergency alert text | mandatory aural + visual | 47 CFR § 79.2 | Cannot be dropped by any QC gate |
Verification & test pattern
Lock each threshold behind a pytest fixture so a config change that loosens a gate fails CI rather than silently shipping non-compliant assets. Test the boundary on both sides — exactly at tolerance must pass, one millisecond beyond must fail.
import pytest
from validators import validate_sync, FRAME_MS, validate_structure
@pytest.fixture
def two_frames_ms():
return 2 * FRAME_MS # ±66.7 ms reference boundary
def test_sync_at_boundary_passes(two_frames_ms):
# Exactly ±2 frames must be accepted (§ 79.1(j))
v = validate_sync(1000.0 + two_frames_ms, 1000.0)
assert v.compliant is True
def test_sync_just_over_boundary_fails(two_frames_ms):
# One ms beyond ±2 frames must quarantine
v = validate_sync(1000.0 + two_frames_ms + 1.0, 1000.0)
assert v.compliant is False
def test_line_over_32_chars_flagged():
res = validate_structure("X" * 33) # ANSI/CTA-608-E grid width
assert res["compliant"] is False
assert "33 > 32" in res["violations"][0]
def test_orphaned_control_byte_flagged():
res = validate_structure("hello\x01world")
assert res["compliant"] is False
The detailed audit-trail and immutable-logging patterns that wrap these assertions for regulatory reporting are documented in the child page, how to audit caption pipelines for FCC compliance.
Troubleshooting / failure modes
Sync passes at start of program, fails mid-air : Root cause: a frame-rate or timebase mismatch (e.g. a 25 fps caption file aligned to a 23.976 fps mezzanine) producing cumulative drift, not a constant offset. Fix: measure drift as a curve across the runtime, not a single spot check — delegate to automated sync drift detection.
Drop-frame timecode arithmetic off by ~3.6 s per hour
: Root cause: treating 29.97 as 30 fps, or skipping the dropped frame numbers at minute boundaries. Fix: derive frame duration from Fraction(30000, 1001) and honor SMPTE ST 12-1 drop-frame counting.
Character fidelity dips below 99% only on accented programming : Root cause: extended CEA-608 character pairs lost during a UTF-8 → 608 mapping, or BOM bytes counted as content. Fix: normalize encoding before scoring; strip the BOM and map extended glyphs explicitly during conversion.
Placement check fails after WebVTT → 708 conversion
: Root cause: WebVTT line/position settings dropped during translation, defaulting cues to the bottom edge inside the 10% margin. Fix: carry placement settings through the conversion matrix and re-validate the normalized bounding box.
100% completeness check fires on legitimate silence
: Root cause: MAX_GAP_MS set tighter than genuine program silence (music beds, ambient segments). Fix: raise the gap threshold or feed a silence-detection mask from ffprobe silencedetect so real silence is exempt.
Roll-up captions render as phantom rows downstream
: Root cause: orphaned or overlapping control codes (e.g. duplicate 0x9425 roll-up commands) surviving the structural gate. Fix: extend the C0 check with a control-code state-machine validator during parsing SCC with Python libraries.
Operational notes
At batch scale the checklist is I/O-bound on ffprobe invocations, not CPU-bound on the Python validators, so the throughput lever is process concurrency around the subprocess calls rather than vectorization. Size the worker pool to roughly the number of physical cores; each worker spawns one short-lived ffprobe per asset and holds only the cue list plus a single reference frame in memory, so per-worker resident size stays well under 100 MB even for feature-length programs. Stream cues lazily from the parser instead of materializing the whole file — a three-hour SCC asset is tens of thousands of cues, and the fidelity DP table is the only structure that grows with cue text length.
Keep each validator stateless and idempotent: a given asset and reference must always yield the same verdict, which lets the QC layer re-run quarantined assets after a fix without special-casing. Write every verdict — pass or fail — to an append-only (WORM) store keyed by asset_id and content hash; the § 79.1(j) audit trail must survive the asset itself. The same character-rate dimension this checklist treats as a fidelity input is enforced as a first-class gate in enforcing character rate limits in QC, so share one threshold config across both rather than duplicating limits.
Frequently asked questions
Does Part 79 specify ±2 frames anywhere in the text? No — § 79.1 only says “synchronous.” The ±2-frame budget is the industry-accepted engineering interpretation that QC vendors and broadcasters enforce in practice; codify it as a config value, not a constant, so it can be tightened for premium delivery.
Is the 99% character-fidelity floor a hard FCC number? It is the widely cited expectation for pre-recorded programming derived from the FCC’s caption-quality best practices, not a verbatim statutory figure. Live captioning is judged on a more lenient, latency-aware basis under the same “accurate” principle.
Do these gates cover live captioning? The structural, placement, and completeness gates do. The static sync gate does not — live caption latency (≤ ~2 s) is a streaming measurement; use the drift detector against a live audio reference instead of a per-asset acceptance check.
Related
- Automated sync drift detection — Continuous drift-curve measurement for the live and cumulative cases this static gate cannot catch.
- SCC vs SRT vs WebVTT architecture — Format translation matrix where most sync and placement gates actually trip.
- Ofcom code on subtitling standards — The UK threshold set to swap in for multi-territory distribution.
- Secure caption pipeline design — The zero-trust ingest boundary that runs this checklist under sandboxed isolation.
- How to audit caption pipelines for FCC compliance — Immutable audit-trail and memory-safe validation patterns built on this checklist.