Skip to main content

container/
ts.rs

1//! Minimal MPEG-2 Transport Stream demuxer.
2//!
3//! Scope: take a .ts / .m2ts byte stream, locate the PAT and a PMT,
4//! pick the first video elementary stream the PMT advertises, and
5//! return its PES payloads as one sample per access unit.
6//!
7//! PTS is carried through on the first PES packet that opens an AU;
8//! continuation packets accumulate bytes onto the current sample until
9//! the next `payload_unit_start_indicator=1` closes it.
10//!
11//! What's implemented:
12//! - PAT walk that surfaces every program in the file, with a default
13//!   "first program" pick (matches legacy behaviour) and a
14//!   `select_program(program_number)` API for callers that want one of
15//!   the others (Squad-37).
16//! - PMT walk: video stream_types 0x02 (MPEG-2), 0x1B (H.264),
17//!   0x24 (HEVC) plus audio stream_types 0x0F (AAC-ADTS, Squad-27),
18//!   0x81 (AC-3, ATSC A/53), 0x87 (E-AC-3, ATSC A/53), and 0x06 (PES
19//!   private) when the ES descriptor loop carries a registration_descriptor
20//!   tagged "AC-3" / "EAC3" (DVB / ETSI TS 101 154) — Squad-37.
21//! - Encrypted streams (`transport_scrambling_control != 0` on the active
22//!   video PID) trip a one-time typed warn and switch the demuxer into a
23//!   drop-everything mode (Squad-37); previously the bytes were silently
24//!   skipped on a per-packet basis which meant a partial-scramble error
25//!   condition could still leak garbled samples.
26//!
27//! What's not implemented:
28//! - Full CRC validation of PAT/PMT (we trust what the bitstream gives
29//!   us; a mis-CRCed file is already corrupt and will surface as wrong
30//!   stream_type or truncated samples further down).
31//! - Multiple video streams within one program (we take the first).
32//! - Adaptation-field-only packets with payload=0 are passed over
33//!   transparently.
34//! - BDAV 192-byte wrapper (the 4-byte timestamp prefix) — if present,
35//!   we detect and strip it.
36//! - Common-Access (CA) tables: encrypted streams are dropped, not
37//!   decrypted (we don't carry CA descriptors).
38
39use anyhow::{Context, Result, bail};
40use codec::frame::{ColorSpace, PixelFormat, StreamInfo};
41
42use crate::ac3_sync::{
43    self, Eac3SyncInfo, SyncInfo, ac3_bit_rate_kbps, channel_count, eac3_sample_rate_hz,
44    eac3_samples_per_frame,
45};
46use crate::demux::{AudioTrack, DemuxResult};
47use crate::mux::{dac3_body_from_sync, dec3_body_from_sync};
48use crate::streaming::{DemuxHeader, Sample, StreamingDemuxer};
49
50const TS_PACKET: usize = 188;
51const TS_SYNC: u8 = 0x47;
52
53const STREAM_TYPE_MPEG2_VIDEO: u8 = 0x02;
54const STREAM_TYPE_H264: u8 = 0x1B;
55const STREAM_TYPE_HEVC: u8 = 0x24;
56/// PES private stream_type. ETSI TS 101 154 (DVB) routes AC-3 / E-AC-3
57/// through this generic stream_type with a `registration_descriptor`
58/// (descriptor_tag = 0x05) tagged "AC-3" or "EAC3" carrying the actual
59/// codec identity. We only honour 0x06 entries that carry one of those
60/// two registrations — random PES-private streams (DVB subtitles, teletext)
61/// are dropped silently.
62const STREAM_TYPE_PES_PRIVATE: u8 = 0x06;
63/// PMT stream_type for AAC carried as ADTS frames in PES packets.
64/// Defined in ISO/IEC 13818-1:2019 Table 2-34 — `0x0F` is
65/// "ISO/IEC 13818-7 Audio with ADTS transport syntax", which is the
66/// MPEG-2/MPEG-4 AAC ADTS form that broadcast / streaming MPEG-TS uses.
67const STREAM_TYPE_AAC_ADTS: u8 = 0x0F;
68/// ATSC A/53 §3 / ATSC A/52 Annex A — AC-3 elementary streams in PES
69/// packets. Common in over-the-air ATSC broadcast captures (.ts / .trp).
70const STREAM_TYPE_AC3: u8 = 0x81;
71/// ATSC A/53 §3 / ATSC A/52 Annex E — E-AC-3 elementary streams.
72const STREAM_TYPE_EAC3: u8 = 0x87;
73
74/// PMT descriptor_tag for the registration_descriptor carrying a
75/// 4-character format identifier. ETSI TS 101 154 §F (DVB) registers
76/// `"AC-3"` (0x41432D33) and `"EAC3"` (0x45414333) for Dolby streams
77/// carried as PES-private (stream_type 0x06).
78const DESC_TAG_REGISTRATION: u8 = 0x05;
79const REG_AC3: u32 = 0x41432D33; // "AC-3"
80const REG_EAC3: u32 = 0x45414333; // "EAC3"
81
82pub(crate) fn demux_ts(data: &[u8]) -> Result<DemuxResult> {
83    // Detect BDAV wrapper: 192-byte packets carry a 4-byte TP_extra
84    // header in front of each 188-byte TS packet. Stripping the 4-byte
85    // prefix brings us back to the canonical 188-byte form.
86    let (packets, packet_stride, prefix_len) = detect_packet_layout(data)?;
87    if packets == 0 {
88        bail!("TS: file contains no TS packets");
89    }
90
91    // First pass: find PAT (PID=0), then PMT, collect video + audio PID +
92    // stream_type. The PMT walk surfaces (video_streams, audio_streams)
93    // and we take the first of each. Squad-37 expanded recognised audio
94    // codec families to AAC-ADTS (0x0F), AC-3 (0x81 / 0x06+reg), and
95    // E-AC-3 (0x87 / 0x06+reg) — every other audio stream_type is
96    // dropped silently (matches MP4/MKV's "non-supported audio → drop"
97    // behaviour at the demuxer layer; the pipeline already knows how to
98    // emit video-only).
99    let mut pmt_pid: Option<u16> = None;
100    let mut chosen_video: Option<VideoStreamInfo> = None;
101    let mut chosen_audio: Option<AudioStreamInfo> = None;
102    for i in 0..packets {
103        let start = i * packet_stride + prefix_len;
104        let pkt = &data[start..start + TS_PACKET];
105        if pkt[0] != TS_SYNC {
106            continue;
107        }
108        let pid = (((pkt[1] & 0x1F) as u16) << 8) | pkt[2] as u16;
109        // PAT
110        if pmt_pid.is_none() && pid == 0 {
111            if let Some(payload) = ts_psi_payload(pkt)
112                && let Some(p) = parse_pat_first_pmt_pid(payload)
113            {
114                pmt_pid = Some(p);
115            }
116            continue;
117        }
118        // PMT
119        if let (Some(pmt), None) = (pmt_pid, chosen_video)
120            && pid == pmt
121            && let Some(payload) = ts_psi_payload(pkt)
122            && let Some((video_streams, audio_streams)) = parse_pmt_streams(payload)
123        {
124            chosen_video = video_streams.into_iter().next();
125            chosen_audio = audio_streams.into_iter().next();
126            if chosen_video.is_some() {
127                break;
128            }
129        }
130    }
131
132    let video = chosen_video.context("TS: no video elementary stream found in PMT")?;
133    let video_pid = video.pid;
134    let codec = match video.stream_type {
135        STREAM_TYPE_MPEG2_VIDEO => "mpeg2",
136        STREAM_TYPE_H264 => "h264",
137        STREAM_TYPE_HEVC => "h265",
138        other => bail!("TS: unsupported stream_type 0x{:02X}", other),
139    }
140    .to_string();
141
142    // Second pass: reassemble PES payloads for the video PID, one
143    // sample per `payload_unit_start_indicator`.
144    let mut samples: Vec<Vec<u8>> = Vec::new();
145    let mut pending: Vec<u8> = Vec::new();
146    let mut have_first_start = false;
147    let mut first_pts: Option<u64> = None;
148    let mut last_pts: Option<u64> = None;
149    // Collect every PTS so we can share the streaming path's
150    // `estimate_frame_rate_from_ptses` (median-of-deltas) — more
151    // robust than `(samples - 1) / duration`, which was off-by-one
152    // on boundary edge cases that the streaming scan also hit.
153    let mut ptses: Vec<u64> = Vec::new();
154
155    let flush = |pending: &mut Vec<u8>, samples: &mut Vec<Vec<u8>>| {
156        if !pending.is_empty() {
157            samples.push(std::mem::take(pending));
158        }
159    };
160
161    for i in 0..packets {
162        let start = i * packet_stride + prefix_len;
163        let pkt = &data[start..start + TS_PACKET];
164        if pkt[0] != TS_SYNC {
165            continue;
166        }
167        let pid = (((pkt[1] & 0x1F) as u16) << 8) | pkt[2] as u16;
168        if pid != video_pid {
169            continue;
170        }
171        let pusi = pkt[1] & 0x40 != 0;
172        let scramble = (pkt[3] >> 6) & 0x03;
173        if scramble != 0 {
174            continue;
175        } // encrypted; no way to decode
176        let adaptation = (pkt[3] >> 4) & 0x03;
177        let has_payload = adaptation & 0x01 != 0;
178        let has_adaptation = adaptation & 0x02 != 0;
179        if !has_payload {
180            continue;
181        }
182
183        let mut offset = 4usize;
184        if has_adaptation {
185            if offset >= TS_PACKET {
186                continue;
187            }
188            let adap_len = pkt[offset] as usize;
189            offset += 1 + adap_len;
190            if offset > TS_PACKET {
191                continue;
192            }
193        }
194        if offset >= TS_PACKET {
195            continue;
196        }
197        let payload = &pkt[offset..];
198
199        if pusi {
200            // New PES packet begins here — flush whatever we were
201            // accumulating, then parse the PES header to find PTS and
202            // the elementary-stream payload start.
203            if have_first_start {
204                flush(&mut pending, &mut samples);
205            }
206            have_first_start = true;
207
208            let Some((es_start, pts)) = parse_pes_header(payload) else {
209                // Malformed PES; skip this packet, keep state.
210                have_first_start = false;
211                pending.clear();
212                continue;
213            };
214            if let Some(p) = pts {
215                if first_pts.is_none() {
216                    first_pts = Some(p);
217                }
218                last_pts = Some(p);
219                ptses.push(p);
220            }
221            if es_start < payload.len() {
222                pending.extend_from_slice(&payload[es_start..]);
223            }
224        } else if have_first_start {
225            pending.extend_from_slice(payload);
226        }
227    }
228    flush(&mut pending, &mut samples);
229
230    if samples.is_empty() {
231        bail!("TS: reassembled zero video samples from PID {}", video_pid);
232    }
233
234    // PTS is 90 kHz. Duration stays span-based (last - first is the
235    // right answer for "how long does this stream play"). Frame rate
236    // switches to the median-of-deltas path for consistency with the
237    // streaming demuxer's init; falls back to the span/count calc and
238    // then 30.0 if the PTS window isn't populated enough for a median.
239    let duration = match (first_pts, last_pts) {
240        (Some(a), Some(b)) if b >= a => (b - a) as f64 / 90_000.0,
241        _ => 0.0,
242    };
243    let frame_rate = estimate_frame_rate_from_ptses(&ptses)
244        .or_else(|| {
245            if duration > 0.0 && samples.len() > 1 {
246                Some((samples.len() - 1) as f64 / duration)
247            } else {
248                None
249            }
250        })
251        .unwrap_or(30.0);
252
253    // TS carries no container-level width/height; the sample-entry /
254    // track-header equivalents that MP4/MKV/AVI/MOV all have don't
255    // exist here. We recover dims by parsing the first sample's SPS
256    // (H.264 / HEVC) or sequence header (MPEG-2). `detect_dims`
257    // returns None if the parse fails — fall back to 0 so downstream
258    // reporting still shows "unknown" rather than a fabricated value.
259    let (width, height) = codec::pixel_format::detect_dims(&codec, &samples).unwrap_or((0, 0));
260    if width == 0 || height == 0 {
261        tracing::warn!(
262            codec = codec.as_str(),
263            "TS demux: could not recover width/height from first sample — \
264             downstream encoder may reject the 0×0 config"
265        );
266    }
267
268    let info = StreamInfo {
269        codec: codec.clone(),
270        width,
271        height,
272        frame_rate,
273        duration,
274        pixel_format: PixelFormat::Yuv420p,
275        color_space: ColorSpace::Bt709,
276        total_frames: samples.len() as u64,
277        bitrate: 0,
278        color_metadata: Default::default(),
279    };
280
281    let detected_pf = codec::pixel_format::detect(&codec, &samples);
282    let info = StreamInfo {
283        pixel_format: detected_pf,
284        ..info
285    };
286
287    // Audio extraction. Squad-37 expanded the routing: AAC-ADTS goes
288    // through Squad-27's path; AC-3 / E-AC-3 use the new pure-Rust
289    // extractors that derive `dac3` / `dec3` from the first frame's
290    // sync header (Squad-26 helpers).
291    let audio = chosen_audio.and_then(|info| {
292        match extract_ts_audio(data, packets, packet_stride, prefix_len, info) {
293            Ok(track) => track,
294            Err(e) => {
295                tracing::warn!(
296                    audio_pid = info.pid,
297                    audio_kind = ?info.kind,
298                    error = %e,
299                    "TS audio extraction failed; emitting video-only"
300                );
301                None
302            }
303        }
304    });
305
306    Ok(DemuxResult {
307        codec,
308        info,
309        samples,
310        audio,
311    })
312}
313
314/// Decide whether the file uses 188-byte (plain TS) or 192-byte (BDAV
315/// M2TS) packets. Returns (packet_count, stride, prefix_len).
316/// BDAV prepends a 4-byte TP_extra_header before each 188-byte TS
317/// packet, so stride=192 and prefix_len=4. For plain TS stride=188
318/// and prefix_len=0.
319fn detect_packet_layout(data: &[u8]) -> Result<(usize, usize, usize)> {
320    if data.len() < TS_PACKET {
321        bail!("TS: file too small");
322    }
323    // Plain 188-byte: sync at 0, 188, 376...
324    if data[0] == TS_SYNC && data.len() >= 2 * TS_PACKET && data[TS_PACKET] == TS_SYNC {
325        return Ok((data.len() / TS_PACKET, TS_PACKET, 0));
326    }
327    // M2TS 192-byte: 4-byte prefix, then sync at 4, 196, 388...
328    if data.len() >= 192 + 4 && data[4] == TS_SYNC && data[196] == TS_SYNC {
329        return Ok((data.len() / 192, 192, 4));
330    }
331    bail!("TS: could not locate 0x47 sync pattern at 188- or 192-byte intervals")
332}
333
334/// Extract the PSI (PAT/PMT) section payload from a TS packet whose PID
335/// we already know carries PSI. Returns the raw section bytes or None
336/// when the packet has no payload or has a continuation we can't
337/// reassemble in a single-packet model.
338fn ts_psi_payload(pkt: &[u8]) -> Option<&[u8]> {
339    let pusi = pkt[1] & 0x40 != 0;
340    let adaptation = (pkt[3] >> 4) & 0x03;
341    let has_payload = adaptation & 0x01 != 0;
342    let has_adaptation = adaptation & 0x02 != 0;
343    if !has_payload {
344        return None;
345    }
346    let mut offset = 4usize;
347    if has_adaptation {
348        if offset >= TS_PACKET {
349            return None;
350        }
351        let adap_len = pkt[offset] as usize;
352        offset += 1 + adap_len;
353        if offset > TS_PACKET {
354            return None;
355        }
356    }
357    // PSI packets with PUSI=1 carry a pointer_field byte telling us how
358    // many bytes to skip before the section starts. We take that first
359    // section only — subsequent sections in the same packet would need
360    // separate handling we don't need for PAT/PMT (usually one each).
361    if pusi {
362        if offset >= TS_PACKET {
363            return None;
364        }
365        let pointer = pkt[offset] as usize;
366        offset += 1 + pointer;
367        if offset >= TS_PACKET {
368            return None;
369        }
370    }
371    Some(&pkt[offset..])
372}
373
374/// One PAT entry — `(program_number, pmt_pid)`. Entry with program=0 is
375/// the network_PID and is skipped by callers (it is not a real program).
376#[derive(Debug, Clone, Copy, PartialEq, Eq)]
377pub(crate) struct PatProgram {
378    pub program_number: u16,
379    pub pmt_pid: u16,
380}
381
382/// Audio codec discriminator surfaced from the PMT walk. The PMT only
383/// tells us the codec family; the actual codec_private bytes (`asc` for
384/// AAC, `dac3` / `dec3` for AC-3 / E-AC-3) are derived in `extract_*` by
385/// reading the first frame of the elementary stream.
386#[derive(Debug, Clone, Copy, PartialEq, Eq)]
387pub enum AudioCodecKind {
388    /// ISO/IEC 13818-7 AAC carried as ADTS frames (stream_type 0x0F).
389    AacAdts,
390    /// ETSI TS 102 366 AC-3 (stream_type 0x81 OR 0x06 + registration "AC-3").
391    Ac3,
392    /// ETSI TS 102 366 E-AC-3 (stream_type 0x87 OR 0x06 + registration "EAC3").
393    Eac3,
394}
395
396/// Per-stream info gathered from one PMT entry.
397#[derive(Debug, Clone, Copy, PartialEq, Eq)]
398pub struct VideoStreamInfo {
399    pub pid: u16,
400    pub stream_type: u8,
401}
402
403/// Per-audio-stream info gathered from one PMT entry. `kind` is the
404/// codec family — extraction reads the first frame to derive
405/// `codec_private` / `sample_rate` / `channels`.
406#[derive(Debug, Clone, Copy, PartialEq, Eq)]
407pub struct AudioStreamInfo {
408    pub pid: u16,
409    pub stream_type: u8,
410    pub kind: AudioCodecKind,
411}
412
413/// One MPEG-TS program found in the PAT, after the corresponding PMT has
414/// been walked. `pmt_pid` is the bitstream-side PID where the PMT section
415/// lives; `video_streams` / `audio_streams` are the elementary streams
416/// the PMT advertises (video filtered to MPEG-2 / H.264 / HEVC; audio
417/// filtered to AAC-ADTS / AC-3 / E-AC-3 — exactly the codec families we
418/// can passthrough). A program with neither a recognised video nor a
419/// recognised audio stream is still surfaced so callers can see "this
420/// program exists, just contains things we can't carry".
421#[derive(Debug, Clone, PartialEq, Eq)]
422pub struct ProgramInfo {
423    pub program_number: u16,
424    pub pmt_pid: u16,
425    pub video_streams: Vec<VideoStreamInfo>,
426    pub audio_streams: Vec<AudioStreamInfo>,
427}
428
429/// Walk the PAT section and return every `(program_number, pmt_pid)`
430/// pair (skipping `program_number == 0`, which carries the network_PID
431/// per ISO/IEC 13818-1 §2.4.4.3 — not a real program).
432fn parse_pat_all_programs(section: &[u8]) -> Vec<PatProgram> {
433    // PAT section:
434    //   table_id(8)=0, section_syntax_indicator(1), '0'(1), reserved(2),
435    //   section_length(12), transport_stream_id(16), reserved(2),
436    //   version(5), current_next(1), section_number(8),
437    //   last_section_number(8), then N × (program_number u16, reserved(3),
438    //   PID(13)), followed by CRC_32.
439    let mut out = Vec::new();
440    if section.len() < 12 {
441        return out;
442    }
443    if section[0] != 0x00 {
444        return out;
445    }
446    let section_length = (((section[1] & 0x0F) as usize) << 8) | section[2] as usize;
447    let total = 3 + section_length;
448    if total > section.len() {
449        return out;
450    }
451    let loop_start = 8;
452    let loop_end = total - 4;
453    let mut i = loop_start;
454    while i + 4 <= loop_end {
455        let program = u16::from_be_bytes([section[i], section[i + 1]]);
456        let pid = (((section[i + 2] & 0x1F) as u16) << 8) | section[i + 3] as u16;
457        if program != 0 {
458            out.push(PatProgram {
459                program_number: program,
460                pmt_pid: pid,
461            });
462        }
463        i += 4;
464    }
465    out
466}
467
468/// Back-compat shim — returns the first program's PMT PID. Kept so the
469/// streaming-init path stays a one-line change; the multi-program walk
470/// uses `parse_pat_all_programs` directly.
471fn parse_pat_first_pmt_pid(section: &[u8]) -> Option<u16> {
472    parse_pat_all_programs(section).first().map(|p| p.pmt_pid)
473}
474
475/// Walk the PMT section once and return every recognised video stream
476/// (PID + stream_type) plus every recognised audio stream (PID +
477/// stream_type + codec kind). Audio is optional — TS files without an
478/// audio track demux video-only, matching MP4/MKV behaviour at the
479/// demuxer layer.
480///
481/// AC-3 / E-AC-3 detection (Squad-37): we honour the ATSC A/53
482/// stream_types (0x81 / 0x87) directly AND the DVB form where the
483/// stream_type is 0x06 (PES private) and the ES descriptor loop carries
484/// a `registration_descriptor` (tag 0x05) with the 4-char identifier
485/// "AC-3" or "EAC3".
486fn parse_pmt_streams(section: &[u8]) -> Option<(Vec<VideoStreamInfo>, Vec<AudioStreamInfo>)> {
487    // PMT section:
488    //   table_id(8)=0x02, section_syntax_indicator(1), '0'(1), reserved(2),
489    //   section_length(12), program_number(16), reserved(2), version(5),
490    //   current_next(1), section_number(8), last_section_number(8),
491    //   reserved(3), PCR_PID(13), reserved(4), program_info_length(12),
492    //   program_info_descriptors...,
493    //   then N × (stream_type(8), reserved(3), elementary_PID(13),
494    //     reserved(4), ES_info_length(12), ES_info_descriptors...),
495    //   CRC_32.
496    if section.len() < 12 {
497        return None;
498    }
499    if section[0] != 0x02 {
500        return None;
501    }
502    let section_length = (((section[1] & 0x0F) as usize) << 8) | section[2] as usize;
503    let total = 3 + section_length;
504    if total > section.len() {
505        return None;
506    }
507    if section.len() < 12 {
508        return None;
509    }
510    let pil = (((section[10] & 0x0F) as usize) << 8) | section[11] as usize;
511    let mut i = 12 + pil;
512    let loop_end = total - 4; // strip CRC
513    let mut video: Vec<VideoStreamInfo> = Vec::new();
514    let mut audio: Vec<AudioStreamInfo> = Vec::new();
515    while i + 5 <= loop_end {
516        let stype = section[i];
517        let pid = (((section[i + 1] & 0x1F) as u16) << 8) | section[i + 2] as u16;
518        let esi_len = (((section[i + 3] & 0x0F) as usize) << 8) | section[i + 4] as usize;
519        let desc_start = i + 5;
520        let desc_end = (desc_start + esi_len).min(loop_end);
521        let descriptors = if desc_start <= desc_end {
522            &section[desc_start..desc_end]
523        } else {
524            &[][..]
525        };
526
527        match stype {
528            STREAM_TYPE_MPEG2_VIDEO | STREAM_TYPE_H264 | STREAM_TYPE_HEVC => {
529                video.push(VideoStreamInfo {
530                    pid,
531                    stream_type: stype,
532                });
533            }
534            STREAM_TYPE_AAC_ADTS => {
535                audio.push(AudioStreamInfo {
536                    pid,
537                    stream_type: stype,
538                    kind: AudioCodecKind::AacAdts,
539                });
540            }
541            STREAM_TYPE_AC3 => {
542                audio.push(AudioStreamInfo {
543                    pid,
544                    stream_type: stype,
545                    kind: AudioCodecKind::Ac3,
546                });
547            }
548            STREAM_TYPE_EAC3 => {
549                audio.push(AudioStreamInfo {
550                    pid,
551                    stream_type: stype,
552                    kind: AudioCodecKind::Eac3,
553                });
554            }
555            STREAM_TYPE_PES_PRIVATE => {
556                // DVB carries AC-3 / E-AC-3 here. Walk the ES descriptor
557                // loop and look for a registration_descriptor whose
558                // 4-char tag is "AC-3" or "EAC3".
559                if let Some(reg) = find_registration(descriptors) {
560                    match reg {
561                        REG_AC3 => audio.push(AudioStreamInfo {
562                            pid,
563                            stream_type: stype,
564                            kind: AudioCodecKind::Ac3,
565                        }),
566                        REG_EAC3 => audio.push(AudioStreamInfo {
567                            pid,
568                            stream_type: stype,
569                            kind: AudioCodecKind::Eac3,
570                        }),
571                        _ => {}
572                    }
573                }
574            }
575            _ => {}
576        }
577        i += 5 + esi_len;
578    }
579    Some((video, audio))
580}
581
582/// Walk the ES descriptor loop and look for a registration_descriptor
583/// (tag 0x05) carrying a 4-byte format_identifier; return the BE u32 of
584/// that identifier or None.
585fn find_registration(descriptors: &[u8]) -> Option<u32> {
586    let mut i = 0usize;
587    while i + 2 <= descriptors.len() {
588        let tag = descriptors[i];
589        let len = descriptors[i + 1] as usize;
590        let body_start = i + 2;
591        let body_end = body_start + len;
592        if body_end > descriptors.len() {
593            break;
594        }
595        if tag == DESC_TAG_REGISTRATION && len >= 4 {
596            let id = u32::from_be_bytes([
597                descriptors[body_start],
598                descriptors[body_start + 1],
599                descriptors[body_start + 2],
600                descriptors[body_start + 3],
601            ]);
602            return Some(id);
603        }
604        i = body_end;
605    }
606    None
607}
608
609// (Squad-13's `parse_pmt_video_and_audio` shim was retired in Squad-37
610// — both the legacy `demux_ts` path and the streaming demuxer now use
611// `parse_pmt_streams` directly so the AC-3 / E-AC-3 / multi-program
612// surface stays a single-walker invariant.)
613
614/// Parse a PES header at the start of `payload`. Returns the byte
615/// offset of the elementary-stream payload within `payload`, plus any
616/// PTS we extracted. PES layout (ISO/IEC 13818-1 §2.4.3.6):
617///   start_code(0x000001) + stream_id(8) + PES_packet_length(16)
618///   flags(16) + PES_header_data_length(8) + header_extension(...) + ES data
619fn parse_pes_header(payload: &[u8]) -> Option<(usize, Option<u64>)> {
620    if payload.len() < 9 {
621        return None;
622    }
623    if payload[0] != 0 || payload[1] != 0 || payload[2] != 1 {
624        return None;
625    }
626    let stream_id = payload[3];
627    // Video streams are 0xE0..=0xEF. Other stream_ids (audio, padding,
628    // program streams) aren't what we want; bail so the caller can drop
629    // the sample.
630    if !(0xE0..=0xEF).contains(&stream_id) {
631        return None;
632    }
633    // The two PES flag bytes live at offsets 6-7.
634    let flags = payload[7];
635    let pts_dts_flags = (flags >> 6) & 0x03;
636    let header_data_len = payload[8] as usize;
637    let es_start = 9 + header_data_len;
638    if es_start > payload.len() {
639        return None;
640    }
641    let pts = if pts_dts_flags == 0b10 || pts_dts_flags == 0b11 {
642        // PTS occupies bytes 9..14. Layout: 4 marker bits + PTS[32..30]
643        //   + 1 marker, 15 bits PTS[29..15] + 1 marker, 15 bits PTS[14..0] + 1 marker.
644        if payload.len() < 14 {
645            return None;
646        }
647        let p0 = ((payload[9] >> 1) & 0x07) as u64;
648        let p1 = (((payload[10] as u64) << 7) | ((payload[11] as u64) >> 1)) & 0x7FFF;
649        let p2 = (((payload[12] as u64) << 7) | ((payload[13] as u64) >> 1)) & 0x7FFF;
650        Some((p0 << 30) | (p1 << 15) | p2)
651    } else {
652        None
653    };
654    Some((es_start, pts))
655}
656
657/// Result of a single-pass scan over the active video PID: the first
658/// access unit's bytes (for SPS / seq-header dim extraction) plus a
659/// window of PTSes (for frame-rate estimation).
660pub(crate) struct VideoStreamScan {
661    pub first_au: Option<Vec<u8>>,
662    pub ptses: Vec<u64>,
663}
664
665/// Walk TS packets on the active video PID and reassemble the first
666/// complete access unit into a single contiguous byte buffer. "Complete"
667/// = from the first PUSI on the target PID up to (but not including)
668/// the second PUSI; if there's no second PUSI before EOF we return
669/// whatever we've accumulated so far. Also collects up to
670/// `max_pts_samples` successive PTSes off the video PID so the caller
671/// can derive a frame rate from their inter-arrival span.
672///
673/// Used by the streaming demuxer's init path to populate
674/// `StreamInfo.width` / `.height` from the codec's SPS (H.264 / HEVC)
675/// or sequence header (MPEG-2) — AND a correct `frame_rate` from the
676/// PTS window — before any downstream consumer reads `header()`.
677/// Walks the same packets `next_video_sample` would walk later; state
678/// is local to this fn so the main walk state isn't disturbed.
679fn scan_first_video_au(
680    data: &[u8],
681    packets: usize,
682    packet_stride: usize,
683    prefix_len: usize,
684    video_pid: u16,
685    max_pts_samples: usize,
686) -> VideoStreamScan {
687    let mut accumulator: Vec<u8> = Vec::new();
688    let mut first_au: Option<Vec<u8>> = None;
689    let mut ptses: Vec<u64> = Vec::new();
690    let mut au_started = false;
691    let mut au_done = false;
692    for i in 0..packets {
693        let start = i * packet_stride + prefix_len;
694        let pkt = &data[start..start + TS_PACKET];
695        if pkt[0] != TS_SYNC {
696            continue;
697        }
698        let pid = (((pkt[1] & 0x1F) as u16) << 8) | pkt[2] as u16;
699        if pid != video_pid {
700            continue;
701        }
702        let pusi = pkt[1] & 0x40 != 0;
703        let scramble = (pkt[3] >> 6) & 0x03;
704        if scramble != 0 {
705            continue;
706        } // encrypted; skip probe
707        let adaptation = (pkt[3] >> 4) & 0x03;
708        let has_payload = adaptation & 0x01 != 0;
709        let has_adaptation = adaptation & 0x02 != 0;
710        if !has_payload {
711            continue;
712        }
713        let mut offset = 4usize;
714        if has_adaptation {
715            if offset >= TS_PACKET {
716                continue;
717            }
718            let adap_len = pkt[offset] as usize;
719            offset += 1 + adap_len;
720            if offset > TS_PACKET {
721                continue;
722            }
723        }
724        if offset >= TS_PACKET {
725            continue;
726        }
727        let payload = &pkt[offset..];
728
729        if pusi {
730            // Close out the first AU on the second PUSI we see.
731            if au_started && !au_done {
732                first_au = Some(std::mem::take(&mut accumulator));
733                au_done = true;
734            }
735            if let Some((es_start, pts)) = parse_pes_header(payload) {
736                if let Some(p) = pts
737                    && ptses.len() < max_pts_samples
738                {
739                    ptses.push(p);
740                }
741                if !au_done {
742                    if es_start < payload.len() {
743                        accumulator.extend_from_slice(&payload[es_start..]);
744                    }
745                    au_started = true;
746                }
747            }
748        } else if au_started && !au_done {
749            accumulator.extend_from_slice(payload);
750        }
751
752        // Early exit once both targets are hit.
753        if au_done && ptses.len() >= max_pts_samples {
754            break;
755        }
756    }
757    // EOF before we saw a second PUSI — emit whatever's accumulated.
758    if first_au.is_none() && au_started && !accumulator.is_empty() {
759        first_au = Some(accumulator);
760    }
761    VideoStreamScan { first_au, ptses }
762}
763
764/// Estimate source frame rate from a window of video-PID PTSes.
765///
766/// Uses the **median** of sorted inter-PTS deltas rather than span /
767/// count: the span method is off-by-one-period sensitive to the
768/// boundary conditions of the scan (an extra stray PTS on the video
769/// PID, a stuffing PES, a mid-stream split) and consistently produced
770/// 23.625 instead of 24.000 on the BBB test sample. Median handles
771/// outliers uniformly — one spurious 2× delta leaves a run of
772/// correct-period deltas around it, and sorting picks the correct
773/// one as the middle.
774///
775/// PTS is 90 kHz; median_delta = ticks-per-frame; fps = 90000 /
776/// median_delta. Zero deltas (duplicate PTSes, e.g. if a frame's
777/// AU is split across multiple PES packets on the same PID) drop
778/// out — they would otherwise force fps → ∞.
779///
780/// Returns `None` when fewer than two PTSes are present, all deltas
781/// are zero, or the estimate lands outside `[1.0, 240.0]` (protects
782/// against 33-bit wraparound or a fixed-value PTS injection).
783fn estimate_frame_rate_from_ptses(ptses: &[u64]) -> Option<f64> {
784    if ptses.len() < 2 {
785        return None;
786    }
787    let mut sorted: Vec<u64> = ptses.to_vec();
788    sorted.sort_unstable();
789    let mut deltas: Vec<u64> = sorted.windows(2).map(|w| w[1] - w[0]).collect();
790    deltas.retain(|&d| d > 0);
791    if deltas.is_empty() {
792        return None;
793    }
794    deltas.sort_unstable();
795    let median = deltas[deltas.len() / 2];
796    if median == 0 {
797        return None;
798    }
799    let fps = 90000.0 / median as f64;
800    if !fps.is_finite() || !(1.0..=240.0).contains(&fps) {
801        return None;
802    }
803    Some(fps)
804}
805
806// ---------------------------------------------------------------------------
807// AAC-ADTS audio extraction (Squad-27)
808// ---------------------------------------------------------------------------
809//
810// The MPEG-TS audio path stores AAC as a stream of ADTS frames inside PES
811// packets — same PES framing as the video path, but the elementary stream
812// payload is ADTS, not Annex-B. The downstream mux (Squad-18) wants raw
813// AAC access units (no ADTS header) plus a synthesized AudioSpecificConfig
814// (ASC) — both come from the first ADTS header.
815//
816// References:
817// - ADTS frame layout: ISO/IEC 13818-7 §6.2 (the "_adts_frame()" syntax
818//   table — 7-byte fixed header without CRC, 9-byte with CRC).
819// - ASC layout: ISO/IEC 14496-3 §1.6.2 (`AudioSpecificConfig` →
820//   `GetAudioObjectType` + `samplingFrequencyIndex` + `channelConfiguration`
821//   + `GASpecificConfig` for AOT 1..7).
822//
823// Sampling frequency table (ISO/IEC 14496-3 §1.6.3.4 Table 1.16):
824const AAC_SAMPLE_RATES: [u32; 13] = [
825    96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350,
826];
827
828/// Parsed view of a single ADTS frame header (ISO/IEC 13818-7 §6.2).
829/// Only the fields we need for ASC synthesis + frame slicing — buffer
830/// fullness / number_of_raw_data_blocks are not exposed.
831#[derive(Debug, Clone, Copy, PartialEq, Eq)]
832struct AdtsHeader {
833    /// ADTS profile (2 bits): AAC ObjectType - 1.
834    /// `0`=Main, `1`=LC, `2`=SSR, `3`=LTP. Maps to ASC AOT via `+1`.
835    profile: u8,
836    /// Sampling frequency index (4 bits, 0..=12 valid; 15 = explicit).
837    /// `decode_sample_rate_index` resolves to Hz.
838    sampling_frequency_index: u8,
839    /// `channel_configuration` (3 bits). 1 = mono, 2 = stereo, etc.
840    /// 0 = "channel config defined in PCE" — uncommon; we accept 1/2
841    /// at the audio-track surface, downstream mux rejects the rest.
842    channel_configuration: u8,
843    /// Whole frame length in bytes including header + (optional CRC) +
844    /// AAC payload.
845    frame_length: usize,
846    /// Length of the ADTS header itself: 7 bytes if `protection_absent`
847    /// (no CRC), 9 bytes otherwise.
848    header_len: usize,
849}
850
851/// Parse an ADTS frame header at `buf[0..]`. Returns the parsed header on
852/// success. Does NOT validate the CRC even when present — the demux path
853/// trusts the upstream PMT routing to point us at AAC bytes; a corrupt
854/// stream surfaces as a sync-loss frame downstream.
855fn parse_adts_header(buf: &[u8]) -> Option<AdtsHeader> {
856    if buf.len() < 7 {
857        return None;
858    }
859    // Sync word: 12 bits = 0xFFF. Bytes 0..1 = `1111_1111  1111_xxxx`.
860    if buf[0] != 0xFF || (buf[1] & 0xF0) != 0xF0 {
861        return None;
862    }
863    let protection_absent = (buf[1] & 0x01) != 0;
864    let header_len = if protection_absent { 7 } else { 9 };
865    if buf.len() < header_len {
866        return None;
867    }
868    let profile = (buf[2] >> 6) & 0x03;
869    let sampling_frequency_index = (buf[2] >> 2) & 0x0F;
870    // channel_configuration straddles bytes 2..3:
871    //   bit 0 of byte 2 (low bit after profile/sr_idx/private) = ch_cfg high bit
872    //   bits 7..6 of byte 3 (top two bits)                     = ch_cfg low 2 bits
873    let channel_configuration = ((buf[2] & 0x01) << 2) | ((buf[3] >> 6) & 0x03);
874    // frame_length is 13 bits across bytes 3..4..5:
875    //   bits 1..0 of byte 3 = frame_length[12..11]
876    //   bits 7..0 of byte 4 = frame_length[10..3]
877    //   bits 7..5 of byte 5 = frame_length[2..0]
878    let frame_length =
879        (((buf[3] & 0x03) as usize) << 11) | ((buf[4] as usize) << 3) | ((buf[5] >> 5) as usize);
880    if frame_length < header_len {
881        return None;
882    }
883    Some(AdtsHeader {
884        profile,
885        sampling_frequency_index,
886        channel_configuration,
887        frame_length,
888        header_len,
889    })
890}
891
892/// Resolve an ADTS sampling_frequency_index to Hz. Only indices 0..=12 are
893/// recognised; 13/14 are reserved and 15 ("escape") would carry an
894/// explicit 24-bit rate after the header, which we don't accept (no
895/// real-world AAC ADTS file uses index 15 — the escape form is for
896/// AAC-in-LATM, not ADTS).
897fn decode_sample_rate_index(idx: u8) -> Option<u32> {
898    AAC_SAMPLE_RATES.get(idx as usize).copied()
899}
900
901/// Synthesize a 2-byte AudioSpecificConfig from an ADTS header per
902/// ISO/IEC 14496-3 §1.6.2:
903/// - 5 bits: audioObjectType = ADTS profile + 1
904///   (so ADTS profile=1 LC → ASC AOT=2 LC; ADTS profile=4 HE-AAC parent
905///    AOT=5 SBR → also AOT=5 here, though real HE-AAC ASC also signals
906///    SBR explicitly via extension AOT — we don't try to do that, the
907///    mux validation rejects HE-AAC anyway).
908/// - 4 bits: samplingFrequencyIndex (copy from ADTS verbatim)
909/// - 4 bits: channelConfiguration (copy from ADTS verbatim)
910/// - 3 bits: GASpecificConfig padding (frameLengthFlag=0,
911///   dependsOnCoreCoder=0, extensionFlag=0)
912///
913/// Total: 16 bits = 2 bytes.
914///
915/// Example: ADTS profile=1 (LC), sr_idx=3 (48k), ch_cfg=2 (stereo) →
916/// ASC bytes `0x11 0x90`.
917fn synthesize_asc(adts: &AdtsHeader) -> [u8; 2] {
918    let aot = adts.profile + 1; // ADTS profile (AOT-1) → ASC AOT
919    let sr_idx = adts.sampling_frequency_index;
920    let ch_cfg = adts.channel_configuration;
921    // Bit layout (MSB first, 16 bits):
922    //   AOT(5) | sr_idx(4) | ch_cfg(4) | GA padding(3)
923    // Pack into a u16 then split to BE bytes.
924    let mut bits: u16 = 0;
925    bits |= ((aot as u16) & 0x1F) << 11;
926    bits |= ((sr_idx as u16) & 0x0F) << 7;
927    bits |= ((ch_cfg as u16) & 0x0F) << 3;
928    // GA padding bits already 0.
929    bits.to_be_bytes()
930}
931
932/// Reassemble all PES packets on `audio_pid` and split the resulting
933/// elementary stream into ADTS frames. Returns one `Vec<u8>` per frame
934/// (raw access unit — ADTS header stripped) and a parallel duration list
935/// in `sample_rate` ticks (always 1024 per AAC-LC frame).
936///
937/// The first valid ADTS header drives ASC synthesis; subsequent frames
938/// must carry the same sampling_frequency_index and channel_configuration
939/// — a switch mid-stream would invalidate the ASC and the mux can't
940/// tolerate that. We currently bail out of audio extraction if the
941/// stream switches; downstream falls back to video-only.
942fn extract_ts_aac_audio(
943    data: &[u8],
944    packets: usize,
945    packet_stride: usize,
946    prefix_len: usize,
947    audio_pid: u16,
948) -> Result<Option<AudioTrack>> {
949    // Reassemble all PES packets on `audio_pid` into one elementary
950    // stream — shared with the AC-3 / E-AC-3 paths (Squad-37). ADTS
951    // sync words let us split into frames after the fact.
952    let es = reassemble_audio_pes(data, packets, packet_stride, prefix_len, audio_pid);
953
954    if es.is_empty() {
955        return Ok(None);
956    }
957
958    // Step 2: scan for the first valid ADTS sync, derive ASC.
959    let mut cursor = match find_adts_sync(&es, 0) {
960        Some(idx) => idx,
961        None => return Ok(None),
962    };
963    let first = parse_adts_header(&es[cursor..]).context("TS: first ADTS frame failed to parse")?;
964    let sample_rate = decode_sample_rate_index(first.sampling_frequency_index)
965        .context("TS: AAC sampling_frequency_index out of range")?;
966    let channels = first.channel_configuration as u16;
967    if channels == 0 {
968        bail!("TS: AAC channel_configuration=0 (PCE-defined); not supported");
969    }
970    let asc = synthesize_asc(&first).to_vec();
971
972    // Step 3: walk frames, strip headers, accumulate samples + durations.
973    // Each AAC-LC frame is exactly 1024 samples per channel — that's the
974    // duration in `sample_rate` ticks (timescale = sample_rate).
975    let mut samples: Vec<Vec<u8>> = Vec::new();
976    let mut durations: Vec<u32> = Vec::new();
977    while cursor < es.len() {
978        // Resync if we've drifted off a frame boundary (rare in practice
979        // but possible on packet loss or if a PES header extension we
980        // don't recognise pushed garbage into the ES).
981        let Some(found) = find_adts_sync(&es, cursor) else {
982            break;
983        };
984        cursor = found;
985        let Some(hdr) = parse_adts_header(&es[cursor..]) else {
986            break;
987        };
988        if hdr.sampling_frequency_index != first.sampling_frequency_index
989            || hdr.channel_configuration != first.channel_configuration
990        {
991            tracing::warn!(
992                "TS: AAC ADTS stream switched sr_idx/ch_cfg mid-stream; truncating audio at frame {}",
993                samples.len()
994            );
995            break;
996        }
997        let end = cursor + hdr.frame_length;
998        if end > es.len() {
999            break;
1000        }
1001        let payload_start = cursor + hdr.header_len;
1002        if payload_start > end {
1003            break;
1004        }
1005        samples.push(es[payload_start..end].to_vec());
1006        durations.push(1024);
1007        cursor = end;
1008    }
1009
1010    if samples.is_empty() {
1011        return Ok(None);
1012    }
1013
1014    Ok(Some(AudioTrack {
1015        codec: "aac".into(),
1016        samples,
1017        sample_rate,
1018        channels,
1019        asc,
1020        codec_private: Vec::new(),
1021        timescale: sample_rate,
1022        durations,
1023    }))
1024}
1025
1026// ---------------------------------------------------------------------------
1027// AC-3 / E-AC-3 in MPEG-TS audio extraction (Squad-37)
1028// ---------------------------------------------------------------------------
1029//
1030// PES payload for an AC-3 / E-AC-3 audio PID is a stream of raw
1031// syncframes — 0x0B77 sync word at the start of each frame, followed by
1032// the BSI fields whose layout `crate::ac3_sync` already parses for
1033// MP4 / MKV passthrough. Squad-26 settled the codec_private wire
1034// format: a 3-byte `dac3` body for AC-3, a 5-byte `dec3` body for
1035// vanilla single-substream E-AC-3.
1036//
1037// The MP4 mux contract (Squad-26) is: pass the raw AC-3 / E-AC-3
1038// frames through verbatim as samples; populate `codec_private` with the
1039// dac3/dec3 body derived from the first frame; `asc` stays empty for
1040// these codecs. We do NOT re-frame, decode, or strip anything — the
1041// frames are length-self-describing via the syncframe info, and the
1042// muxer / downstream demuxer round-trip in Squad-26 already handles
1043// that on the MP4 side.
1044
1045/// Find the next 0x0B77 AC-3 / E-AC-3 sync word at or after `from`.
1046fn find_ac3_sync(es: &[u8], from: usize) -> Option<usize> {
1047    let mut i = from;
1048    while i + 1 < es.len() {
1049        if es[i] == 0x0B && es[i + 1] == 0x77 {
1050            return Some(i);
1051        }
1052        i += 1;
1053    }
1054    None
1055}
1056
1057/// Compute the byte length of one AC-3 syncframe given its bit-rate
1058/// code and fscod. ETSI TS 102 366 §F Table F.7 gives the wire-byte
1059/// count per (bit_rate_code, fscod) pair; the closed form is:
1060///   for fscod=0 (48k):  frame_size_bytes = 2 * frame_size_words[brc]
1061///                       where frame_size_words = bit_rate_kbps * 32 / 48 / 2
1062///                       reduces to: bytes = bit_rate_kbps * 4 / 3
1063/// For 44.1k and 32k there's a per-(brc,fscod) padding offset table; we
1064/// derive it from the algebraic identity bytes = bit_rate_kbps * 1000 /
1065/// (sample_rate / samples_per_frame * 8). AC-3 has a fixed 1536 samples
1066/// per frame, so:
1067///   bytes = bit_rate_kbps * 1000 * 1536 / sample_rate / 8
1068///         = bit_rate_kbps * 192000 / sample_rate
1069/// 44.1k frames are not byte-exact this way (frame size oscillates
1070/// between two adjacent values to track the average rate); the bsi
1071/// `frmsizecod` low bit indicates which of the two values applies, so
1072/// we honour it and add 2 bytes when set. For 48k and 32k the low bit
1073/// is irrelevant (rates divide evenly).
1074fn ac3_frame_size(brc: u8, fscod: u8, frmsizecod_low_bit: u8) -> Option<usize> {
1075    let kbps = ac3_bit_rate_kbps(brc) as usize;
1076    if kbps == 0 {
1077        return None;
1078    }
1079    let sr = ac3_sync::ac3_sample_rate_hz(fscod) as usize;
1080    if sr == 0 {
1081        return None;
1082    }
1083    let base = (kbps * 1000 * 1536) / (sr * 8);
1084    // 44.1k oscillation: one of two frame sizes per syncframe (the low
1085    // bit of frmsizecod selects). At 48k / 32k both sides match the
1086    // algebraic value, so the bit is harmless.
1087    let extra = if fscod == 1 && frmsizecod_low_bit != 0 {
1088        2
1089    } else {
1090        0
1091    };
1092    Some(base + extra)
1093}
1094
1095/// Compute the byte length of one E-AC-3 syncframe — the BSI directly
1096/// carries `frmsiz` (frame_size_words - 1), so frame_size_bytes is
1097/// (frmsiz + 1) * 2.
1098fn eac3_frame_size(frmsiz: u16) -> usize {
1099    ((frmsiz as usize) + 1) * 2
1100}
1101
1102/// Extract AC-3 frames from PES packets on `audio_pid`. Returns an
1103/// `AudioTrack` with `codec = "ac3"`, `codec_private = dac3 body`, and
1104/// one sample per AC-3 syncframe (raw frame bytes verbatim).
1105///
1106/// The first valid syncframe drives `dac3` / sample_rate / channel
1107/// derivation; subsequent frames are emitted as samples without
1108/// re-validating their BSI (a corrupt mid-stream sync would surface as
1109/// a downstream decoder error, the same way our AAC path handles it).
1110fn extract_ts_ac3_audio(
1111    data: &[u8],
1112    packets: usize,
1113    packet_stride: usize,
1114    prefix_len: usize,
1115    audio_pid: u16,
1116) -> Result<Option<AudioTrack>> {
1117    let es = reassemble_audio_pes(data, packets, packet_stride, prefix_len, audio_pid);
1118    if es.is_empty() {
1119        return Ok(None);
1120    }
1121    let mut cursor = match find_ac3_sync(&es, 0) {
1122        Some(idx) => idx,
1123        None => return Ok(None),
1124    };
1125    // Parse the first frame's BSI to derive dac3 + sample_rate + channels.
1126    let first = match ac3_sync::parse_sync_info(&es[cursor..])
1127        .context("TS: first AC-3 frame failed to parse sync header")?
1128    {
1129        SyncInfo::Ac3(s) => s,
1130        SyncInfo::Eac3(_) => bail!("TS: AC-3 PMT entry but bitstream is E-AC-3 (bsid=16)"),
1131    };
1132    let sample_rate = ac3_sync::ac3_sample_rate_hz(first.fscod);
1133    if sample_rate == 0 {
1134        bail!("TS: AC-3 fscod={} reserved", first.fscod);
1135    }
1136    let channels = channel_count(first.acmod, first.lfeon);
1137    let dac3 = dac3_body_from_sync(&first).to_vec();
1138
1139    // Walk frames: re-sync on 0x0B77, slice by computed frame size, push
1140    // the slice as a sample. AC-3 emits 1536 samples per frame.
1141    let mut samples: Vec<Vec<u8>> = Vec::new();
1142    let mut durations: Vec<u32> = Vec::new();
1143    while cursor < es.len() {
1144        let Some(found) = find_ac3_sync(&es, cursor) else {
1145            break;
1146        };
1147        cursor = found;
1148        // Re-read the per-frame frmsizecod low bit so the 44.1k
1149        // oscillation lands on the right boundary.
1150        if cursor + 5 > es.len() {
1151            break;
1152        }
1153        let frmsizecod = es[cursor + 4] & 0x3F;
1154        let bit_rate_code = frmsizecod >> 1;
1155        let low_bit = frmsizecod & 0x01;
1156        let fscod = (es[cursor + 4] >> 6) & 0x03;
1157        let Some(size) = ac3_frame_size(bit_rate_code, fscod, low_bit) else {
1158            break;
1159        };
1160        let end = cursor + size;
1161        if end > es.len() {
1162            break;
1163        }
1164        samples.push(es[cursor..end].to_vec());
1165        durations.push(1536);
1166        cursor = end;
1167    }
1168    if samples.is_empty() {
1169        return Ok(None);
1170    }
1171    Ok(Some(AudioTrack {
1172        codec: "ac3".into(),
1173        samples,
1174        sample_rate,
1175        channels,
1176        asc: Vec::new(),
1177        codec_private: dac3,
1178        timescale: sample_rate,
1179        durations,
1180    }))
1181}
1182
1183/// Extract E-AC-3 frames from PES packets on `audio_pid`. Returns an
1184/// `AudioTrack` with `codec = "eac3"`, `codec_private = dec3 body`, and
1185/// one sample per E-AC-3 syncframe (raw frame bytes verbatim).
1186///
1187/// `dec3.data_rate` is computed from the first frame: frame_size_bytes /
1188/// samples_per_frame * sample_rate * 8 / 2 / 1000 (kbps / 2 per §F.6).
1189fn extract_ts_eac3_audio(
1190    data: &[u8],
1191    packets: usize,
1192    packet_stride: usize,
1193    prefix_len: usize,
1194    audio_pid: u16,
1195) -> Result<Option<AudioTrack>> {
1196    let es = reassemble_audio_pes(data, packets, packet_stride, prefix_len, audio_pid);
1197    if es.is_empty() {
1198        return Ok(None);
1199    }
1200    let mut cursor = match find_ac3_sync(&es, 0) {
1201        Some(idx) => idx,
1202        None => return Ok(None),
1203    };
1204    let first: Eac3SyncInfo = match ac3_sync::parse_sync_info(&es[cursor..])
1205        .context("TS: first E-AC-3 frame failed to parse sync header")?
1206    {
1207        SyncInfo::Eac3(s) => s,
1208        SyncInfo::Ac3(_) => bail!("TS: E-AC-3 PMT entry but bitstream is AC-3 (bsid<=10)"),
1209    };
1210    let sample_rate = eac3_sample_rate_hz(first.fscod, first.fscod2);
1211    if sample_rate == 0 {
1212        bail!(
1213            "TS: E-AC-3 reserved sample rate (fscod={}, fscod2={})",
1214            first.fscod,
1215            first.fscod2
1216        );
1217    }
1218    let channels = channel_count(first.acmod, first.lfeon);
1219    let spf = eac3_samples_per_frame(first.numblkscod) as u64;
1220    let frame_bytes = ((first.frmsiz as u64) + 1) * 2;
1221    let bitrate_kbps = if spf > 0 && sample_rate > 0 {
1222        (frame_bytes * 8 * sample_rate as u64) / spf / 1000
1223    } else {
1224        0
1225    };
1226    let data_rate = bitrate_kbps.div_ceil(2) as u16;
1227    let dec3 = dec3_body_from_sync(&first, data_rate).to_vec();
1228
1229    let mut samples: Vec<Vec<u8>> = Vec::new();
1230    let mut durations: Vec<u32> = Vec::new();
1231    while cursor < es.len() {
1232        let Some(found) = find_ac3_sync(&es, cursor) else {
1233            break;
1234        };
1235        cursor = found;
1236        if cursor + 5 > es.len() {
1237            break;
1238        }
1239        // Re-read frmsiz from this frame's BSI: bytes 2..4 carry
1240        // strmtyp(2) + substreamid(3) + frmsiz(11). frmsiz = bits 5..15
1241        // of the BE u16 starting at byte 2.
1242        let raw = u16::from_be_bytes([es[cursor + 2], es[cursor + 3]]);
1243        let frmsiz = raw & 0x07FF;
1244        let size = eac3_frame_size(frmsiz);
1245        let end = cursor + size;
1246        if end > es.len() {
1247            break;
1248        }
1249        samples.push(es[cursor..end].to_vec());
1250        durations.push(spf as u32);
1251        cursor = end;
1252    }
1253    if samples.is_empty() {
1254        return Ok(None);
1255    }
1256    Ok(Some(AudioTrack {
1257        codec: "eac3".into(),
1258        samples,
1259        sample_rate,
1260        channels,
1261        asc: Vec::new(),
1262        codec_private: dec3,
1263        timescale: sample_rate,
1264        durations,
1265    }))
1266}
1267
1268/// Reassemble all PES payloads on `audio_pid` into one elementary stream
1269/// `Vec<u8>`. Shared between the AAC, AC-3 and E-AC-3 audio extractors —
1270/// each codec slices the resulting buffer into frames using its own
1271/// sync-word + frame-size logic.
1272fn reassemble_audio_pes(
1273    data: &[u8],
1274    packets: usize,
1275    packet_stride: usize,
1276    prefix_len: usize,
1277    audio_pid: u16,
1278) -> Vec<u8> {
1279    let mut es: Vec<u8> = Vec::new();
1280    let mut have_first_start = false;
1281    for i in 0..packets {
1282        let start = i * packet_stride + prefix_len;
1283        let pkt = &data[start..start + TS_PACKET];
1284        if pkt[0] != TS_SYNC {
1285            continue;
1286        }
1287        let pid = (((pkt[1] & 0x1F) as u16) << 8) | pkt[2] as u16;
1288        if pid != audio_pid {
1289            continue;
1290        }
1291        let pusi = pkt[1] & 0x40 != 0;
1292        let scramble = (pkt[3] >> 6) & 0x03;
1293        if scramble != 0 {
1294            continue;
1295        }
1296        let adaptation = (pkt[3] >> 4) & 0x03;
1297        let has_payload = adaptation & 0x01 != 0;
1298        let has_adaptation = adaptation & 0x02 != 0;
1299        if !has_payload {
1300            continue;
1301        }
1302
1303        let mut offset = 4usize;
1304        if has_adaptation {
1305            if offset >= TS_PACKET {
1306                continue;
1307            }
1308            let adap_len = pkt[offset] as usize;
1309            offset += 1 + adap_len;
1310            if offset > TS_PACKET {
1311                continue;
1312            }
1313        }
1314        if offset >= TS_PACKET {
1315            continue;
1316        }
1317        let payload = &pkt[offset..];
1318
1319        if pusi {
1320            let Some((es_start, _pts)) = parse_pes_header_audio(payload) else {
1321                have_first_start = false;
1322                continue;
1323            };
1324            have_first_start = true;
1325            if es_start < payload.len() {
1326                es.extend_from_slice(&payload[es_start..]);
1327            }
1328        } else if have_first_start {
1329            es.extend_from_slice(payload);
1330        }
1331    }
1332    es
1333}
1334
1335/// Dispatch audio extraction by codec kind from the PMT walk. Per
1336/// Squad-37: AAC routes through `extract_ts_aac_audio` (Squad-27 path);
1337/// AC-3 and E-AC-3 route through their respective new extractors.
1338fn extract_ts_audio(
1339    data: &[u8],
1340    packets: usize,
1341    packet_stride: usize,
1342    prefix_len: usize,
1343    info: AudioStreamInfo,
1344) -> Result<Option<AudioTrack>> {
1345    match info.kind {
1346        AudioCodecKind::AacAdts => {
1347            extract_ts_aac_audio(data, packets, packet_stride, prefix_len, info.pid)
1348        }
1349        AudioCodecKind::Ac3 => {
1350            extract_ts_ac3_audio(data, packets, packet_stride, prefix_len, info.pid)
1351        }
1352        AudioCodecKind::Eac3 => {
1353            extract_ts_eac3_audio(data, packets, packet_stride, prefix_len, info.pid)
1354        }
1355    }
1356}
1357
1358/// Find the next ADTS sync word at or after `from` in `es`. Returns the
1359/// offset of the sync byte (0xFF) or `None`.
1360fn find_adts_sync(es: &[u8], from: usize) -> Option<usize> {
1361    let mut i = from;
1362    while i + 1 < es.len() {
1363        if es[i] == 0xFF && (es[i + 1] & 0xF0) == 0xF0 {
1364            return Some(i);
1365        }
1366        i += 1;
1367    }
1368    None
1369}
1370
1371/// Parse a PES header for audio (stream_id 0xC0..=0xDF). Same shape as
1372/// `parse_pes_header` for video but accepts the audio stream_id range.
1373/// Returns `(es_start, pts)`.
1374fn parse_pes_header_audio(payload: &[u8]) -> Option<(usize, Option<u64>)> {
1375    if payload.len() < 9 {
1376        return None;
1377    }
1378    if payload[0] != 0 || payload[1] != 0 || payload[2] != 1 {
1379        return None;
1380    }
1381    let stream_id = payload[3];
1382    // Audio streams are 0xC0..=0xDF per ISO/IEC 13818-1 §2.4.3.7.
1383    if !(0xC0..=0xDF).contains(&stream_id) {
1384        return None;
1385    }
1386    let flags = payload[7];
1387    let pts_dts_flags = (flags >> 6) & 0x03;
1388    let header_data_len = payload[8] as usize;
1389    let es_start = 9 + header_data_len;
1390    if es_start > payload.len() {
1391        return None;
1392    }
1393    let pts = if pts_dts_flags == 0b10 || pts_dts_flags == 0b11 {
1394        if payload.len() < 14 {
1395            return None;
1396        }
1397        let p0 = ((payload[9] >> 1) & 0x07) as u64;
1398        let p1 = (((payload[10] as u64) << 7) | ((payload[11] as u64) >> 1)) & 0x7FFF;
1399        let p2 = (((payload[12] as u64) << 7) | ((payload[13] as u64) >> 1)) & 0x7FFF;
1400        Some((p0 << 30) | (p1 << 15) | p2)
1401    } else {
1402        None
1403    };
1404    Some((es_start, pts))
1405}
1406
1407// ---------------------------------------------------------------------------
1408// TsStreamingDemuxer (Squad streaming-migration-55 P1)
1409// ---------------------------------------------------------------------------
1410
1411/// Streaming MPEG-TS demuxer. Holds the PES reassembly buffer for one
1412/// in-flight access unit only — yields whenever a PUSI=1 packet
1413/// closes the current sample (or at EOF for the final pending sample).
1414///
1415/// Squad-37 added:
1416/// - **Multi-program awareness**: `programs()` returns every program
1417///   the PAT advertised plus their PMT contents; `select_program()`
1418///   switches the active video PID + audio extraction to a different
1419///   program. Default behaviour is unchanged (first program with a
1420///   recognised video stream wins).
1421/// - **Encrypted-stream guard**: if any packet on the active video PID
1422///   carries `transport_scrambling_control != 0`, we log a one-time
1423///   typed warn ("encrypted TS stream; we don't carry CA tables —
1424///   drop video output") and switch to a "drop everything" mode where
1425///   `next_video_sample` returns `Ok(None)` without further parsing.
1426pub struct TsStreamingDemuxer {
1427    data: Vec<u8>,
1428    header: DemuxHeader,
1429    audio: Option<AudioTrack>,
1430    packets: usize,
1431    packet_stride: usize,
1432    prefix_len: usize,
1433    /// Every program the PAT advertised, in PAT order. Each entry's
1434    /// PMT was walked at init to populate its video/audio stream lists.
1435    /// Programs whose PMT we couldn't parse are still listed (with
1436    /// empty video_streams/audio_streams) so callers see them.
1437    programs: Vec<ProgramInfo>,
1438    /// Index into `programs` of the currently active program. Default:
1439    /// the first program with a recognised video stream.
1440    active_program_idx: usize,
1441    /// Active video PID (mirrors `programs[active_program_idx].video_streams[0].pid`).
1442    video_pid: u16,
1443    /// Index of the next packet to scan.
1444    next_pkt: usize,
1445    /// In-flight PES payload — emptied & yielded on next PUSI.
1446    pending: Vec<u8>,
1447    /// PTS attached to `pending` (PTS lives in the PES header that
1448    /// opened the AU).
1449    pending_pts: Option<u64>,
1450    /// True once we've seen the first PUSI for our PID. Bytes before
1451    /// the first PUSI are dropped (mid-stream join semantics).
1452    have_first_start: bool,
1453    /// True after we've returned `Ok(None)` once — guards against
1454    /// repeated drains.
1455    eof: bool,
1456    /// Lazily set on first emitted sample — `pixel_format::detect` is
1457    /// one-shot against `samples[0]` so we patch `header.info.pixel_format`
1458    /// in place once and skip the probe thereafter.
1459    pixel_format_detected: bool,
1460    /// Encrypted-stream guard (Squad-37). Latches `true` the first time
1461    /// we see `transport_scrambling_control != 0` on the active video
1462    /// PID; warning is logged exactly once and `next_video_sample`
1463    /// returns `Ok(None)` from that point on.
1464    encrypted_drop: bool,
1465}
1466
1467pub(crate) fn demux_ts_streaming_init(data: &[u8]) -> Result<TsStreamingDemuxer> {
1468    let owned = data.to_vec();
1469    let (packets, packet_stride, prefix_len) = detect_packet_layout(&owned)?;
1470    if packets == 0 {
1471        bail!("TS: file contains no TS packets");
1472    }
1473
1474    // Phase 1: walk the PAT and collect every program + its PMT PID.
1475    let mut pat_programs: Vec<PatProgram> = Vec::new();
1476    for i in 0..packets {
1477        let start = i * packet_stride + prefix_len;
1478        let pkt = &owned[start..start + TS_PACKET];
1479        if pkt[0] != TS_SYNC {
1480            continue;
1481        }
1482        let pid = (((pkt[1] & 0x1F) as u16) << 8) | pkt[2] as u16;
1483        if pid == 0
1484            && let Some(payload) = ts_psi_payload(pkt)
1485        {
1486            let progs = parse_pat_all_programs(payload);
1487            if !progs.is_empty() {
1488                pat_programs = progs;
1489                break;
1490            }
1491        }
1492    }
1493    if pat_programs.is_empty() {
1494        bail!("TS: no PAT entries found");
1495    }
1496
1497    // Phase 2: walk every PMT and resolve its video+audio streams. We
1498    // remember the FIRST PMT section we see per PID — later versions
1499    // (table_id 0x02 with a higher `version_number`) would update an
1500    // active session in a real-world receiver but our demuxer is
1501    // start-of-file-only, so first-section semantics are correct.
1502    let mut programs: Vec<ProgramInfo> = pat_programs
1503        .iter()
1504        .map(|p| ProgramInfo {
1505            program_number: p.program_number,
1506            pmt_pid: p.pmt_pid,
1507            video_streams: Vec::new(),
1508            audio_streams: Vec::new(),
1509        })
1510        .collect();
1511    // Track which programs still need their PMT parsed.
1512    let mut need: std::collections::HashSet<u16> = pat_programs.iter().map(|p| p.pmt_pid).collect();
1513    for i in 0..packets {
1514        if need.is_empty() {
1515            break;
1516        }
1517        let start = i * packet_stride + prefix_len;
1518        let pkt = &owned[start..start + TS_PACKET];
1519        if pkt[0] != TS_SYNC {
1520            continue;
1521        }
1522        let pid = (((pkt[1] & 0x1F) as u16) << 8) | pkt[2] as u16;
1523        if !need.contains(&pid) {
1524            continue;
1525        }
1526        if let Some(payload) = ts_psi_payload(pkt)
1527            && let Some((video_streams, audio_streams)) = parse_pmt_streams(payload)
1528        {
1529            if let Some(prog) = programs.iter_mut().find(|p| p.pmt_pid == pid) {
1530                prog.video_streams = video_streams;
1531                prog.audio_streams = audio_streams;
1532            }
1533            need.remove(&pid);
1534        }
1535    }
1536
1537    // Phase 3: pick the default active program — first one with a
1538    // recognised video stream. Matches legacy "first program wins"
1539    // semantics for single-program files.
1540    let active_program_idx = programs
1541        .iter()
1542        .position(|p| !p.video_streams.is_empty())
1543        .context("TS: no program advertises a recognised video elementary stream")?;
1544    let active = &programs[active_program_idx];
1545    let video = active.video_streams[0];
1546    let audio = active.audio_streams.first().copied();
1547    let codec = match video.stream_type {
1548        STREAM_TYPE_MPEG2_VIDEO => "mpeg2",
1549        STREAM_TYPE_H264 => "h264",
1550        STREAM_TYPE_HEVC => "h265",
1551        other => bail!("TS: unsupported stream_type 0x{:02X}", other),
1552    }
1553    .to_string();
1554
1555    // total_frames + duration are unknown until drained.
1556    //
1557    // width/height recovery: TS carries nothing at the container layer,
1558    // so we walk just enough packets to capture the first video AU and
1559    // parse its SPS (H.264 / HEVC) or sequence header (MPEG-2). This
1560    // has to happen during init — `header()` is read by the pipeline
1561    // before any `next_video_sample` call, and the rav1e encoder
1562    // rejects 0×0 configs outright. Parse failure is non-fatal: we
1563    // warn and leave dims at 0 so the failure surfaces in the encoder
1564    // config error rather than silently corrupting the output.
1565    //
1566    // frame_rate: same scan collects a window of video-PID PTSes (up
1567    // to 64 PUSIs). Inter-PTS span over (count-1) intervals at the
1568    // 90 kHz TS clock gives the source fps. A wrong frame_rate here
1569    // causes exactly the kind of "video sped up, audio drags" sync
1570    // symptom that the BBB 24 fps sample hit against the previous
1571    // hardcoded `30.0` fallback. Falls back to `30.0` only when the
1572    // scan can't derive a finite fps in [1.0, 240.0].
1573    let scan = scan_first_video_au(&owned, packets, packet_stride, prefix_len, video.pid, 64);
1574    let (width, height) = match &scan.first_au {
1575        Some(au) => codec::pixel_format::detect_dims(&codec, std::slice::from_ref(au))
1576            .unwrap_or_else(|| {
1577                tracing::warn!(
1578                    codec = codec.as_str(),
1579                    video_pid = video.pid,
1580                    "TS streaming demux: first AU SPS parse failed; width/height=0×0"
1581                );
1582                (0, 0)
1583            }),
1584        None => {
1585            tracing::warn!(
1586                codec = codec.as_str(),
1587                video_pid = video.pid,
1588                "TS streaming demux: could not locate first video AU during init; width/height=0×0"
1589            );
1590            (0, 0)
1591        }
1592    };
1593    let frame_rate = estimate_frame_rate_from_ptses(&scan.ptses).unwrap_or_else(|| {
1594        tracing::warn!(
1595            codec = codec.as_str(),
1596            video_pid = video.pid,
1597            pts_samples = scan.ptses.len(),
1598            "TS streaming demux: could not derive frame_rate from PTS window; defaulting to 30.0"
1599        );
1600        30.0
1601    });
1602
1603    let info = StreamInfo {
1604        codec: codec.clone(),
1605        width,
1606        height,
1607        frame_rate,
1608        duration: 0.0,
1609        pixel_format: PixelFormat::Yuv420p,
1610        color_space: ColorSpace::Bt709,
1611        total_frames: 0,
1612        bitrate: 0,
1613        color_metadata: Default::default(),
1614    };
1615
1616    // Audio passthrough still happens up-front (Squad-18 contract).
1617    // Squad-37 routes by codec kind (AAC / AC-3 / E-AC-3).
1618    let audio_track = audio.and_then(|info| {
1619        match extract_ts_audio(&owned, packets, packet_stride, prefix_len, info) {
1620            Ok(track) => track,
1621            Err(e) => {
1622                tracing::warn!(
1623                    audio_pid = info.pid,
1624                    audio_kind = ?info.kind,
1625                    error = %e,
1626                    "TS audio extraction failed; emitting video-only"
1627                );
1628                None
1629            }
1630        }
1631    });
1632
1633    Ok(TsStreamingDemuxer {
1634        data: owned,
1635        header: DemuxHeader { codec, info },
1636        audio: audio_track,
1637        packets,
1638        packet_stride,
1639        prefix_len,
1640        programs,
1641        active_program_idx,
1642        video_pid: video.pid,
1643        next_pkt: 0,
1644        pending: Vec::new(),
1645        pending_pts: None,
1646        have_first_start: false,
1647        eof: false,
1648        pixel_format_detected: false,
1649        encrypted_drop: false,
1650    })
1651}
1652
1653impl TsStreamingDemuxer {
1654    /// Every program the PAT advertised, in PAT order. Squad-37 multi-
1655    /// program API — useful for callers that want to enumerate channels
1656    /// in a multi-program transport (DVB / ATSC broadcast capture). For
1657    /// single-program files the slice has length 1.
1658    pub fn programs(&self) -> &[ProgramInfo] {
1659        &self.programs
1660    }
1661
1662    /// Index of the currently active program (within `programs()`).
1663    pub fn active_program_index(&self) -> usize {
1664        self.active_program_idx
1665    }
1666
1667    /// Switch the active program by PMT-side `program_number`. Resets the
1668    /// per-AU walk state (pending PES bytes, PTS, encrypted-drop guard,
1669    /// pixel-format detection) so the next `next_video_sample` call
1670    /// starts cleanly on the new video PID. Returns `Ok(())` on success
1671    /// or an error if `program_number` is not in `programs()` or the
1672    /// chosen program has no recognised video stream.
1673    ///
1674    /// Audio is re-extracted from the new program's first audio stream
1675    /// (if any). For single-program files (the common case) callers
1676    /// don't need to touch this; the constructor already picked program
1677    /// 0 by default.
1678    pub fn select_program(&mut self, program_number: u16) -> Result<()> {
1679        let new_idx = self
1680            .programs
1681            .iter()
1682            .position(|p| p.program_number == program_number)
1683            .with_context(|| format!("TS: program_number {} not found in PAT", program_number))?;
1684        if self.programs[new_idx].video_streams.is_empty() {
1685            bail!(
1686                "TS: program {} has no recognised video stream",
1687                program_number
1688            );
1689        }
1690        let video = self.programs[new_idx].video_streams[0];
1691        let audio = self.programs[new_idx].audio_streams.first().copied();
1692        let codec = match video.stream_type {
1693            STREAM_TYPE_MPEG2_VIDEO => "mpeg2",
1694            STREAM_TYPE_H264 => "h264",
1695            STREAM_TYPE_HEVC => "h265",
1696            other => bail!(
1697                "TS: program {} video stream_type 0x{:02X} unsupported",
1698                program_number,
1699                other
1700            ),
1701        }
1702        .to_string();
1703        self.active_program_idx = new_idx;
1704        self.video_pid = video.pid;
1705        // Refresh the codec / pixel-format fields on the cached header
1706        // — `info.codec` flows out of `header()` to the pipeline.
1707        self.header.codec = codec.clone();
1708        self.header.info.codec = codec.clone();
1709        self.header.info.pixel_format = PixelFormat::Yuv420p;
1710        self.pixel_format_detected = false;
1711        // Re-probe width/height + frame_rate from the new program's
1712        // video PID. Zero dims / 30 fps fallback on parse failure so
1713        // the encoder reports the miss rather than silently using the
1714        // previous program's values.
1715        let scan = scan_first_video_au(
1716            &self.data,
1717            self.packets,
1718            self.packet_stride,
1719            self.prefix_len,
1720            video.pid,
1721            64,
1722        );
1723        let (w, h) = match &scan.first_au {
1724            Some(au) => {
1725                codec::pixel_format::detect_dims(&codec, std::slice::from_ref(au)).unwrap_or((0, 0))
1726            }
1727            None => (0, 0),
1728        };
1729        self.header.info.width = w;
1730        self.header.info.height = h;
1731        self.header.info.frame_rate = estimate_frame_rate_from_ptses(&scan.ptses).unwrap_or(30.0);
1732        // Reset PES walk state.
1733        self.next_pkt = 0;
1734        self.pending.clear();
1735        self.pending_pts = None;
1736        self.have_first_start = false;
1737        self.eof = false;
1738        self.encrypted_drop = false;
1739        // Re-extract audio from the new program's first audio stream.
1740        self.audio = audio.and_then(|info| {
1741            match extract_ts_audio(
1742                &self.data,
1743                self.packets,
1744                self.packet_stride,
1745                self.prefix_len,
1746                info,
1747            ) {
1748                Ok(track) => track,
1749                Err(e) => {
1750                    tracing::warn!(
1751                        audio_pid = info.pid,
1752                        audio_kind = ?info.kind,
1753                        error = %e,
1754                        "TS audio extraction failed on program switch; emitting video-only"
1755                    );
1756                    None
1757                }
1758            }
1759        });
1760        Ok(())
1761    }
1762
1763    /// Build a Sample from raw AU bytes, applying the one-shot
1764    /// pixel_format detection on the first emission. Centralises the
1765    /// three yield sites in `next_video_sample`.
1766    fn yield_sample(&mut self, data: Vec<u8>, pts: Option<u64>) -> Sample {
1767        if !self.pixel_format_detected {
1768            let detected =
1769                codec::pixel_format::detect(&self.header.codec, std::slice::from_ref(&data));
1770            self.header.info.pixel_format = detected;
1771            self.pixel_format_detected = true;
1772        }
1773        Sample {
1774            data,
1775            pts_ticks: pts.map(|p| p as i64).unwrap_or(0),
1776            duration_ticks: 0,
1777        }
1778    }
1779}
1780
1781impl StreamingDemuxer for TsStreamingDemuxer {
1782    fn header(&self) -> &DemuxHeader {
1783        &self.header
1784    }
1785
1786    fn next_video_sample(&mut self) -> Result<Option<Sample>> {
1787        if self.eof || self.encrypted_drop {
1788            return Ok(None);
1789        }
1790        loop {
1791            if self.next_pkt >= self.packets {
1792                // Drain the final pending sample at EOF.
1793                self.eof = true;
1794                if !self.pending.is_empty() {
1795                    let data = std::mem::take(&mut self.pending);
1796                    let pts = self.pending_pts.take();
1797                    return Ok(Some(self.yield_sample(data, pts)));
1798                }
1799                return Ok(None);
1800            }
1801
1802            let i = self.next_pkt;
1803            self.next_pkt += 1;
1804            let start = i * self.packet_stride + self.prefix_len;
1805            let pkt = &self.data[start..start + TS_PACKET];
1806            if pkt[0] != TS_SYNC {
1807                continue;
1808            }
1809            let pid = (((pkt[1] & 0x1F) as u16) << 8) | pkt[2] as u16;
1810            if pid != self.video_pid {
1811                continue;
1812            }
1813            let pusi = pkt[1] & 0x40 != 0;
1814            let scramble = (pkt[3] >> 6) & 0x03;
1815            if scramble != 0 {
1816                // Encrypted-stream guard (Squad-37). The first scrambled
1817                // packet on the active video PID triggers a one-time
1818                // typed warn and flips us into drop-everything mode —
1819                // any further `next_video_sample` calls return
1820                // `Ok(None)` immediately. We don't carry CA tables, so
1821                // any byte we feed downstream from here is garbage.
1822                tracing::warn!(
1823                    video_pid = self.video_pid,
1824                    transport_scrambling_control = scramble,
1825                    error_kind = "encrypted_ts",
1826                    "encrypted TS stream; we don't carry CA tables — drop video output"
1827                );
1828                self.encrypted_drop = true;
1829                self.pending.clear();
1830                self.pending_pts = None;
1831                self.have_first_start = false;
1832                self.eof = true;
1833                return Ok(None);
1834            }
1835            let adaptation = (pkt[3] >> 4) & 0x03;
1836            let has_payload = adaptation & 0x01 != 0;
1837            let has_adaptation = adaptation & 0x02 != 0;
1838            if !has_payload {
1839                continue;
1840            }
1841
1842            let mut offset = 4usize;
1843            if has_adaptation {
1844                if offset >= TS_PACKET {
1845                    continue;
1846                }
1847                let adap_len = pkt[offset] as usize;
1848                offset += 1 + adap_len;
1849                if offset > TS_PACKET {
1850                    continue;
1851                }
1852            }
1853            if offset >= TS_PACKET {
1854                continue;
1855            }
1856            let payload = &pkt[offset..];
1857
1858            if pusi {
1859                // PUSI flushes the previous AU. If we already had one
1860                // in-flight, return it now and stage the new one for
1861                // the next call.
1862                let had_pending = self.have_first_start;
1863                let prev_data = if had_pending {
1864                    std::mem::take(&mut self.pending)
1865                } else {
1866                    Vec::new()
1867                };
1868                let prev_pts = self.pending_pts.take();
1869                self.have_first_start = true;
1870
1871                let Some((es_start, pts)) = parse_pes_header(payload) else {
1872                    // Malformed PES — drop state, keep walking.
1873                    self.have_first_start = false;
1874                    self.pending.clear();
1875                    if !prev_data.is_empty() {
1876                        return Ok(Some(self.yield_sample(prev_data, prev_pts)));
1877                    }
1878                    continue;
1879                };
1880                self.pending_pts = pts;
1881                if es_start < payload.len() {
1882                    self.pending.extend_from_slice(&payload[es_start..]);
1883                }
1884
1885                if !prev_data.is_empty() {
1886                    return Ok(Some(self.yield_sample(prev_data, prev_pts)));
1887                }
1888                // No previous AU to yield — keep walking until the next
1889                // PUSI (or EOF).
1890            } else if self.have_first_start {
1891                self.pending.extend_from_slice(payload);
1892            }
1893        }
1894    }
1895
1896    fn audio(&self) -> Option<&AudioTrack> {
1897        self.audio.as_ref()
1898    }
1899}
1900
1901#[cfg(test)]
1902mod tests {
1903    use super::*;
1904
1905    fn ts_pkt(pid: u16, pusi: bool, adaptation: u8, payload: &[u8]) -> [u8; TS_PACKET] {
1906        let mut p = [0xFFu8; TS_PACKET];
1907        p[0] = TS_SYNC;
1908        // TEI=0, PUSI=pusi, transport_priority=0, PID(13)
1909        p[1] = if pusi { 0x40 } else { 0x00 } | ((pid >> 8) & 0x1F) as u8;
1910        p[2] = (pid & 0xFF) as u8;
1911        // scramble=00, adaptation=adaptation, continuity=0
1912        p[3] = (adaptation & 0x03) << 4;
1913        let mut off = 4;
1914        // For these tests we always use adaptation=01 (payload only).
1915        let pay_len = payload.len().min(TS_PACKET - off);
1916        p[off..off + pay_len].copy_from_slice(&payload[..pay_len]);
1917        off += pay_len;
1918        // Pad any remaining bytes with 0xFF (already initialised).
1919        let _ = off;
1920        p
1921    }
1922
1923    #[test]
1924    fn estimate_frame_rate_from_uniform_ptses_returns_exact_fps() {
1925        // 24 fps: inter-PTS = 90000/24 = 3750 ticks.
1926        let ptses: Vec<u64> = (0..64).map(|i| i as u64 * 3750).collect();
1927        let fps = estimate_frame_rate_from_ptses(&ptses).expect("24 fps");
1928        assert!((fps - 24.0).abs() < 1e-9, "{} != 24.0", fps);
1929    }
1930
1931    #[test]
1932    fn estimate_frame_rate_from_reordered_ptses_sorts_before_delta() {
1933        // Same 24 fps, but decode-order != display-order (one B-frame
1934        // pair swapped). Median should still pick up the 3750-tick
1935        // period cleanly.
1936        let mut ptses: Vec<u64> = (0..32).map(|i| i as u64 * 3750).collect();
1937        ptses.swap(5, 6);
1938        ptses.swap(10, 11);
1939        let fps = estimate_frame_rate_from_ptses(&ptses).expect("24 fps after swap");
1940        assert!((fps - 24.0).abs() < 1e-9, "{} != 24.0", fps);
1941    }
1942
1943    #[test]
1944    fn estimate_frame_rate_from_single_outlier_delta_uses_median() {
1945        // 23 uniform 24-fps deltas + one 10× outlier. Median still 3750.
1946        let mut ptses: Vec<u64> = (0..24).map(|i| i as u64 * 3750).collect();
1947        ptses.push(24 * 3750 + 37500); // one huge gap
1948        let fps = estimate_frame_rate_from_ptses(&ptses).expect("24 fps despite outlier");
1949        assert!((fps - 24.0).abs() < 1e-9);
1950    }
1951
1952    #[test]
1953    fn estimate_frame_rate_returns_none_when_all_ptses_equal() {
1954        let ptses = vec![0u64; 10];
1955        assert!(estimate_frame_rate_from_ptses(&ptses).is_none());
1956    }
1957
1958    #[test]
1959    fn estimate_frame_rate_returns_none_when_fewer_than_two() {
1960        assert!(estimate_frame_rate_from_ptses(&[]).is_none());
1961        assert!(estimate_frame_rate_from_ptses(&[1234]).is_none());
1962    }
1963
1964    #[test]
1965    fn estimate_frame_rate_rejects_out_of_range_values() {
1966        // Single 1-tick delta → fps = 90000, outside [1, 240].
1967        let ptses = vec![0u64, 1];
1968        assert!(estimate_frame_rate_from_ptses(&ptses).is_none());
1969    }
1970
1971    #[test]
1972    fn estimate_frame_rate_handles_29_97_ntsc() {
1973        // 29.97 fps = 30000/1001. Inter-PTS = 90000 * 1001 / 30000 = 3003.
1974        let ptses: Vec<u64> = (0..32).map(|i| i as u64 * 3003).collect();
1975        let fps = estimate_frame_rate_from_ptses(&ptses).expect("29.97");
1976        assert!((fps - 30.0).abs() < 0.05, "got {}", fps); // 90000/3003 = 29.97..30.03
1977    }
1978
1979    #[test]
1980    fn detects_plain_ts_layout() {
1981        let mut buf = Vec::with_capacity(3 * TS_PACKET);
1982        for _ in 0..3 {
1983            let pkt = ts_pkt(0x1FFF, false, 0b01, &[]);
1984            buf.extend_from_slice(&pkt);
1985        }
1986        let (count, stride, prefix) = detect_packet_layout(&buf).unwrap();
1987        assert_eq!((count, stride, prefix), (3, 188, 0));
1988    }
1989
1990    #[test]
1991    fn parses_minimal_pat_pmt_and_reassembles_one_sample() {
1992        // Build a PAT pointing at PMT=0x100, a PMT listing video PID=0x200
1993        // stream_type=MPEG-2, then a single PES packet carrying 16 bytes
1994        // of video ES.
1995
1996        // PAT section (we skip CRC correctness — the parser only uses
1997        // section_length to decide where to stop).
1998        let mut pat = vec![0u8; 0];
1999        pat.push(0x00); // table_id
2000        let section_length: usize = 5 + 4 + 4; // 5 header bytes (after len) + 1 program + CRC
2001        pat.push(0xB0 | ((section_length >> 8) & 0x0F) as u8);
2002        pat.push((section_length & 0xFF) as u8);
2003        pat.extend_from_slice(&[0x00, 0x01, 0xC1, 0x00, 0x00]); // tsid, ver/current, secno, lastno
2004        pat.extend_from_slice(&[0x00, 0x01]); // program_number = 1
2005        pat.extend_from_slice(&[0xE1, 0x00]); // reserved + PMT PID = 0x100
2006        pat.extend_from_slice(&[0, 0, 0, 0]); // CRC placeholder
2007
2008        // PAT packet payload = [pointer_field=0, section...]
2009        let mut pat_payload = vec![0u8];
2010        pat_payload.extend_from_slice(&pat);
2011        let pat_pkt = ts_pkt(0x0000, true, 0b01, &pat_payload);
2012
2013        // PMT section.
2014        let mut pmt = vec![0u8; 0];
2015        pmt.push(0x02);
2016        let pmt_sec_len: usize = 9 + 5 + 4; // program_number..pil(9) + 1 stream entry(5) + CRC(4)
2017        pmt.push(0xB0 | ((pmt_sec_len >> 8) & 0x0F) as u8);
2018        pmt.push((pmt_sec_len & 0xFF) as u8);
2019        pmt.extend_from_slice(&[0x00, 0x01, 0xC1, 0x00, 0x00]); // prog, ver/current, sec/last
2020        pmt.extend_from_slice(&[0xE2, 0x00]); // PCR PID = 0x200
2021        pmt.extend_from_slice(&[0xF0, 0x00]); // program_info_length = 0
2022        pmt.extend_from_slice(&[STREAM_TYPE_MPEG2_VIDEO, 0xE2, 0x00, 0xF0, 0x00]); // stream entry
2023        pmt.extend_from_slice(&[0, 0, 0, 0]); // CRC placeholder
2024        let mut pmt_payload = vec![0u8];
2025        pmt_payload.extend_from_slice(&pmt);
2026        let pmt_pkt = ts_pkt(0x0100, true, 0b01, &pmt_payload);
2027
2028        // Two PES packets, each 16 bytes of ES, so the reassembler's
2029        // PUSI-flush path is exercised. Real MPEG-TS files also set
2030        // PES_packet_length which bounds the first one, but packet_length=0
2031        // ("unbounded") is also legal for MPEG-2 video PES, which is what
2032        // we emit here — termination comes from the next PUSI.
2033        let make_pes = |byte: u8| {
2034            let mut pes = vec![0u8, 0u8, 1u8]; // start code
2035            pes.push(0xE0); // stream_id video
2036            pes.extend_from_slice(&[0u8, 0u8]); // packet_length=0
2037            pes.push(0x80);
2038            pes.push(0x80); // PTS_DTS_flags = 10
2039            pes.push(5); // PES_header_data_length
2040            pes.extend_from_slice(&[0x21, 0x00, 0x01, 0x00, 0x01]); // PTS=0
2041            pes.extend_from_slice(&[byte; 16]);
2042            pes
2043        };
2044        let pes_pkt_a = ts_pkt(0x0200, true, 0b01, &make_pes(0xAA));
2045        let pes_pkt_b = ts_pkt(0x0200, true, 0b01, &make_pes(0xBB));
2046
2047        let mut buf = Vec::new();
2048        buf.extend_from_slice(&pat_pkt);
2049        buf.extend_from_slice(&pmt_pkt);
2050        buf.extend_from_slice(&pes_pkt_a);
2051        buf.extend_from_slice(&pes_pkt_b);
2052        // Trailing null packet so detect_packet_layout sees a sync run.
2053        buf.extend_from_slice(&ts_pkt(0x1FFF, false, 0b01, &[]));
2054
2055        let d = demux_ts(&buf).expect("demux");
2056        assert_eq!(d.codec, "mpeg2");
2057        // We should have reassembled two samples (the first flushed when
2058        // the second PUSI arrives). Sample A carries the 16 AU bytes
2059        // plus whatever TS padding trailed the PES header — the
2060        // demuxer does not know the bound, so exact byte-for-byte
2061        // comparison needs packet_length support (future). For now
2062        // assert: right sample count, correct leading bytes.
2063        assert_eq!(d.samples.len(), 2);
2064        assert_eq!(&d.samples[0][..16], &[0xAA; 16]);
2065        assert_eq!(&d.samples[1][..16], &[0xBB; 16]);
2066    }
2067
2068    #[test]
2069    fn rejects_file_with_no_sync() {
2070        let garbage = vec![0u8; TS_PACKET * 3];
2071        assert!(demux_ts(&garbage).is_err());
2072    }
2073
2074    // ---------------- AAC-ADTS / ASC unit tests (Squad-27) ----------------
2075
2076    /// Build a 7-byte ADTS header (no CRC) with the given fields.
2077    /// `frame_length` covers header + payload.
2078    fn build_adts_header_7(profile: u8, sr_idx: u8, ch_cfg: u8, frame_length: usize) -> [u8; 7] {
2079        let mut h = [0u8; 7];
2080        // Bytes 0..1: 0xFFF sync + ID(1)=0 (MPEG-4) + layer(2)=0 +
2081        // protection_absent(1)=1.
2082        h[0] = 0xFF;
2083        h[1] = 0xF0 | 0x01; // protection_absent = 1
2084        // Byte 2: profile(2) | sr_idx(4) | private(1) | ch_cfg high bit(1).
2085        h[2] = ((profile & 0x03) << 6) | ((sr_idx & 0x0F) << 2) | ((ch_cfg >> 2) & 0x01);
2086        // Byte 3: ch_cfg low 2 bits(2) | original/copy(1) | home(1) |
2087        // copyright_id_bit(1) | copyright_id_start(1) | frame_length high 2.
2088        h[3] = ((ch_cfg & 0x03) << 6) | (((frame_length >> 11) & 0x03) as u8);
2089        h[4] = ((frame_length >> 3) & 0xFF) as u8;
2090        h[5] = (((frame_length & 0x07) << 5) | 0x1F) as u8;
2091        // Byte 6: low buffer_fullness bits + number_of_raw_data_blocks(2) = 0.
2092        h[6] = 0xFC;
2093        h
2094    }
2095
2096    /// Build a 9-byte ADTS header (with CRC). CRC bytes are placeholders.
2097    fn build_adts_header_9(profile: u8, sr_idx: u8, ch_cfg: u8, frame_length: usize) -> [u8; 9] {
2098        let mut h = [0u8; 9];
2099        h[0] = 0xFF;
2100        h[1] = 0xF0; // protection_absent = 0 → CRC present
2101        h[2] = ((profile & 0x03) << 6) | ((sr_idx & 0x0F) << 2) | ((ch_cfg >> 2) & 0x01);
2102        h[3] = ((ch_cfg & 0x03) << 6) | (((frame_length >> 11) & 0x03) as u8);
2103        h[4] = ((frame_length >> 3) & 0xFF) as u8;
2104        h[5] = (((frame_length & 0x07) << 5) | 0x1F) as u8;
2105        h[6] = 0xFC;
2106        // Bytes 7..8: CRC placeholder (not validated by the parser).
2107        h
2108    }
2109
2110    #[test]
2111    fn adts_parser_decodes_canonical_lc_stereo_7byte_header() {
2112        // Canonical LC stereo @ 48k, 100-byte payload + 7-byte header.
2113        let h = build_adts_header_7(1, 3, 2, 107);
2114        let parsed = parse_adts_header(&h).expect("must parse 7-byte ADTS header");
2115        assert_eq!(parsed.profile, 1, "ADTS profile=1 LC");
2116        assert_eq!(parsed.sampling_frequency_index, 3, "sr_idx=3 → 48kHz");
2117        assert_eq!(parsed.channel_configuration, 2, "ch_cfg=2 stereo");
2118        assert_eq!(parsed.frame_length, 107);
2119        assert_eq!(parsed.header_len, 7, "protection_absent=1 → 7-byte header");
2120        assert_eq!(
2121            decode_sample_rate_index(parsed.sampling_frequency_index),
2122            Some(48000)
2123        );
2124    }
2125
2126    #[test]
2127    fn adts_parser_decodes_9byte_header_with_crc() {
2128        let h = build_adts_header_9(1, 4, 2, 109);
2129        let parsed = parse_adts_header(&h).expect("must parse 9-byte ADTS header");
2130        assert_eq!(parsed.profile, 1);
2131        assert_eq!(parsed.sampling_frequency_index, 4, "sr_idx=4 → 44.1kHz");
2132        assert_eq!(parsed.channel_configuration, 2);
2133        assert_eq!(parsed.frame_length, 109);
2134        assert_eq!(
2135            parsed.header_len, 9,
2136            "protection_absent=0 → 9-byte header (incl CRC)"
2137        );
2138        assert_eq!(
2139            decode_sample_rate_index(parsed.sampling_frequency_index),
2140            Some(44100)
2141        );
2142    }
2143
2144    #[test]
2145    fn adts_parser_decodes_aac_profile_bits_full_range() {
2146        // ADTS profile is 2 bits → values 0..=3 are the only legal forms:
2147        // 0=Main, 1=LC, 2=SSR, 3=LTP. Parent HE-AAC's AOT=5 (SBR) cannot
2148        // be carried in ADTS — HE-AAC streams in ADTS look like LC at
2149        // the header level and signal SBR inside the access unit. The
2150        // parser must round-trip every legal 2-bit profile value so the
2151        // upstream router can decide what to do (we accept LC=1 and
2152        // reject the rest at mux-validation time).
2153        for profile in 0u8..=3 {
2154            let h = build_adts_header_7(profile, 3, 2, 32);
2155            let parsed =
2156                parse_adts_header(&h).unwrap_or_else(|| panic!("must parse profile={profile}"));
2157            assert_eq!(parsed.profile, profile);
2158        }
2159    }
2160
2161    #[test]
2162    fn adts_parser_rejects_missing_sync() {
2163        let mut h = build_adts_header_7(1, 3, 2, 32);
2164        h[0] = 0x00;
2165        assert!(parse_adts_header(&h).is_none());
2166    }
2167
2168    #[test]
2169    fn adts_parser_rejects_short_buffer() {
2170        let h = build_adts_header_7(1, 3, 2, 32);
2171        assert!(
2172            parse_adts_header(&h[..6]).is_none(),
2173            "<7 bytes can't carry a complete ADTS header"
2174        );
2175    }
2176
2177    #[test]
2178    fn synthesize_asc_lc_stereo_48k_emits_0x1190() {
2179        // Squad-27 spec example: ADTS profile=1 (LC), sr_idx=3 (48k),
2180        // ch_cfg=2 (stereo) → ASC `0x11 0x90`.
2181        // Bit math:
2182        //   AOT=2 (LC),    5 bits = 00010
2183        //   sr_idx=3,      4 bits = 0011
2184        //   ch_cfg=2,      4 bits = 0010
2185        //   GA padding,    3 bits = 000
2186        // Concat: 00010 0011 0010 000 = 0001 0001 1001 0000 = 0x1190
2187        let adts = AdtsHeader {
2188            profile: 1,
2189            sampling_frequency_index: 3,
2190            channel_configuration: 2,
2191            frame_length: 0,
2192            header_len: 7,
2193        };
2194        let asc = synthesize_asc(&adts);
2195        assert_eq!(asc, [0x11, 0x90], "LC/48k/stereo → ASC 0x11 0x90");
2196    }
2197
2198    #[test]
2199    fn synthesize_asc_lc_mono_44k() {
2200        // AOT=2, sr_idx=4 (44.1k), ch_cfg=1 (mono):
2201        //   00010 0100 0001 000 = 0001 0010 0000 1000 = 0x12 0x08
2202        let adts = AdtsHeader {
2203            profile: 1,
2204            sampling_frequency_index: 4,
2205            channel_configuration: 1,
2206            frame_length: 0,
2207            header_len: 7,
2208        };
2209        assert_eq!(synthesize_asc(&adts), [0x12, 0x08]);
2210    }
2211
2212    #[test]
2213    fn synthesize_asc_main_aot_at_44k_5p1_rejected_at_channel_layer() {
2214        // ADTS profile=0 (Main) → ASC AOT=1. sr_idx=4 (44.1k),
2215        // ch_cfg=6 (5.1). The ASC bit packing must round-trip these
2216        // values regardless of whether the downstream mux accepts them
2217        // (mux today validates channels in {1, 2}).
2218        //   00001 0100 0110 000 = 0000 1010 0011 0000 = 0x0A 0x30
2219        let adts = AdtsHeader {
2220            profile: 0,
2221            sampling_frequency_index: 4,
2222            channel_configuration: 6,
2223            frame_length: 0,
2224            header_len: 7,
2225        };
2226        assert_eq!(synthesize_asc(&adts), [0x0A, 0x30]);
2227    }
2228
2229    #[test]
2230    fn adts_strip_7byte_header_yields_payload_only() {
2231        // Synthesize one ADTS frame: 7-byte header + 100-byte payload.
2232        // Run it through extract_ts_aac_audio's frame loop (via a minimal
2233        // synthetic TS) and assert the resulting sample is exactly 100
2234        // bytes — header stripped.
2235        let mut frame = Vec::with_capacity(107);
2236        frame.extend_from_slice(&build_adts_header_7(1, 3, 2, 107));
2237        frame.extend_from_slice(&[0x42u8; 100]);
2238        // Drive the frame loop directly to avoid the PES/TS scaffolding.
2239        // We test the public extraction in a separate integration test.
2240        let header = parse_adts_header(&frame).unwrap();
2241        assert_eq!(header.frame_length, 107);
2242        let payload = &frame[header.header_len..header.frame_length];
2243        assert_eq!(payload.len(), 100);
2244        assert!(payload.iter().all(|b| *b == 0x42));
2245    }
2246
2247    #[test]
2248    fn adts_sample_rate_table_covers_documented_indices() {
2249        // Spot-check the two anchors plus the boundary indices.
2250        assert_eq!(decode_sample_rate_index(0), Some(96000));
2251        assert_eq!(decode_sample_rate_index(3), Some(48000));
2252        assert_eq!(decode_sample_rate_index(4), Some(44100));
2253        assert_eq!(decode_sample_rate_index(12), Some(7350));
2254        assert!(decode_sample_rate_index(13).is_none(), "13 is reserved");
2255        assert!(
2256            decode_sample_rate_index(15).is_none(),
2257            "15 (escape) not supported"
2258        );
2259    }
2260
2261    /// End-to-end: build a synthetic TS file with PAT + PMT advertising
2262    /// MPEG-2 video on PID 0x200 AND AAC-ADTS on PID 0x300, plus PES
2263    /// packets carrying ADTS frames. After demux, the audio track must
2264    /// surface with synthesized ASC + stripped AAC samples + 1024-tick
2265    /// durations.
2266    #[test]
2267    fn demux_ts_yields_audio_track_when_pmt_advertises_aac() {
2268        // ---- PAT pointing at PMT 0x100 ----
2269        let mut pat = vec![0x00];
2270        let pat_section_len: usize = 5 + 4 + 4;
2271        pat.push(0xB0 | ((pat_section_len >> 8) & 0x0F) as u8);
2272        pat.push((pat_section_len & 0xFF) as u8);
2273        pat.extend_from_slice(&[0x00, 0x01, 0xC1, 0x00, 0x00]);
2274        pat.extend_from_slice(&[0x00, 0x01, 0xE1, 0x00, 0u8, 0u8, 0u8, 0u8]);
2275        let mut pat_payload = vec![0u8];
2276        pat_payload.extend_from_slice(&pat);
2277        let pat_pkt = ts_pkt(0x0000, true, 0b01, &pat_payload);
2278
2279        // ---- PMT advertising MPEG-2 video (PID 0x200) and AAC-ADTS audio
2280        // (PID 0x300) ----
2281        let mut pmt = vec![0x02];
2282        let pmt_section_len: usize = 9 + 5 + 5 + 4; // hdr + 2 stream entries + CRC
2283        pmt.push(0xB0 | ((pmt_section_len >> 8) & 0x0F) as u8);
2284        pmt.push((pmt_section_len & 0xFF) as u8);
2285        pmt.extend_from_slice(&[0x00, 0x01, 0xC1, 0x00, 0x00]);
2286        pmt.extend_from_slice(&[0xE2, 0x00]); // PCR PID = 0x200
2287        pmt.extend_from_slice(&[0xF0, 0x00]); // program_info_length = 0
2288        // Stream 1: MPEG-2 video on 0x200
2289        pmt.extend_from_slice(&[STREAM_TYPE_MPEG2_VIDEO, 0xE2, 0x00, 0xF0, 0x00]);
2290        // Stream 2: AAC-ADTS on 0x300
2291        pmt.extend_from_slice(&[STREAM_TYPE_AAC_ADTS, 0xE3, 0x00, 0xF0, 0x00]);
2292        pmt.extend_from_slice(&[0u8; 4]); // CRC placeholder
2293        let mut pmt_payload = vec![0u8];
2294        pmt_payload.extend_from_slice(&pmt);
2295        let pmt_pkt = ts_pkt(0x0100, true, 0b01, &pmt_payload);
2296
2297        // ---- Video PES (one packet, byte-pattern 0xAA × 16) so video
2298        // path doesn't bail. ----
2299        let video_pes = {
2300            let mut pes = vec![
2301                0u8, 0u8, 1u8, 0xE0, 0u8, 0u8, 0x80, 0x80, 5, 0x21, 0x00, 0x01, 0x00, 0x01,
2302            ];
2303            pes.extend_from_slice(&[0xAAu8; 16]);
2304            pes
2305        };
2306        let video_pkt = ts_pkt(0x0200, true, 0b01, &video_pes);
2307
2308        // ---- Audio PES carrying TWO ADTS frames (so we exercise the
2309        // frame-walking loop, not just the first). Each frame: 7-byte
2310        // header + 32-byte payload = 39 bytes total.
2311        let mut adts_stream = Vec::new();
2312        for fill in [0xCCu8, 0xDDu8] {
2313            adts_stream.extend_from_slice(&build_adts_header_7(1, 3, 2, 39));
2314            adts_stream.extend_from_slice(&[fill; 32]);
2315        }
2316        let audio_pes = {
2317            // PES header (audio stream_id 0xC0).
2318            let mut pes = vec![
2319                0u8, 0u8, 1u8, 0xC0, 0u8, 0u8, 0x80, 0x80, 5, 0x21, 0x00, 0x01, 0x00, 0x01,
2320            ];
2321            pes.extend_from_slice(&adts_stream);
2322            pes
2323        };
2324        let audio_pkt = ts_pkt(0x0300, true, 0b01, &audio_pes);
2325
2326        let mut buf = Vec::new();
2327        buf.extend_from_slice(&pat_pkt);
2328        buf.extend_from_slice(&pmt_pkt);
2329        buf.extend_from_slice(&video_pkt);
2330        buf.extend_from_slice(&audio_pkt);
2331        buf.extend_from_slice(&ts_pkt(0x1FFF, false, 0b01, &[]));
2332
2333        let d = demux_ts(&buf).expect("demux must succeed");
2334        assert_eq!(d.codec, "mpeg2");
2335        let audio = d.audio.expect("AAC audio track must be surfaced");
2336        assert_eq!(audio.codec, "aac");
2337        assert_eq!(audio.channels, 2, "ch_cfg=2 stereo");
2338        assert_eq!(audio.sample_rate, 48000, "sr_idx=3 → 48k");
2339        assert_eq!(audio.timescale, 48000, "AAC timescale = sample_rate");
2340        assert_eq!(
2341            audio.asc,
2342            vec![0x11, 0x90],
2343            "synthesized ASC for LC/48k/stereo"
2344        );
2345        assert_eq!(audio.samples.len(), 2, "two ADTS frames → two samples");
2346        assert_eq!(
2347            audio.samples[0].len(),
2348            32,
2349            "32-byte payload after 7-byte header strip"
2350        );
2351        assert!(audio.samples[0].iter().all(|b| *b == 0xCC));
2352        assert!(audio.samples[1].iter().all(|b| *b == 0xDD));
2353        assert_eq!(
2354            audio.durations,
2355            vec![1024, 1024],
2356            "AAC-LC frame duration = 1024 ticks @ sample-rate timescale"
2357        );
2358    }
2359
2360    #[test]
2361    fn demux_ts_emits_audio_none_when_no_aac_stream_in_pmt() {
2362        // The original two-stream test (video-only PMT). No audio expected.
2363        let mut buf = Vec::new();
2364        let mut pat = vec![0x00];
2365        let pat_section_len: usize = 5 + 4 + 4;
2366        pat.push(0xB0 | ((pat_section_len >> 8) & 0x0F) as u8);
2367        pat.push((pat_section_len & 0xFF) as u8);
2368        pat.extend_from_slice(&[0x00, 0x01, 0xC1, 0x00, 0x00]);
2369        pat.extend_from_slice(&[0x00, 0x01, 0xE1, 0x00, 0u8, 0u8, 0u8, 0u8]);
2370        let mut pat_payload = vec![0u8];
2371        pat_payload.extend_from_slice(&pat);
2372        buf.extend_from_slice(&ts_pkt(0x0000, true, 0b01, &pat_payload));
2373
2374        let mut pmt = vec![0x02];
2375        let pmt_section_len: usize = 9 + 5 + 4;
2376        pmt.push(0xB0 | ((pmt_section_len >> 8) & 0x0F) as u8);
2377        pmt.push((pmt_section_len & 0xFF) as u8);
2378        pmt.extend_from_slice(&[0x00, 0x01, 0xC1, 0x00, 0x00]);
2379        pmt.extend_from_slice(&[0xE2, 0x00, 0xF0, 0x00]);
2380        pmt.extend_from_slice(&[STREAM_TYPE_MPEG2_VIDEO, 0xE2, 0x00, 0xF0, 0x00]);
2381        pmt.extend_from_slice(&[0u8; 4]);
2382        let mut pmt_payload = vec![0u8];
2383        pmt_payload.extend_from_slice(&pmt);
2384        buf.extend_from_slice(&ts_pkt(0x0100, true, 0b01, &pmt_payload));
2385
2386        let video_pes = {
2387            let mut pes = vec![
2388                0u8, 0u8, 1u8, 0xE0, 0u8, 0u8, 0x80, 0x80, 5, 0x21, 0x00, 0x01, 0x00, 0x01,
2389            ];
2390            pes.extend_from_slice(&[0xAAu8; 16]);
2391            pes
2392        };
2393        buf.extend_from_slice(&ts_pkt(0x0200, true, 0b01, &video_pes));
2394        buf.extend_from_slice(&ts_pkt(0x1FFF, false, 0b01, &[]));
2395
2396        let d = demux_ts(&buf).expect("demux");
2397        assert!(
2398            d.audio.is_none(),
2399            "PMT without AAC-ADTS stream → no audio track surfaced"
2400        );
2401    }
2402
2403    // ---------------- Squad-37: AC-3 / E-AC-3 in TS, multi-program, encrypted ----------------
2404
2405    /// Build a minimal AC-3 syncframe by hand with a valid frmsizecod:
2406    /// fscod=0 (48k), bit_rate_code=8 (128 kbps) → frame_length = 384
2407    /// bytes per Table F.7. acmod=2 stereo, lfeon=0, bsid=8, bsmod=0.
2408    /// The body bytes after the BSI prefix are zero-padded — only the
2409    /// first ~7 bytes participate in our parser.
2410    fn synth_ac3_frame_stereo_48k_128k() -> Vec<u8> {
2411        let mut bw = BitWriter::new();
2412        bw.put(16, 0x0B77); // syncword
2413        bw.put(16, 0); // crc1
2414        bw.put(2, 0); // fscod=0 → 48k
2415        bw.put(6, 8 << 1); // frmsizecod = bit_rate_code(8) << 1 = 16
2416        bw.put(5, 8); // bsid
2417        bw.put(3, 0); // bsmod
2418        bw.put(3, 2); // acmod=2 stereo
2419        // acmod=2 → dsurmod (2 bits)
2420        bw.put(2, 0);
2421        bw.put(1, 0); // lfeon=0
2422        // Pad up to 384 bytes (the AC-3 frame size we just announced).
2423        while bw.bytes.len() < 384 {
2424            bw.put(8, 0);
2425        }
2426        bw.flush()
2427    }
2428
2429    /// E-AC-3 stereo frame with 6 audio blocks (numblkscod=3) at 48k.
2430    /// frmsiz chosen such that frame_size_bytes = 192 ((0x5F + 1) * 2).
2431    fn synth_eac3_frame_stereo_48k_192bytes() -> Vec<u8> {
2432        let mut bw = BitWriter::new();
2433        bw.put(16, 0x0B77);
2434        bw.put(2, 0); // strmtyp = 0 (independent)
2435        bw.put(3, 0); // substreamid
2436        bw.put(11, 0x5F); // frmsiz = 95 → frame_size = 192 bytes
2437        bw.put(2, 0); // fscod=0 → 48k
2438        bw.put(2, 3); // numblkscod=3 → 6 blocks
2439        bw.put(3, 2); // acmod=2 stereo
2440        bw.put(1, 0); // lfeon
2441        bw.put(5, 16); // bsid=16
2442        bw.put(5, 0); // dialnorm
2443        bw.put(1, 0); // compre=0
2444        while bw.bytes.len() < 192 {
2445            bw.put(8, 0);
2446        }
2447        bw.flush()
2448    }
2449
2450    /// Local copy of the BitWriter used by the existing AAC tests, kept
2451    /// alongside the Squad-37 sync-frame builders for self-containment.
2452    struct BitWriter {
2453        bytes: Vec<u8>,
2454        bit_pos: usize,
2455    }
2456    impl BitWriter {
2457        fn new() -> Self {
2458            Self {
2459                bytes: Vec::new(),
2460                bit_pos: 0,
2461            }
2462        }
2463        fn put(&mut self, n: usize, v: u32) {
2464            for i in (0..n).rev() {
2465                let bit = ((v >> i) & 0x01) as u8;
2466                if self.bit_pos % 8 == 0 {
2467                    self.bytes.push(0);
2468                }
2469                let byte_idx = self.bit_pos / 8;
2470                let bit_idx = 7 - (self.bit_pos % 8);
2471                self.bytes[byte_idx] |= bit << bit_idx;
2472                self.bit_pos += 1;
2473            }
2474        }
2475        fn flush(self) -> Vec<u8> {
2476            self.bytes
2477        }
2478    }
2479
2480    /// Build a continuation TS packet (PUSI=0) on `pid` with raw
2481    /// `payload` bytes. Used by `build_ts_with_audio` when an audio PES
2482    /// payload doesn't fit in a single 188-byte packet — the PES header
2483    /// rides on the PUSI=1 packet, and continuation packets carry the
2484    /// rest of the elementary-stream bytes verbatim until the next PUSI.
2485    fn ts_pkt_continuation(pid: u16, payload: &[u8]) -> [u8; TS_PACKET] {
2486        let mut p = [0xFFu8; TS_PACKET];
2487        p[0] = TS_SYNC;
2488        p[1] = ((pid >> 8) & 0x1F) as u8; // PUSI=0
2489        p[2] = (pid & 0xFF) as u8;
2490        p[3] = 0b01 << 4; // adaptation=01 (payload only), continuity=0
2491        let pay_len = payload.len().min(TS_PACKET - 4);
2492        p[4..4 + pay_len].copy_from_slice(&payload[..pay_len]);
2493        p
2494    }
2495
2496    /// Helper to build a TS file with: PAT, PMT, video PES (so the
2497    /// video gate doesn't bail), audio PES on `audio_pid` with a given
2498    /// `stream_type` byte and `descriptor_loop` for the PMT entry.
2499    /// `audio_es` is the elementary-stream payload (AC-3 frame, etc.)
2500    /// inserted into the audio PES packet body. If `audio_es` is too
2501    /// large to fit in a single TS packet's payload area (~184 bytes),
2502    /// the helper emits one PUSI=1 packet with the PES header + the
2503    /// first chunk and successive PUSI=0 continuation packets carrying
2504    /// the rest.
2505    fn build_ts_with_audio(
2506        audio_stream_type: u8,
2507        audio_descriptors: &[u8],
2508        audio_pid: u16,
2509        audio_es: &[u8],
2510    ) -> Vec<u8> {
2511        // PAT pointing at PMT 0x100.
2512        let mut pat = vec![0x00];
2513        let pat_section_len: usize = 5 + 4 + 4;
2514        pat.push(0xB0 | ((pat_section_len >> 8) & 0x0F) as u8);
2515        pat.push((pat_section_len & 0xFF) as u8);
2516        pat.extend_from_slice(&[0x00, 0x01, 0xC1, 0x00, 0x00]);
2517        pat.extend_from_slice(&[0x00, 0x01, 0xE1, 0x00, 0u8, 0u8, 0u8, 0u8]);
2518        let mut pat_payload = vec![0u8];
2519        pat_payload.extend_from_slice(&pat);
2520        let pat_pkt = ts_pkt(0x0000, true, 0b01, &pat_payload);
2521
2522        // PMT advertising MPEG-2 video on 0x200 + audio entry.
2523        let mut pmt = vec![0x02];
2524        let pmt_stream_entries = 5  // video stream entry
2525            + 5 + audio_descriptors.len(); // audio stream entry + descriptors
2526        let pmt_section_len: usize = 9 + pmt_stream_entries + 4;
2527        pmt.push(0xB0 | ((pmt_section_len >> 8) & 0x0F) as u8);
2528        pmt.push((pmt_section_len & 0xFF) as u8);
2529        pmt.extend_from_slice(&[0x00, 0x01, 0xC1, 0x00, 0x00]);
2530        pmt.extend_from_slice(&[0xE2, 0x00]); // PCR PID = 0x200
2531        pmt.extend_from_slice(&[0xF0, 0x00]); // program_info_length=0
2532        // Stream 1: MPEG-2 video on 0x200, no descriptors.
2533        pmt.extend_from_slice(&[STREAM_TYPE_MPEG2_VIDEO, 0xE2, 0x00, 0xF0, 0x00]);
2534        // Stream 2: audio_pid w/ given stream_type + descriptors.
2535        pmt.push(audio_stream_type);
2536        pmt.push(0xE0 | ((audio_pid >> 8) & 0x1F) as u8);
2537        pmt.push((audio_pid & 0xFF) as u8);
2538        let esi_len = audio_descriptors.len() as u16;
2539        pmt.push(0xF0 | ((esi_len >> 8) & 0x0F) as u8);
2540        pmt.push((esi_len & 0xFF) as u8);
2541        pmt.extend_from_slice(audio_descriptors);
2542        pmt.extend_from_slice(&[0u8; 4]); // CRC placeholder
2543        let mut pmt_payload = vec![0u8];
2544        pmt_payload.extend_from_slice(&pmt);
2545        let pmt_pkt = ts_pkt(0x0100, true, 0b01, &pmt_payload);
2546
2547        // Video PES (just enough so the video path doesn't bail).
2548        let video_pes = {
2549            let mut pes = vec![
2550                0u8, 0u8, 1u8, 0xE0, 0u8, 0u8, 0x80, 0x80, 5, 0x21, 0x00, 0x01, 0x00, 0x01,
2551            ];
2552            pes.extend_from_slice(&[0xAAu8; 16]);
2553            pes
2554        };
2555        let video_pkt = ts_pkt(0x0200, true, 0b01, &video_pes);
2556
2557        // Audio PES — a single PES packet carrying all of audio_es,
2558        // potentially split across multiple TS packets via continuation.
2559        // Stream_id 0xC0 is audio per ISO/IEC 13818-1 §2.4.3.7.
2560        // Note: for AC-3 / E-AC-3, ATSC A/53 PES uses stream_id 0xBD
2561        // (PES private) rather than 0xC0; our parse_pes_header_audio
2562        // accepts the 0xC0..=0xDF range so we use 0xC0 here for test
2563        // simplicity. In real-world bitstreams the parser would also
2564        // need 0xBD support — that's a separate uplift.
2565        let mut audio_pes = vec![
2566            0u8, 0u8, 1u8, 0xC0, 0u8, 0u8, 0x80, 0x80, 5, 0x21, 0x00, 0x01, 0x00, 0x01,
2567        ];
2568        audio_pes.extend_from_slice(audio_es);
2569
2570        // Split audio_pes across one PUSI=1 packet plus continuation
2571        // packets so PES payloads larger than 184 bytes flow through.
2572        let first_chunk_max = TS_PACKET - 4; // 184 bytes per TS packet payload
2573        let mut audio_pkts: Vec<[u8; TS_PACKET]> = Vec::new();
2574        let first_len = audio_pes.len().min(first_chunk_max);
2575        audio_pkts.push(ts_pkt(audio_pid, true, 0b01, &audio_pes[..first_len]));
2576        let mut cursor = first_len;
2577        while cursor < audio_pes.len() {
2578            let end = (cursor + first_chunk_max).min(audio_pes.len());
2579            audio_pkts.push(ts_pkt_continuation(audio_pid, &audio_pes[cursor..end]));
2580            cursor = end;
2581        }
2582
2583        let mut buf = Vec::new();
2584        buf.extend_from_slice(&pat_pkt);
2585        buf.extend_from_slice(&pmt_pkt);
2586        buf.extend_from_slice(&video_pkt);
2587        for pkt in &audio_pkts {
2588            buf.extend_from_slice(pkt);
2589        }
2590        buf.extend_from_slice(&ts_pkt(0x1FFF, false, 0b01, &[]));
2591        buf
2592    }
2593
2594    #[test]
2595    fn pmt_walker_classifies_aac_ac3_eac3_stream_types() {
2596        // Build a synthetic PMT section with one of each audio
2597        // stream_type and verify the walker tags them correctly.
2598        let mut pmt = vec![0x02];
2599        let stream_entries = 5 + 5 + 5 + 5; // video + AAC + AC-3 + E-AC-3
2600        let pmt_section_len: usize = 9 + stream_entries + 4;
2601        pmt.push(0xB0 | ((pmt_section_len >> 8) & 0x0F) as u8);
2602        pmt.push((pmt_section_len & 0xFF) as u8);
2603        pmt.extend_from_slice(&[0x00, 0x01, 0xC1, 0x00, 0x00]);
2604        pmt.extend_from_slice(&[0xE2, 0x00, 0xF0, 0x00]); // PCR + pil=0
2605        pmt.extend_from_slice(&[STREAM_TYPE_MPEG2_VIDEO, 0xE2, 0x00, 0xF0, 0x00]);
2606        pmt.extend_from_slice(&[STREAM_TYPE_AAC_ADTS, 0xE3, 0x00, 0xF0, 0x00]);
2607        pmt.extend_from_slice(&[STREAM_TYPE_AC3, 0xE4, 0x00, 0xF0, 0x00]);
2608        pmt.extend_from_slice(&[STREAM_TYPE_EAC3, 0xE5, 0x00, 0xF0, 0x00]);
2609        pmt.extend_from_slice(&[0u8; 4]);
2610
2611        let (video, audio) = parse_pmt_streams(&pmt).expect("parse");
2612        assert_eq!(video.len(), 1);
2613        assert_eq!(video[0].pid, 0x200);
2614        assert_eq!(audio.len(), 3);
2615        assert_eq!(
2616            (audio[0].pid, audio[0].kind),
2617            (0x300, AudioCodecKind::AacAdts)
2618        );
2619        assert_eq!((audio[1].pid, audio[1].kind), (0x400, AudioCodecKind::Ac3));
2620        assert_eq!((audio[2].pid, audio[2].kind), (0x500, AudioCodecKind::Eac3));
2621    }
2622
2623    #[test]
2624    fn pmt_walker_recognises_dvb_ac3_via_registration_descriptor() {
2625        // PES private (0x06) with a registration_descriptor whose 4-char
2626        // identifier is "AC-3" → audio routed as AC-3 per ETSI TS 101 154.
2627        let mut pmt = vec![0x02];
2628        let descriptors: [u8; 6] = [DESC_TAG_REGISTRATION, 4, b'A', b'C', b'-', b'3'];
2629        let stream_entries = 5 + 5 + descriptors.len();
2630        let pmt_section_len: usize = 9 + stream_entries + 4;
2631        pmt.push(0xB0 | ((pmt_section_len >> 8) & 0x0F) as u8);
2632        pmt.push((pmt_section_len & 0xFF) as u8);
2633        pmt.extend_from_slice(&[0x00, 0x01, 0xC1, 0x00, 0x00]);
2634        pmt.extend_from_slice(&[0xE2, 0x00, 0xF0, 0x00]);
2635        pmt.extend_from_slice(&[STREAM_TYPE_MPEG2_VIDEO, 0xE2, 0x00, 0xF0, 0x00]);
2636        pmt.push(STREAM_TYPE_PES_PRIVATE);
2637        pmt.extend_from_slice(&[0xE3, 0x00]);
2638        let esi_len = descriptors.len() as u16;
2639        pmt.push(0xF0 | ((esi_len >> 8) & 0x0F) as u8);
2640        pmt.push((esi_len & 0xFF) as u8);
2641        pmt.extend_from_slice(&descriptors);
2642        pmt.extend_from_slice(&[0u8; 4]);
2643
2644        let (_, audio) = parse_pmt_streams(&pmt).expect("parse");
2645        assert_eq!(audio.len(), 1);
2646        assert_eq!(audio[0].kind, AudioCodecKind::Ac3);
2647        assert_eq!(audio[0].stream_type, STREAM_TYPE_PES_PRIVATE);
2648    }
2649
2650    #[test]
2651    fn pmt_walker_recognises_dvb_eac3_via_registration_descriptor() {
2652        let mut pmt = vec![0x02];
2653        let descriptors: [u8; 6] = [DESC_TAG_REGISTRATION, 4, b'E', b'A', b'C', b'3'];
2654        let stream_entries = 5 + 5 + descriptors.len();
2655        let pmt_section_len: usize = 9 + stream_entries + 4;
2656        pmt.push(0xB0 | ((pmt_section_len >> 8) & 0x0F) as u8);
2657        pmt.push((pmt_section_len & 0xFF) as u8);
2658        pmt.extend_from_slice(&[0x00, 0x01, 0xC1, 0x00, 0x00]);
2659        pmt.extend_from_slice(&[0xE2, 0x00, 0xF0, 0x00]);
2660        pmt.extend_from_slice(&[STREAM_TYPE_MPEG2_VIDEO, 0xE2, 0x00, 0xF0, 0x00]);
2661        pmt.push(STREAM_TYPE_PES_PRIVATE);
2662        pmt.extend_from_slice(&[0xE3, 0x00]);
2663        let esi_len = descriptors.len() as u16;
2664        pmt.push(0xF0 | ((esi_len >> 8) & 0x0F) as u8);
2665        pmt.push((esi_len & 0xFF) as u8);
2666        pmt.extend_from_slice(&descriptors);
2667        pmt.extend_from_slice(&[0u8; 4]);
2668
2669        let (_, audio) = parse_pmt_streams(&pmt).expect("parse");
2670        assert_eq!(audio.len(), 1);
2671        assert_eq!(audio[0].kind, AudioCodecKind::Eac3);
2672    }
2673
2674    #[test]
2675    fn extract_ac3_frames_from_synthetic_ts_yields_passthrough_track() {
2676        // stream_type 0x81, no descriptors needed.
2677        let frame = synth_ac3_frame_stereo_48k_128k();
2678        // Concatenate two frames so the frame loop runs more than once.
2679        let mut es = frame.clone();
2680        es.extend_from_slice(&frame);
2681        let buf = build_ts_with_audio(STREAM_TYPE_AC3, &[], 0x300, &es);
2682
2683        let d = demux_ts(&buf).expect("demux");
2684        let audio = d.audio.expect("AC-3 audio surfaced");
2685        assert_eq!(audio.codec, "ac3");
2686        assert_eq!(audio.channels, 2);
2687        assert_eq!(audio.sample_rate, 48_000);
2688        assert_eq!(audio.timescale, 48_000);
2689        // dac3 body is the 3-byte payload that goes into the MP4 sample
2690        // entry verbatim — derived from the first sync header.
2691        assert_eq!(audio.codec_private.len(), 3);
2692        // Two frames in, two samples out (raw frame bytes, sync word
2693        // intact).
2694        assert!(
2695            audio.samples.len() >= 1,
2696            "at least one AC-3 frame extracted"
2697        );
2698        assert_eq!(
2699            &audio.samples[0][..2],
2700            &[0x0B, 0x77],
2701            "AC-3 frame begins with 0x0B77 sync word verbatim"
2702        );
2703        // Each AC-3 frame is 1536 samples per spec.
2704        assert!(
2705            audio.durations.iter().all(|&d| d == 1536),
2706            "AC-3 frames are 1536 samples each"
2707        );
2708    }
2709
2710    #[test]
2711    fn extract_eac3_frames_from_synthetic_ts_yields_passthrough_track() {
2712        let frame = synth_eac3_frame_stereo_48k_192bytes();
2713        let mut es = frame.clone();
2714        es.extend_from_slice(&frame);
2715        let buf = build_ts_with_audio(STREAM_TYPE_EAC3, &[], 0x300, &es);
2716
2717        let d = demux_ts(&buf).expect("demux");
2718        let audio = d.audio.expect("E-AC-3 audio surfaced");
2719        assert_eq!(audio.codec, "eac3");
2720        assert_eq!(audio.channels, 2);
2721        assert_eq!(audio.sample_rate, 48_000);
2722        // dec3 single-substream body is 5 bytes per ETSI TS 102 366 §F.6.
2723        assert_eq!(audio.codec_private.len(), 5);
2724        assert!(!audio.samples.is_empty());
2725        assert_eq!(
2726            &audio.samples[0][..2],
2727            &[0x0B, 0x77],
2728            "E-AC-3 frame begins with 0x0B77 sync word verbatim"
2729        );
2730        // numblkscod=3 → 1536 samples/frame.
2731        assert!(audio.durations.iter().all(|&d| d == 1536));
2732    }
2733
2734    #[test]
2735    fn extract_ac3_via_pes_private_with_dvb_registration() {
2736        // stream_type 0x06 + registration "AC-3" must route through the
2737        // AC-3 extractor end-to-end.
2738        let frame = synth_ac3_frame_stereo_48k_128k();
2739        let descriptors: [u8; 6] = [DESC_TAG_REGISTRATION, 4, b'A', b'C', b'-', b'3'];
2740        let buf = build_ts_with_audio(STREAM_TYPE_PES_PRIVATE, &descriptors, 0x300, &frame);
2741        let d = demux_ts(&buf).expect("demux");
2742        let audio = d.audio.expect("AC-3 audio via DVB registration surfaced");
2743        assert_eq!(audio.codec, "ac3");
2744        assert_eq!(&audio.samples[0][..2], &[0x0B, 0x77]);
2745    }
2746
2747    #[test]
2748    fn dac3_body_synthesized_from_first_ts_frame_matches_sync_header() {
2749        // The dac3 body the TS extractor produces must equal the body
2750        // we'd compute by parsing the same first frame independently —
2751        // proves the AC-3 path is using the canonical Squad-26 helper
2752        // rather than a parallel implementation.
2753        let frame = synth_ac3_frame_stereo_48k_128k();
2754        let buf = build_ts_with_audio(STREAM_TYPE_AC3, &[], 0x300, &frame);
2755        let d = demux_ts(&buf).expect("demux");
2756        let audio = d.audio.expect("AC-3 audio");
2757        let parsed = match crate::ac3_sync::parse_sync_info(&frame).unwrap() {
2758            crate::ac3_sync::SyncInfo::Ac3(s) => s,
2759            _ => panic!("expected AC-3"),
2760        };
2761        let expected = crate::mux::dac3_body_from_sync(&parsed);
2762        assert_eq!(
2763            audio.codec_private,
2764            expected.to_vec(),
2765            "TS-extracted dac3 must match the canonical helper"
2766        );
2767    }
2768
2769    /// Build a TS file with two distinct programs (program_number 1 and
2770    /// 2). Program 1 carries MPEG-2 video on 0x200; program 2 carries
2771    /// H.264 video on 0x300. Both PMTs live in their own PIDs (0x100,
2772    /// 0x101 respectively).
2773    fn build_two_program_ts() -> Vec<u8> {
2774        // PAT with TWO program entries.
2775        let mut pat = vec![0x00];
2776        let pat_section_len: usize = 5 + 4 + 4 + 4; // 2 programs + CRC
2777        pat.push(0xB0 | ((pat_section_len >> 8) & 0x0F) as u8);
2778        pat.push((pat_section_len & 0xFF) as u8);
2779        pat.extend_from_slice(&[0x00, 0x01, 0xC1, 0x00, 0x00]);
2780        pat.extend_from_slice(&[0x00, 0x01, 0xE1, 0x00]); // program 1 → PMT 0x100
2781        pat.extend_from_slice(&[0x00, 0x02, 0xE1, 0x01]); // program 2 → PMT 0x101
2782        pat.extend_from_slice(&[0u8; 4]);
2783        let mut pat_payload = vec![0u8];
2784        pat_payload.extend_from_slice(&pat);
2785        let pat_pkt = ts_pkt(0x0000, true, 0b01, &pat_payload);
2786
2787        // PMT 1: MPEG-2 video on 0x200.
2788        let mut pmt1 = vec![0x02];
2789        let pmt1_section_len: usize = 9 + 5 + 4;
2790        pmt1.push(0xB0 | ((pmt1_section_len >> 8) & 0x0F) as u8);
2791        pmt1.push((pmt1_section_len & 0xFF) as u8);
2792        pmt1.extend_from_slice(&[0x00, 0x01, 0xC1, 0x00, 0x00]); // program 1
2793        pmt1.extend_from_slice(&[0xE2, 0x00, 0xF0, 0x00]);
2794        pmt1.extend_from_slice(&[STREAM_TYPE_MPEG2_VIDEO, 0xE2, 0x00, 0xF0, 0x00]);
2795        pmt1.extend_from_slice(&[0u8; 4]);
2796        let mut pmt1_payload = vec![0u8];
2797        pmt1_payload.extend_from_slice(&pmt1);
2798        let pmt1_pkt = ts_pkt(0x0100, true, 0b01, &pmt1_payload);
2799
2800        // PMT 2: H.264 video on 0x300.
2801        let mut pmt2 = vec![0x02];
2802        let pmt2_section_len: usize = 9 + 5 + 4;
2803        pmt2.push(0xB0 | ((pmt2_section_len >> 8) & 0x0F) as u8);
2804        pmt2.push((pmt2_section_len & 0xFF) as u8);
2805        pmt2.extend_from_slice(&[0x00, 0x02, 0xC1, 0x00, 0x00]); // program 2
2806        pmt2.extend_from_slice(&[0xE3, 0x00, 0xF0, 0x00]);
2807        pmt2.extend_from_slice(&[STREAM_TYPE_H264, 0xE3, 0x00, 0xF0, 0x00]);
2808        pmt2.extend_from_slice(&[0u8; 4]);
2809        let mut pmt2_payload = vec![0u8];
2810        pmt2_payload.extend_from_slice(&pmt2);
2811        let pmt2_pkt = ts_pkt(0x0101, true, 0b01, &pmt2_payload);
2812
2813        // Distinct PES bytes so we can tell programs apart at sample
2814        // level. Program 1 → 0xAA; program 2 → 0xBB.
2815        let make_pes = |fill: u8| {
2816            let mut pes = vec![
2817                0u8, 0u8, 1u8, 0xE0, 0u8, 0u8, 0x80, 0x80, 5, 0x21, 0x00, 0x01, 0x00, 0x01,
2818            ];
2819            pes.extend_from_slice(&[fill; 16]);
2820            pes
2821        };
2822        let p1_pes = ts_pkt(0x0200, true, 0b01, &make_pes(0xAA));
2823        let p2_pes = ts_pkt(0x0300, true, 0b01, &make_pes(0xBB));
2824
2825        let mut buf = Vec::new();
2826        buf.extend_from_slice(&pat_pkt);
2827        buf.extend_from_slice(&pmt1_pkt);
2828        buf.extend_from_slice(&pmt2_pkt);
2829        // Two PES per program so the streaming path's PUSI flush yields.
2830        buf.extend_from_slice(&p1_pes);
2831        buf.extend_from_slice(&p2_pes);
2832        buf.extend_from_slice(&ts_pkt(0x0200, true, 0b01, &make_pes(0xAA)));
2833        buf.extend_from_slice(&ts_pkt(0x0300, true, 0b01, &make_pes(0xBB)));
2834        buf.extend_from_slice(&ts_pkt(0x1FFF, false, 0b01, &[]));
2835        buf
2836    }
2837
2838    #[test]
2839    fn streaming_demuxer_lists_all_pat_programs() {
2840        let buf = build_two_program_ts();
2841        let dem = demux_ts_streaming_init(&buf).expect("init");
2842        let progs = dem.programs();
2843        assert_eq!(progs.len(), 2, "PAT advertised 2 programs");
2844        let nums: Vec<u16> = progs.iter().map(|p| p.program_number).collect();
2845        assert_eq!(nums, vec![1, 2]);
2846        assert_eq!(progs[0].pmt_pid, 0x100);
2847        assert_eq!(progs[1].pmt_pid, 0x101);
2848        // Program 1 → MPEG-2 on 0x200; program 2 → H.264 on 0x300.
2849        assert_eq!(progs[0].video_streams[0].pid, 0x200);
2850        assert_eq!(
2851            progs[0].video_streams[0].stream_type,
2852            STREAM_TYPE_MPEG2_VIDEO
2853        );
2854        assert_eq!(progs[1].video_streams[0].pid, 0x300);
2855        assert_eq!(progs[1].video_streams[0].stream_type, STREAM_TYPE_H264);
2856    }
2857
2858    #[test]
2859    fn streaming_demuxer_default_picks_first_program() {
2860        let buf = build_two_program_ts();
2861        let mut dem = demux_ts_streaming_init(&buf).expect("init");
2862        assert_eq!(dem.active_program_index(), 0);
2863        assert_eq!(dem.header().codec, "mpeg2", "program 1 is MPEG-2");
2864        // Drain — samples should be 0xAA-filled (program 1's bytes).
2865        let s = dem.next_video_sample().expect("sample").expect("some");
2866        assert!(
2867            s.data.iter().any(|&b| b == 0xAA),
2868            "program 1 sample should carry 0xAA"
2869        );
2870        assert!(
2871            !s.data.iter().any(|&b| b == 0xBB),
2872            "program 1 sample must not carry program 2's 0xBB"
2873        );
2874    }
2875
2876    #[test]
2877    fn streaming_demuxer_select_program_switches_active_streams() {
2878        let buf = build_two_program_ts();
2879        let mut dem = demux_ts_streaming_init(&buf).expect("init");
2880        dem.select_program(2).expect("switch to program 2");
2881        assert_eq!(dem.active_program_index(), 1);
2882        assert_eq!(dem.header().codec, "h264", "program 2 is H.264");
2883        let s = dem.next_video_sample().expect("sample").expect("some");
2884        assert!(
2885            s.data.iter().any(|&b| b == 0xBB),
2886            "program 2 sample should carry 0xBB"
2887        );
2888        assert!(
2889            !s.data.iter().any(|&b| b == 0xAA),
2890            "program 2 sample must not carry program 1's 0xAA"
2891        );
2892    }
2893
2894    #[test]
2895    fn streaming_demuxer_select_program_rejects_unknown_number() {
2896        let buf = build_two_program_ts();
2897        let mut dem = demux_ts_streaming_init(&buf).expect("init");
2898        assert!(
2899            dem.select_program(99).is_err(),
2900            "unknown program_number must error rather than silently no-op"
2901        );
2902    }
2903
2904    /// Build a single-program TS where the video PID's packets carry
2905    /// `transport_scrambling_control != 0` (TSC=01 = "user-defined,
2906    /// reserved" in ISO/IEC 13818-1 — both this and 10/11 indicate the
2907    /// payload is encrypted and we have no CA tables).
2908    fn build_encrypted_ts() -> Vec<u8> {
2909        // Reuse the single-program PAT/PMT shape from the existing tests.
2910        let mut pat = vec![0x00];
2911        let pat_section_len: usize = 5 + 4 + 4;
2912        pat.push(0xB0 | ((pat_section_len >> 8) & 0x0F) as u8);
2913        pat.push((pat_section_len & 0xFF) as u8);
2914        pat.extend_from_slice(&[0x00, 0x01, 0xC1, 0x00, 0x00]);
2915        pat.extend_from_slice(&[0x00, 0x01, 0xE1, 0x00, 0u8, 0u8, 0u8, 0u8]);
2916        let mut pat_payload = vec![0u8];
2917        pat_payload.extend_from_slice(&pat);
2918        let pat_pkt = ts_pkt(0x0000, true, 0b01, &pat_payload);
2919
2920        let mut pmt = vec![0x02];
2921        let pmt_section_len: usize = 9 + 5 + 4;
2922        pmt.push(0xB0 | ((pmt_section_len >> 8) & 0x0F) as u8);
2923        pmt.push((pmt_section_len & 0xFF) as u8);
2924        pmt.extend_from_slice(&[0x00, 0x01, 0xC1, 0x00, 0x00]);
2925        pmt.extend_from_slice(&[0xE2, 0x00, 0xF0, 0x00]);
2926        pmt.extend_from_slice(&[STREAM_TYPE_MPEG2_VIDEO, 0xE2, 0x00, 0xF0, 0x00]);
2927        pmt.extend_from_slice(&[0u8; 4]);
2928        let mut pmt_payload = vec![0u8];
2929        pmt_payload.extend_from_slice(&pmt);
2930        let pmt_pkt = ts_pkt(0x0100, true, 0b01, &pmt_payload);
2931
2932        // Encrypted video PES: build the packet as normal but flip
2933        // bits 6-7 of byte 3 to TSC=01.
2934        let video_pes = {
2935            let mut pes = vec![
2936                0u8, 0u8, 1u8, 0xE0, 0u8, 0u8, 0x80, 0x80, 5, 0x21, 0x00, 0x01, 0x00, 0x01,
2937            ];
2938            pes.extend_from_slice(&[0xAAu8; 16]);
2939            pes
2940        };
2941        let mut video_pkt = ts_pkt(0x0200, true, 0b01, &video_pes);
2942        // TSC = 01 (single-bit set in the top 2 bits of byte 3).
2943        video_pkt[3] = (video_pkt[3] & 0x3F) | (0x01 << 6);
2944
2945        let mut buf = Vec::new();
2946        buf.extend_from_slice(&pat_pkt);
2947        buf.extend_from_slice(&pmt_pkt);
2948        buf.extend_from_slice(&video_pkt);
2949        buf.extend_from_slice(&ts_pkt(0x1FFF, false, 0b01, &[]));
2950        buf
2951    }
2952
2953    #[test]
2954    fn streaming_demuxer_drops_video_when_active_pid_is_scrambled() {
2955        let buf = build_encrypted_ts();
2956        let mut dem = demux_ts_streaming_init(&buf).expect("init");
2957        // First call should hit the encrypted packet, latch the guard,
2958        // and return None. No samples should ever surface.
2959        let s = dem.next_video_sample().expect("call must not error");
2960        assert!(
2961            s.is_none(),
2962            "encrypted TS → next_video_sample returns None on first call"
2963        );
2964        // Subsequent calls remain None — the guard latches.
2965        let s2 = dem.next_video_sample().expect("call must not error");
2966        assert!(
2967            s2.is_none(),
2968            "encrypted TS → guard remains latched on subsequent calls"
2969        );
2970    }
2971}