CEA-708 vs CEA-608 Decoder Targeting

The decision this page settles is whether a delivered asset must carry CEA-608 line 21 byte pairs, CEA-708 DTVCC service blocks, or both, and exactly how the 708 stream carries the 608 compatibility bytes that a legacy decoder still needs — because FCC 47 CFR § 79.1 obliges a digital broadcaster to deliver captions that both a modern DTV receiver and a downconverted analog path can render. Getting the target wrong produces a silent failure: a 708-only DTVCC packet renders on a smart TV but a set-top box downconverting to composite finds no field-1 data on line 21 and shows nothing, or a 608-only file plays on legacy hardware while the DTV decoder’s default caption service #1 stays blank. Both outcomes clear a naive “captions present” check and fail at the FCC Part 79 audit. The controlling numbers are the 3-bit service-number field (services 1–6 standard, 7–63 extended), the 5-bit block-size field, and the two cc_type codes that separate 608 field data from DTVCC packets inside one cc_data() structure.

import struct

# --- Constants from the governing specs -------------------------------------
# CTA-708 (formerly CEA-708) DTVCC service-block header layout and the cc_data()
# packet structure it shares with CEA-608 line 21, carried under ATSC A/53
# Part 4 caption_data and mandated for delivery by FCC 47 CFR § 79.1.
CC_TYPE_608_FIELD1 = 0b00    # CEA-608 line 21 field 1 (caption service #1 lives here)
CC_TYPE_608_FIELD2 = 0b01    # CEA-608 line 21 field 2 (caption service #2)
CC_TYPE_DTVCC_START = 0b10   # CTA-708 DTVCC packet header byte follows
CC_TYPE_DTVCC_DATA = 0b11    # CTA-708 DTVCC packet continuation
CC_VALID = 0b1
MARKER_BITS = 0b11111        # 5 reserved 1-bits at the top of every cc_data pair


def odd_parity(byte: int) -> int:
    """CEA-608 line 21 requires odd parity on each 7-bit character byte."""
    b = byte & 0x7F
    ones = bin(b).count("1")
    return b | (0x00 if ones % 2 else 0x80)     # set bit 7 to make the 1-count odd


def cc_data_pair(cc_type: int, b1: int, b2: int, valid: int = CC_VALID) -> bytes:
    """One cc_data triplet: flag byte + two payload bytes (ATSC A/53 cc_data())."""
    flag = (MARKER_BITS << 3) | (valid << 2) | (cc_type & 0b11)
    return struct.pack(">BBB", flag, b1 & 0xFF, b2 & 0xFF)


def dtvcc_service_block(service_number: int, block_data: bytes) -> bytes:
    """CTA-708 §6.2.2 service block: 3-bit service number + 5-bit block_size.
    Services 1-6 use the standard header; 7-63 use the extended header form."""
    if not 1 <= service_number <= 63:           # CTA-708 caps DTVCC at 63 services
        raise ValueError("service_number must be 1..63")
    size = len(block_data)
    if size > 31:                               # 5-bit block_size field, max 31 bytes
        raise ValueError("service block data exceeds 31-byte block_size field")
    if service_number <= 6:                     # standard service block header
        header = struct.pack(">B", (service_number << 5) | size)
    else:                                       # extended service block header
        std = struct.pack(">B", (0b111 << 5) | size)             # svc-number field = 7
        ext = struct.pack(">B", 0b11000000 | (service_number & 0x3F))
        header = std + ext
    return header + block_data


def dtvcc_packet(sequence: int, service_blocks: bytes) -> bytes:
    """CTA-708 §5 DTVCC packet: header = (2-bit seq | 6-bit packet_size_code).
    packet_size_code n encodes 2*n bytes; the packet is padded to that size."""
    total = len(service_blocks) + 1             # +1 for the packet header byte itself
    size_code = (total + 1) // 2                # round up to 2-byte granularity
    if size_code == 0 or size_code > 63:        # code 0 is reserved for 128-byte packets
        raise ValueError("packet_size_code must be 1..63")
    packet_size = size_code * 2
    header = struct.pack(">B", ((sequence & 0b11) << 6) | (size_code & 0x3F))
    packet = header + service_blocks
    packet += b"\x00" * (packet_size - len(packet))     # null padding to packet_size
    return packet


def build_cc_data(dtvcc: bytes, cea608_field1: list) -> bytes:
    """Interleave CEA-608 compatibility bytes (field 1) with the CTA-708 DTVCC
    packet into one cc_data() sequence, exactly as a 708-aware decoder and a
    legacy 608-only decoder both read it from the same MPEG-2 user_data."""
    pairs = []
    # CEA-608 field-1 pairs first — legacy line 21 decoders stop here and still work.
    for b1, b2 in cea608_field1:
        pairs.append(cc_data_pair(CC_TYPE_608_FIELD1, odd_parity(b1), odd_parity(b2)))
    # CTA-708 DTVCC: first pair carries the packet header (START), the rest DATA.
    for i in range(0, len(dtvcc), 2):
        chunk = dtvcc[i:i + 2]
        b1 = chunk[0]
        b2 = chunk[1] if len(chunk) == 2 else 0x00
        cc_type = CC_TYPE_DTVCC_START if i == 0 else CC_TYPE_DTVCC_DATA
        pairs.append(cc_data_pair(cc_type, b1, b2))
    return b"".join(pairs)


if __name__ == "__main__":
    # DTVCC service #1 (English by convention) carrying an illustrative payload.
    caption = b"\x47\x91\x48\x49"               # 708 command + text bytes (illustrative)
    block = dtvcc_service_block(1, caption)     # service #1 == 708 primary English
    packet = dtvcc_packet(sequence=0, service_blocks=block)
    # CEA-608 caption service #1 backward-compat bytes on field 1.
    cea608 = [(0x14, 0x20), (0x48, 0x49)]       # 0x1420 = Resume Caption Loading (CC1)
    cc = build_cc_data(packet, cea608)
    print(f"cc_data bytes: {cc.hex()}  ({len(cc) // 3} pairs)")

Code walkthrough

cc_data_pair is the atom of the whole scheme. Every caption triplet in an ATSC A/53 caption_data() structure is a flag byte followed by two payload bytes, and the flag byte is where 608 and 708 diverge: five reserved marker bits, one cc_valid bit, then a 2-bit cc_type. That cc_type0 for 608 field 1, 1 for 608 field 2, 2 for a DTVCC packet start, 3 for DTVCC packet continuation — is the entire routing mechanism. A 608-only decoder consumes the cc_type == 0 pairs and ignores the rest; a 708 decoder reassembles the cc_type == 2/3 pairs into a packet and ignores the 608 field data. One byte stream, two audiences.

odd_parity exists because line 21 predates the DTV era and still demands odd parity on each 7-bit character byte. The 708 DTVCC bytes carry no parity, so parity is applied only to the 608 compatibility pairs — mixing the two rules is a classic reason a legacy decoder shows garbage while the 708 render is clean.

dtvcc_service_block encodes the service-number decision directly. Services 1–6 fit the standard header — three bits of service number shifted above five bits of block size — so (service_number << 5) | size is the whole header. Numbers 7–63 do not fit three bits, so the standard field is pinned to 7 (the “use extended header” escape) and a second byte carries the real number. The 5-bit block_size caps a single block at 31 bytes, which is why long captions are split across successive service blocks. Service #1 is the primary English service by convention, so it is the target that a 708 receiver renders by default and the one your compatibility 608 stream should mirror.

dtvcc_packet wraps one or more service blocks in the transport packet CTA-708 defines: a header byte holding a 2-bit sequence number and a 6-bit packet_size_code, where the code encodes twice its value in bytes and the packet is null-padded up to that size. build_cc_data then does the targeting work the page is about — it emits the 608 field-1 pairs first so a legacy line 21 decoder finds its data immediately, then the DTVCC start and data pairs for the 708 receiver, producing the single interleaved cc_data() that satisfies both decoders from one delivery.

Threshold reference table

Field / limit Value Source / clause
DTVCC service number (standard) 1–6 (3-bit field) CTA-708 §6.2.2
DTVCC service number (extended) 7–63 (extended header) CTA-708 §6.2.2
Max caption services 63 CTA-708
Service block size ≤ 31 bytes (5-bit field) CTA-708 §6.2.2
DTVCC packet size ≤ 128 bytes (packet_size_code×2) CTA-708 §5
cc_type codes 0/1 = 608 field 1/2, 2/3 = DTVCC ATSC A/53 Part 4
CEA-608 field rate ~59.94 B/s per field (1 pair/frame) CEA-608 / line 21
Line 21 parity odd, per 7-bit byte CEA-608
Primary service convention service #1 = English CTA-708
Delivery obligation 608 + 708 reachable FCC 47 CFR § 79.1
One cc_data stream, two decoder targets, routed by cc_type A single cc_data structure on the left holds interleaved pairs tagged by cc_type: cc_type 0 pairs carry CEA-608 line 21 field-1 bytes, cc_type 2 and 3 pairs carry the CTA-708 DTVCC packet. An arrow labelled cc_type equals 0 routes to a legacy line 21 decoder that renders caption service one; an arrow labelled cc_type equals 2 or 3 routes to a CTA-708 DTVCC decoder that reassembles service block one and renders the same caption. Both decoders read the one delivery, satisfying the FCC dual-carriage obligation. One cc_data() delivery targets both decoders — cc_type is the router cc_data() structure interleaved pairs, one delivery cc_type 0 · 608 field 1 cc_type 2 · DTVCC start cc_type 3 · DTVCC data cc_type = 0 Legacy line 21 decoder renders caption service #1 (608) cc_type = 2 / 3 CTA-708 DTVCC decoder reassembles service block #1 (708)

Edge cases & known gotchas

  • 608-only legacy path: a set-top box that downconverts DTV to analog composite reads only line 21 field data. If you author a 708-only DTVCC packet with no cc_type == 0 pairs, that path shows nothing — always carry the 608 compatibility bytes when any legacy delivery exists.
  • 708 windows vs 608 roll-up: 708 renders into positioned windows while 608 uses fixed roll-up/pop-on rows on a 32-column grid. The two caption models are not byte-for-byte translatable, so the 608 compatibility stream is a readable equivalent, not a mechanical transcode of the 708 windows.
  • Service #1 English convention: DTV receivers default to service #1, so English must live there. Putting the primary language on service #2 or higher leaves the default viewer with a blank caption track even though data is present.
  • Downconvert to 608: when a mux downconverts, the 608 pairs must already exist in the cc_data() — the converter copies field data, it does not synthesize 608 from 708. Verify both are present before delivery, not after.
  • Parity mismatch: applying odd parity to the DTVCC bytes (or omitting it on the 608 bytes) corrupts one decoder while the other looks fine, which is why the builder applies parity only to the field-1 pairs.

Integration hook

This builder produces the exact cc_data() payload that the muxing stage embeds into a transport stream — the byte-level structure here is what embedding CEA-608 & CEA-708 in transport streams packs into MPEG-2 user_data or an SEI NAL, and what the FCC Part 79 compliance checklist audits for dual-carriage. Decide the decoder target here, at authoring time, so the downstream mux carries provably reachable captions for both the DTV receiver and the legacy line 21 path.

Frequently asked questions

Do I need both CEA-608 and CEA-708 in a modern delivery? For terrestrial and cable DTV under FCC 47 CFR § 79.1 the safe answer is yes: carry 708 DTVCC service blocks for the DTV receiver and 608 compatibility bytes in the same cc_data() so any downconverted or legacy path still renders. Pure-OTT deliveries that never touch line 21 can drop 608, but broadcast masters should not.

Which caption service number should English use? Service #1. CTA-708 receivers default to service #1, so the primary language must live there; secondary languages take services #2 and up, and services 7–63 require the extended service-block header.

How do the 608 compatibility bytes reach a decoder inside a 708 stream? They ride as cc_type == 0 (and 1) pairs in the shared cc_data() structure alongside the cc_type == 2/3 DTVCC pairs. A 608 decoder reads only its own pairs and a 708 decoder reads only the DTVCC pairs, so one interleaved stream serves both.

Part of: Broadcast Media Closed Captioning & QC Automation — the architecture and compliance reference for broadcast caption pipelines.