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.

FCC Part 79 ingest gates — pass through to mux, fail to quarantine A single caption asset enters at the top and descends through four sequential acceptance gates: synchronization (47 CFR 79.1(j), plus or minus 2 frames), accuracy and completeness (greater than or equal to 99 percent character fidelity and 100 percent cue coverage), placement (10 percent title-safe inset) and control-code integrity (no illegal C0 bytes, lines at most 32 columns). A green pass path runs straight down from gate to gate and ends at ROUTE_TO_MUX, shown in the site accent colour. From every gate a magenta fail path branches right into a single QUARANTINE node that records the failing frame range and the violated clause id. FCC Part 79 ingest gate sequence post-ingest, pre-mux · all gates must pass pass fail Caption asset in canonical cue model Gate 1 · Synchronization § 79.1(j) · drift ≤ ±2 frames (±66.7 ms) Gate 2 · Accuracy & completeness § 79.1(j) · ≥99% chars · 100% cue coverage Gate 3 · Placement § 79.1 · 10% title-safe inset, all edges Gate 4 · Control-code integrity CTA-608-E · clean C0 bytes · ≤32 cols ROUTE_TO_MUX embed into CEA-608/708 ancillary QUARANTINE verdict: FAIL · § 79.1(j)(3) + failing frame range attached + violated clause id attached → WORM audit store

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.

Part of: Broadcast Captioning Architecture & Compliance.