Enforcing Character Rate Limits in QC

Character-rate validation is the gate that stops captions from outrunning the viewer. When text density climbs above what a CEA-608 decoder can flush — or above what a human can read before the cue clears — the result is dropped glyphs, on-screen stutter, forced scrolling, and a documented accessibility failure under FCC 47 CFR § 79.1 (synchronicity and completeness). This step sits inside Automated QC Validation & Reporting as a deterministic checkpoint: it converts a parsed caption track into an instantaneous characters-per-second (CPS) signal, asserts that signal against jurisdictional ceilings, and emits a pass/fail verdict with the exact offending cue indices.

Problem framing

The constraint is physical before it is regulatory. CEA-608 carries two bytes of caption data per frame, so at 29.97 fps the channel sustains roughly 29.97 characters per second of airtime — a hard ceiling no authoring tool can exceed without the decoder buffer overrunning. Production validators do not run at that ceiling; they enforce a sustained 20 CPS with short bursts permitted to 30 CPS for dense dialogue or emergency-alert overlays, and the more conservative EBU-TT-D / BBC reading-rate guidance caps sustained density at 17 CPS (≈ 160–180 WPM) so viewers with cognitive or visual impairments can keep pace.

Three things make this hard to get right:

  • Tokenization correctness. A naive len(text) over-counts: control codes, mid-cue line breaks, and inter-word spaces are not glyphs a decoder paints, so they must be excluded from the CPS numerator the same way a CEA-608 decoder accounts for buffer occupancy.
  • Window choice. Per-cue chars / duration hides bursts that straddle a cue boundary. A rolling window over the whole track catches the dense moment that a per-cue average smooths away.
  • Non-destructive remediation. A rate fix must never silently truncate dialogue — trading a completeness violation for a rate one re-breaks § 79.1. Enforcement flags; it never deletes words.

A spike in character density is also a reliable proxy for upstream timing damage, which is why this check is a prerequisite for automated sync drift detection: rapid cue turnover inflates timestamp jitter and produces false drift warnings unless density is validated first.

A sliding window converts a caption track into an instantaneous CPS signal A 1-second window slides left to right across cues placed on a time axis from 0 to 6 seconds. At each cue onset the window aggregates the visible-character count of the cues currently inside it and divides by the window duration to yield a characters-per-second value, drawn as a bar. Five readings (8, 13, 17, 14 and 9 CPS) stay below the dashed 20 CPS ceiling and pass; the reading over a dense cluster of two tightly packed cues reaches 27 CPS, crossing the ceiling and being flagged as a violation. INSTANTANEOUS CPS PER WINDOW 0 10 20 30 20 CPS ceiling 8 13 17 27 14 9 CAPTION CUES ON TIME AXIS 1 s window 0s 1s 2s 3s 4s 5s 6s

Pipeline stage & prerequisites

Rate enforcement runs in the post-parse, pre-render stage. By this point the upstream parser has already produced a normalized cue model — the same canonical model described in SRT, SCC & WebVTT parsing workflows — with every timestamp converted to an integer millisecond epoch. Running the check before SRT timestamp normalization is a mistake: drop-frame notation and TTML tick offsets will distort the duration denominator and throw the CPS math off.

Required tooling:

Dependency Minimum version Role
Python 3.9+ dataclasses, list[...] generics, collections.deque
pysrt 1.1.2+ SRT cue parsing (cue.start.ordinal gives integer ms)
webvtt-py 0.4.6+ WebVTT cue extraction when the source is IP-native
charset_normalizer 3.x BOM/codec detection before parsing (prevents a corrupt first cue)

For SCC sources the cue model comes from the state machine described in parsing SCC with Python libraries; for HLS/DASH sources it comes from WebVTT cue extraction & validation. The rate checker itself is format-agnostic — it consumes the normalized events and nothing else.

Step-by-step implementation

1. Normalize cues into a uniform event model

Collapse every parser’s output into one CaptionEvent shape so the rate logic never branches on format. Strip the BOM during decode, not after.

from dataclasses import dataclass
import pysrt

@dataclass
class CaptionEvent:
    start_ms: int
    end_ms: int
    text: str

def load_srt_events(path: str) -> list[CaptionEvent]:
    """Parse an SRT file into millisecond-epoch caption events."""
    # cue.start.ordinal is integer milliseconds — avoids float rounding
    return [
        CaptionEvent(c.start.ordinal, c.end.ordinal, c.text)
        for c in pysrt.open(path, encoding="utf-8-sig")  # 'utf-8-sig' strips a UTF-8 BOM
    ]

2. Count only the glyphs the decoder paints

Visible-character accounting mirrors CEA-608 buffer occupancy: spaces, newlines, and control sequences do not consume a display cell in the CPS sense.

import re

_CONTROL = re.compile(r"[\x00-\x1f]")  # strip control codes (CEA-608 PAC/midrow artifacts)

def visible_chars(text: str) -> int:
    """Count glyphs a decoder renders: drop control codes, newlines, and spaces."""
    flattened = _CONTROL.sub("", text).replace("\n", " ")
    return len(flattened.replace(" ", ""))  # spaces excluded from CPS numerator

3. Compute an instantaneous rate with a sliding window

A rolling deque over time-sorted events gives an exact instantaneous CPS without the O(n²) cost of re-scanning the track per cue. Expired events fall off the left of the window as the parser advances.

from collections import deque

def rolling_cps(
    events: list[CaptionEvent],
    window_ms: int = 1000,
    sustained_cps: float = 20.0,   # CEA-608 operational sustained ceiling
    burst_cps: float = 30.0,       # short-burst allowance (EAS / dense dialogue)
) -> list[tuple[int, float, str]]:
    """Return (timestamp_ms, rate_cps, status) for each cue onset.

    status is PASS (<= sustained), BURST (sustained < r <= burst), or FAIL (> burst).
    """
    ordered = sorted(events, key=lambda e: e.start_ms)
    window: deque[CaptionEvent] = deque()
    results: list[tuple[int, float, str]] = []

    for event in ordered:
        window.append(event)
        now = event.start_ms
        # Purge events whose onset is older than the window
        while window and (now - window[0].start_ms) >= window_ms:
            window.popleft()

        chars = sum(visible_chars(e.text) for e in window)
        rate = chars / (window_ms / 1000.0)

        if rate <= sustained_cps:
            status = "PASS"
        elif rate <= burst_cps:
            status = "BURST"   # tolerated transient — log, do not fail
        else:
            status = "FAIL"    # FCC 47 CFR § 79.1 — exceeds decoder buffer budget
        results.append((now, round(rate, 2), status))

    return results

4. Reduce the signal to a gating verdict

CI/CD needs a single boolean. Collapse the per-onset series into a deterministic exit contract and surface the offending indices so an editor — or an automated re-timer — can act without re-reading the file.

def evaluate(events: list[CaptionEvent], window_ms: int = 1000) -> dict:
    """Collapse the rolling series into an audit-ready verdict."""
    series = rolling_cps(events, window_ms=window_ms)
    failures = [(t, r) for t, r, s in series if s == "FAIL"]
    return {
        "max_cps": max((r for _, r, _ in series), default=0.0),
        "failure_count": len(failures),
        "first_failure_ms": failures[0][0] if failures else None,
        # EBU-TT-D / BBC sustained reading-rate ceiling for cross-check
        "verdict": "fail" if failures else "pass",
    }

Deeper handling — multi-language payloads, overlapping-cue resolution, and frame-accurate timecode conversion — is covered in how to enforce FCC character rate limits programmatically.

Threshold reference table

Constraint Threshold Source
CEA-608 physical channel ceiling ≈ 29.97 CPS @ 29.97 fps (2 bytes/frame) CEA-608 / SMPTE ST 334-1
Sustained CPS (operational) ≤ 20 CPS Broadcast operational practice
Burst CPS (transient, EAS/dense) ≤ 30 CPS Broadcast operational practice
Sustained CPS (accessibility) ≤ 17 CPS EBU-TT-D / BBC subtitle guidelines
Reading rate (English) 160–180 WPM target, 200–250 WPM hard ceiling FCC / Ofcom ITC legacy
Minimum sustained rate (starvation floor) ≥ 5 CPS over extended spans Encoder-health heuristic
Characters per line (CEA-608) 32 CEA-608
Minimum display duration 1.0–1.5 s FCC / Ofcom

The 17 CPS accessibility figure and the 20 CPS operational figure are not in conflict: 17 CPS is the reader-comfort target enforced for compliance against EBU-TT-D, while 20/30 CPS is the decoder-survival envelope. A strict pipeline asserts the lower of the two for the target jurisdiction. Jurisdiction-specific ceilings are enumerated in the FCC Part 79 compliance checklist and the Ofcom Code on subtitling standards.

Verification & test pattern

Validate the algorithm against hand-built fixtures whose expected CPS you can compute by hand. The assertions below pin both a clean pass and a deliberate over-budget burst.

def test_rolling_cps_flags_burst():
    events = [
        CaptionEvent(0, 900, "hello there"),        # 10 visible chars in ~1s -> 10 CPS
        CaptionEvent(1000, 1900, "this is a very dense caption line that overruns"),
    ]
    series = rolling_cps(events, window_ms=1000)

    # First onset: 10 visible chars over a 1s window -> well under the 20 CPS ceiling
    assert series[0][1] <= 20.0 and series[0][2] == "PASS"

    # Dense second cue must breach the 30 CPS burst ceiling
    assert series[1][2] == "FAIL"

def test_visible_chars_excludes_whitespace_and_controls():
    # newline + spaces + a control code must not inflate the count
    assert visible_chars("ab\ncd\x01 ef") == 6

def test_clean_track_passes():
    events = [CaptionEvent(0, 2000, "a calm short line")]
    assert evaluate(events)["verdict"] == "pass"

Run these as part of the same suite that gates the build; the pattern slots directly into the pytest fixtures described in CI/CD gating for caption builds.

Troubleshooting / failure modes

Whitespace-inflated density. Artificial carriage returns or padding raise per-line cell occupancy without changing raw CPS, yet still trigger decoder truncation or forced scrolling. Fix: assert characters-per-line (≤ 32 for CEA-608) independently of CPS — both checks are required.

Per-cue averaging hides bursts. A long cue with a dense opening clause passes a per-cue average while overrunning the decoder in its first 400 ms. Fix: always use the sliding window; never divide chars by full cue duration.

Overlapping cue windows. Two cues whose display windows intersect double-count characters and make the active window ambiguous. Fix: assert cue[n].end <= cue[n+1].start and clamp to a non-negative gap before rate evaluation.

Encoder starvation (rate floor breach). Sustained density below ~5 CPS over a long span signals dropped cues or malformed timestamps, not compliant captions. Fix: alarm on the floor as well as the ceiling.

BOM-corrupted first cue. A UTF-8 BOM left in the file breaks the first cue’s text and skews its character count. Fix: decode with utf-8-sig (step 1) or detect with charset_normalizer before parsing.

Multibyte glyph miscounting. Counting bytes instead of code points over-charges accented or CJK captions. Fix: operate on str (code points), never bytes, and normalize to NFC first.

Operational notes

Memory must scale with window size, not asset duration. The deque holds only the events inside the current window, so peak memory is bounded regardless of whether the asset is a 30-second promo or a four-hour feature — provided the caller streams cues rather than materializing the whole sorted list for very large tracks. For batch QC across thousands of assets, drive the checker with async batch caption processing and cap worker-pool concurrency so I/O on shared caption storage does not saturate; each job is stateless and consumes exactly one caption track. Wrap rolling_cps in a generator when integrating into a streaming pipeline so violations yield in real time instead of buffering a feature-length asset. Finally, serialize every verdict — including the per-onset CPS series — into the telemetry store that feeds scheduled QC report generation, so character-rate failures are queryable by clause alongside every other QC dimension during an audit.

Frequently Asked Questions

Should spaces count toward CPS? No. A CEA-608 decoder’s buffer pressure tracks painted glyphs, so spaces, newlines, and control codes are excluded from the CPS numerator. They are counted separately for characters-per-line checks.

Why a sliding window instead of per-cue CPS? Per-cue division averages away bursts that straddle cue boundaries. A rolling window over the whole track reports the true instantaneous peak — the value that actually overruns the decoder.

Is the limit 17, 20, or 30 CPS? All three describe different ceilings: 17 CPS is the EBU-TT-D reader-comfort target, 20 CPS is the operational sustained limit, and 30 CPS is the short-burst decoder-survival envelope. Enforce the lowest one your target jurisdiction requires.

Can the checker auto-fix violations? It can flag dense cues for splitting or window extension, but it must never truncate dialogue — that trades a rate violation for an FCC § 79.1 completeness violation. Remediation is logged and re-validated, never applied blind.

Part of: Automated QC Validation & Reporting — the broadcast caption QC reference.