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_type — 0 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 |
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 == 0pairs, 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.
Related
- How to audit caption pipelines for FCC compliance — verifying dual-carriage and control-code integrity at ingest.
- Embedding CEA-608 & CEA-708 in transport streams — where the cc_data() built here is muxed into the container.
- FCC Part 79 compliance checklist — the parent rule set this targeting decision serves.
Part of: Broadcast Media Closed Captioning & QC Automation — the architecture and compliance reference for broadcast caption pipelines.