From b4bf0daa82500846b11cbc80d8d7c3d483c0e81e Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:26:51 -0700 Subject: [PATCH] Group E-AC-3 dependent substreams into one access unit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AC-3 parser's own module doc stated the assumption: "AC3 frames are self-contained and always start with syncword 0x0B77". True for legacy AC-3, false for E-AC-3 above 5.1. Per ETSI TS 102 366 (A/52) Annex E, byte 2 of an E-AC-3 syncframe is strmtyp(2) | substreamid(3) | frmsiz[10:8], and an access unit is one INDEPENDENT substream plus every DEPENDENT substream that follows it until the next independent one. The parser emitted one PES frame per syncframe, so a decoder saw each dependent substream as a standalone frame with no parent — including the AC-3-core + E-AC-3-dependent form Blu-ray uses for Dolby Digital Plus. The extra channels were lost and the timeline ran at 2x. The bit position is cross-checked against code already in the tree: the existing frmsiz parse takes byte2 & 0x07 as its high bits, which is only consistent with strmtyp occupying byte2's top two bits. Legacy AC-3 is excluded by bsid < 11, where byte 2 is crc1 and reading strmtyp there would be nonsense. Reserved strmtyp 3 is treated as INDEPENDENT so an unknown type starts a fresh AU rather than merging into an unrelated one. The AU carries the INDEPENDENT substream's PTS, and only the independent substream advances the clock — dependents cover the same time period and add zero duration. That is what removes the doubled timeline. A trailing AU that can still grow is HELD across the PES boundary, because the boundary is unknowable until the next independent sync; the whole AU is re-scanned next call, so there is no shift and no double-count in the loss tally. Plain AC-3 is never held, which keeps DVD/AC-3 latency and behaviour unchanged. A latent pre-existing bug surfaced while testing this: a new PES's PTS was re-stamping an AU that began in an earlier PES, a constant one-frame shift. Fixed with a PtsAnchor so a PES timestamp applies to the first AU that STARTS in that PES's own bytes, while a genuine PTS jump is still adopted. Nine tests. Verified red against five mutations, each killing a specific set: reverting to the pre-fix behaviour kills 8 while plain_ac3_frames_are_not_grouped_or_delayed SURVIVES as the no-regression guard — reproduced independently here. Stamping the dependent's PTS kills 6; not holding across PES kills 4; holding plain AC-3 too kills 15; neutering the PTS anchor kills exactly the 2 split-across-PES timing tests. Three sibling defects found and deliberately NOT fixed, all in mp4/audio.rs: dec3 hardcodes num_dep_sub = 0 (and a nonzero value changes the box LAYOUT, not just a field, per Annex F/G); parse_eac3 ignores strmtyp/substreamid entirely; and a 7.1 DD+ track is still labelled 5.1 because the channel count comes from the independent substream while the extra channels are described by the dependent's chanmap, which nothing parses. Not verified: no real E-AC-3-with-dependents sample exists here, so all evidence is synthetic frames plus the spec layout. The multi-independent-substream case (num_ind_sub > 1, main + associated audio in one PID) is deliberately treated as one AU per independent substream and is untested. --- src/mux/codec/ac3.rs | 749 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 597 insertions(+), 152 deletions(-) diff --git a/src/mux/codec/ac3.rs b/src/mux/codec/ac3.rs index 879a7ca..0389b67 100644 --- a/src/mux/codec/ac3.rs +++ b/src/mux/codec/ac3.rs @@ -1,8 +1,17 @@ //! AC3 (Dolby Digital) / EAC3 (Dolby Digital Plus) frame parser. //! -//! AC3 frames are self-contained and always start with syncword 0x0B77. -//! Buffers across PES boundaries so frames that span two PES packets -//! are emitted complete, not truncated. +//! Every (E-)AC-3 syncframe starts with syncword 0x0B77. A legacy AC-3 +//! syncframe is a complete access unit on its own, but an E-AC-3 access unit +//! (ETSI TS 102 366 / ATSC A/52 Annex E) is an INDEPENDENT substream frame +//! plus every DEPENDENT substream frame that follows it — a decoder needs the +//! whole set to reconstruct the programme (a 5.1 independent substream plus a +//! dependent substream carrying the extra channels of a 7.1 programme, and the +//! AC-3-core + E-AC-3-dependent arrangement used for backwards-compatible +//! Dolby Digital Plus). This parser therefore groups syncframes into access +//! units by `strmtyp` rather than emitting one frame per syncframe. +//! +//! Buffers across PES boundaries so access units that span two PES packets +//! are emitted complete, not truncated or split. use super::{CodecParser, Frame, PesPacket, pts_to_ns}; @@ -26,13 +35,14 @@ const MIN_FRAME_BYTES: usize = 6; /// AC-3 (legacy) always carries 6 audio blocks × 256 samples = 1536 samples. const AC3_SAMPLES_PER_FRAME: u32 = 1536; -/// Hard cap on the carry-over buffer. An AC-3/E-AC-3 frame is at most 8192 -/// bytes (the `frame_size > 8192` reject below), so a single straddling frame -/// plus a little slack never needs more than this. If the buffer grows past -/// the cap without yielding a frame (pathological / never-syncing input) we -/// drop it and resync rather than accumulate one PES worth of data per call -/// for the whole title. -const MAX_AC3_BUF: usize = 64 * 1024; +/// Hard cap on the carry-over buffer. An AC-3/E-AC-3 syncframe is at most 8192 +/// bytes (the `frame_size > 8192` reject below) and an access unit is at most one +/// independent substream plus 8 dependent substreams (ETSI TS 102 366 Annex E), +/// so a single straddling access unit plus slack never needs more than this. If +/// the buffer grows past the cap without yielding a frame (pathological / +/// never-syncing input) we drop it and resync rather than accumulate one PES +/// worth of data per call for the whole title. +const MAX_AC3_BUF: usize = 128 * 1024; pub struct Ac3Parser { /// Leftover bytes from previous PES (incomplete frame at end). @@ -46,6 +56,13 @@ pub struct Ac3Parser { /// the running PTS is advanced across it (see the emit loop) so the drop is a /// silence gap, never a shift of the following audio. tally: super::dropgate::DropTally, + /// Set once a dependent substream (E-AC-3 `strmtyp` == 1) has been seen on + /// this track. Until then a trailing LEGACY AC-3 syncframe is closed and + /// emitted in-call (a plain AC-3 / DVD track has no substreams at all, so + /// holding it back would only add latency); afterwards it is held open + /// across the PES boundary because it may be the core of an AC-3-core + + /// E-AC-3-dependent access unit whose dependent half is in the next PES. + saw_dependent: bool, } impl Default for Ac3Parser { @@ -60,6 +77,7 @@ impl Ac3Parser { buf: Vec::with_capacity(4096), flush_pts_ns: 0, tally: super::dropgate::DropTally::new("ac3"), + saw_dependent: false, } } @@ -73,49 +91,246 @@ impl Ac3Parser { self.tally.dropped_duration_ns() } - /// Emit the final buffered frame at EOS, through the decodability gate. - /// During streaming a final frame may sit in `buf` with no following PES to - /// complete it; without this drain the last ~32 ms of audio is lost. Only a - /// fully-sized frame at a syncword is considered; a partial/garbage tail is - /// discarded, and a corrupt (CRC-failing) final frame is dropped. - fn flush_tail(&mut self) -> Vec { - let buf = std::mem::take(&mut self.buf); - let Some(off) = find_ac3_sync(&buf) else { - return Vec::new(); + /// Scan `data` for (E-)AC-3 syncframes and group them into access units. + /// + /// Per ETSI TS 102 366 (ATSC A/52) Annex E an access unit is one INDEPENDENT + /// substream frame plus every DEPENDENT substream frame that follows it, up + /// to the next independent substream. The access unit therefore closes only + /// when the NEXT independent substream (or, with `at_eos`, the end of the + /// stream) is seen, and it carries the INDEPENDENT substream's PTS and + /// duration: dependent substreams describe the same time period and add no + /// duration of their own. + /// + /// `base_pts_ns` times the access unit that begins at `data[0]` — i.e. the + /// running cadence carried over from the previous call. `anchor` re-anchors + /// the running PTS to this PES's own timestamp at the first access unit that + /// STARTS in the newly-appended bytes: a PES timestamp applies to the first + /// access unit beginning in that PES, never to one that began in an earlier + /// PES and is only being completed (or was held) here. + /// + /// Returns the emitted access units, the offset in `data` from which bytes + /// must be carried over to the next call, and the PTS to stamp on the access + /// unit that begins that carry-over. + fn scan_access_units( + &mut self, + data: &[u8], + base_pts_ns: i64, + anchor: Option, + at_eos: bool, + ) -> (Vec, usize, i64) { + let mut frames = Vec::new(); + let mut pos = 0usize; + // Running PTS for the next access unit to emit in this call. + let mut frame_pts_ns = base_pts_ns; + let mut anchor = anchor; + let mut pending: Option = None; + + while pos < data.len() { + let sync = find_ac3_sync(&data[pos..]); + let start = match sync { + Some(offset) => pos + offset, + None => break, + }; + + let remaining = &data[start..]; + + if remaining.len() < 6 { + // Not enough data to determine frame size — keep for next PES + break; + } + + let bsid = get_bsid(remaining); + let frame_size = if bsid >= 11 { + eac3_frame_size(remaining) + } else { + ac3_frame_size(remaining) + }; + + if !(MIN_FRAME_BYTES..=8192).contains(&frame_size) { + // Invalid/sub-header frame size (e.g. an E-AC-3 frmsiz of 0/1 + // sizing to a 2/4-byte fragment) — skip this sync word. + pos = start + 2; + continue; + } + + if start + frame_size > data.len() { + // Incomplete frame — keep for next PES + break; + } + + let frame = &data[start..start + frame_size]; + // Decodability gate: a syncframe with an out-of-range bsid (> 16) or + // a failed native CRC (payload corruption) poisons the access unit it + // belongs to — a dependent substream is useless without its parent and + // vice versa, so the whole access unit is dropped as one silence gap. + let reason = ac3_drop_reason(&self.tally, frame, bsid); + + if is_dependent_substream(remaining, bsid) { + match pending.as_mut() { + // A dependent substream extends the access unit it directly + // follows. Requiring byte contiguity keeps skipped junk out of + // the emitted access unit. + Some(au) if au.end == start => { + au.end = start + frame_size; + if au.drop_reason.is_none() { + au.drop_reason = reason; + } + self.saw_dependent = true; + } + // A dependent substream with no independent parent (a mid-AU + // resync, or a stream that starts inside an access unit) is not + // decodable on its own; skip it rather than ship a frame a + // decoder cannot use. No PTS advance: it carries no duration. + _ => { + tracing::debug!( + target: "mux", + "ac3: dependent substream with no independent parent; skipped" + ); + } + } + } else { + if let Some(au) = pending.take() { + close_access_unit(&mut self.tally, data, &au, &mut frames); + } + // First access unit that starts in this PES's own bytes: adopt + // this PES's timestamp so a genuine PTS jump is followed instead + // of the running cadence drifting past it. + if let Some(a) = &anchor { + if start >= a.at { + frame_pts_ns = a.pts_ns; + anchor = None; + } + } + let duration_ns = frame_duration_ns(remaining, bsid); + pending = Some(PendingAu { + start, + end: start + frame_size, + pts_ns: frame_pts_ns, + duration_ns, + drop_reason: reason, + bsid, + }); + // Only the independent substream advances the timeline. + frame_pts_ns += duration_ns as i64; + } + + pos = start + frame_size; + } + + // Close or HOLD the trailing access unit. Its dependent substreams may + // still be in the next PES, so an access unit that can still grow is + // held: the carry-over rewinds to its first byte and the whole access + // unit is re-scanned (and only then counted/emitted) next call. An + // E-AC-3 independent substream can always gain dependents; a legacy + // AC-3 syncframe only in the AC-3-core + E-AC-3-dependent arrangement, + // so it is held only once this track has actually shown a dependent + // substream — a plain AC-3 track keeps emitting every frame in-call. + let mut hold_from = None; + if let Some(au) = pending { + if !at_eos && (au.bsid >= 11 || self.saw_dependent) { + frame_pts_ns = au.pts_ns; + hold_from = Some(au.start); + } else { + close_access_unit(&mut self.tally, data, &au, &mut frames); + } + } + + // Keep unconsumed data for the next call. `pos` is the start of the + // last unprocessed search region. On the `start + frame_size > len` + // break it sits exactly at the straddling frame's syncword; on the + // `remaining.len() < 6` break it is the value from the top of that + // iteration, with the syncword possibly sitting after some pre-sync + // junk — so the re-scan below (from `pos`, NOT a recomputed sync) is + // required to locate the carry-over syncword. Carry from `pos`, NOT + // from the next syncword: discarding bytes between `pos` and the next + // sync would drop the partial frame we are deliberately keeping across + // the boundary. A held access unit wins: it starts before `pos`. + let keep_from = match hold_from { + Some(h) => h, + None if pos < data.len() => { + // A syncword at/after `pos` marks the carry-over start (anything + // before it is junk with no sync). With no full sync, retain the + // whole tail — including a lone trailing 0x0B that may be the first + // half of a syncword split across the PES boundary. + match find_ac3_sync(&data[pos..]) { + Some(o) => pos + o, + None if data.last() == Some(&0x0B) => data.len() - 1, + None => data.len(), + } + } + None => data.len(), }; - let frame_all = &buf[off..]; - if frame_all.len() < 6 { - return Vec::new(); - } - let bsid = get_bsid(frame_all); - let frame_size = if bsid >= 11 { - eac3_frame_size(frame_all) - } else { - ac3_frame_size(frame_all) - }; - if !(MIN_FRAME_BYTES..=8192).contains(&frame_size) || off + frame_size > buf.len() { - return Vec::new(); - } - let frame = &buf[off..off + frame_size]; - let duration_ns = frame_duration_ns(frame, bsid); - if let Some(reason) = ac3_drop_reason(&self.tally, frame, bsid) { - self.tally - .record_drop(self.flush_pts_ns, duration_ns as i64, frame.len(), reason); - return Vec::new(); - } - self.tally.record_kept(); - vec![Frame { - discontinuity: false, - coding: None, - source: None, - pts_ns: self.flush_pts_ns, - keyframe: true, - data: frame.to_vec(), - duration_ns: Some(duration_ns), - }] + + (frames, keep_from, frame_pts_ns) } } +/// Where a PES's own timestamp takes over the running per-access-unit PTS: +/// `pts_ns` applies to the first access unit whose independent substream starts +/// at or after byte `at` of the scanned buffer (the first byte contributed by +/// that PES). +struct PtsAnchor { + at: usize, + pts_ns: i64, +} + +/// An access unit under construction: `data[start..end]` is an independent +/// substream frame plus the dependent substream frames appended so far. +struct PendingAu { + start: usize, + end: usize, + /// PTS of the independent substream — the PTS the whole access unit carries. + pts_ns: i64, + /// Duration of the independent substream; dependent substreams add none. + duration_ns: u64, + /// First decodability failure among the access unit's substreams, if any. + drop_reason: Option<&'static str>, + /// bsid of the independent substream (< 11 = legacy AC-3 core). + bsid: u8, +} + +/// Emit a finished access unit, or record it as a drop when any of its +/// substreams failed the decodability gate. A drop still accounts for the full +/// access-unit duration, so it reads as a silence gap and never shifts the audio +/// that follows. +fn close_access_unit( + tally: &mut super::dropgate::DropTally, + data: &[u8], + au: &PendingAu, + out: &mut Vec, +) { + if let Some(reason) = au.drop_reason { + tally.record_drop(au.pts_ns, au.duration_ns as i64, au.end - au.start, reason); + return; + } + tally.record_kept(); + out.push(Frame { + discontinuity: false, + coding: None, + source: None, + pts_ns: au.pts_ns, + keyframe: true, + data: data[au.start..au.end].to_vec(), + duration_ns: Some(au.duration_ns), + }); +} + +/// Whether a syncframe is an E-AC-3 DEPENDENT substream, i.e. one that must be +/// decoded together with the preceding independent substream. +/// +/// Byte 2 of an E-AC-3 syncframe is `strmtyp(2) | substreamid(3) | frmsiz[10:8]` +/// (ETSI TS 102 366 Annex E BSI). `strmtyp` 0 and 2 are independent substreams +/// (type 2 being an independent substream that is not the first of the bit +/// stream); `strmtyp` 1 is the dependent substream. `strmtyp` 3 is reserved and +/// is treated as independent, so an unknown type starts a fresh access unit +/// rather than being merged into an unrelated one. +/// +/// Legacy AC-3 (`bsid < 11`) has no substream structure — byte 2 there is the +/// crc1 field, never `strmtyp` — so it is never dependent. +fn is_dependent_substream(data: &[u8], bsid: u8) -> bool { + bsid >= 11 && data.len() >= 3 && ((data[2] >> 6) & 0x03) == 1 +} + use super::crc::crc16_ansi; /// Whether a fully-buffered (E-)AC-3 frame passes its native CRC. Per ETSI TS @@ -174,111 +389,33 @@ impl CodecParser for Ac3Parser { return Vec::new(); } - // Base PTS for the FIRST frame emitted from this call. Each subsequent - // frame in the same call advances by the previous frame's duration, so a - // PES that carries several AC-3 frames stamps a monotonically increasing - // PTS per frame instead of the same PES timestamp on all of them (which - // collapses their timecodes and drifts A/V). + // This PES's timestamp applies to the first access unit that STARTS in + // its own bytes; anything already buffered began in an earlier PES and + // keeps the running cadence (`flush_pts_ns`). Each subsequent access unit + // in the same call advances by the previous one's duration, so a PES that + // carries several access units stamps a monotonically increasing PTS + // instead of the same PES timestamp on all of them (which collapses their + // timecodes and drifts A/V). // - // A PES with no PTS (rare for audio, but legal — and the case OSS demuxers - // guard at a post-gap continuation) must NOT reset the timeline to 0; - // carry the running cadence (`flush_pts_ns` tracks the retained partial / - // last frame's PTS). The discontinuity-carrying PES is a PUSI with a PTS - // in practice, so this is defense-in-depth. - let base_pts_ns = pes.pts.map(pts_to_ns).unwrap_or(self.flush_pts_ns); + // A PES with no PTS (rare for audio, but legal — and the case demuxers + // guard at a post-gap continuation) must NOT reset the timeline to 0: with + // no anchor the running cadence simply continues. The discontinuity- + // carrying PES is a PUSI with a PTS in practice, so this is + // defense-in-depth. + let carry_len = self.buf.len(); + let anchor = pes.pts.map(|p| PtsAnchor { + at: carry_len, + pts_ns: pts_to_ns(p), + }); // Prepend leftover from previous PES, then take the whole buffer into a - // local so the emit loop can call `self.tally` (the bytes are no longer + // local so the scanner can call `self.tally` (the bytes are no longer // borrowed from `self`). The unconsumed tail is written back at the end. self.buf.extend_from_slice(&pes.data); let buf = std::mem::take(&mut self.buf); let data = &buf; - let mut frames = Vec::new(); - let mut pos = 0; - // Running PTS for the next frame to emit in this call. - let mut frame_pts_ns = base_pts_ns; - - while pos < data.len() { - let sync = find_ac3_sync(&data[pos..]); - let start = match sync { - Some(offset) => pos + offset, - None => break, - }; - - let remaining = &data[start..]; - - if remaining.len() < 6 { - // Not enough data to determine frame size — keep for next PES - break; - } - - let bsid = get_bsid(remaining); - let frame_size = if bsid >= 11 { - eac3_frame_size(remaining) - } else { - ac3_frame_size(remaining) - }; - - if !(MIN_FRAME_BYTES..=8192).contains(&frame_size) { - // Invalid/sub-header frame size (e.g. an E-AC-3 frmsiz of 0/1 - // sizing to a 2/4-byte fragment) — skip this sync word. - pos = start + 2; - continue; - } - - if start + frame_size > data.len() { - // Incomplete frame — keep for next PES - break; - } - - let duration_ns = frame_duration_ns(remaining, bsid); - let frame = &data[start..start + frame_size]; - // Decodability gate: drop a frame with an out-of-range bsid (> 16) - // or whose native CRC fails (payload corruption). `frame_pts_ns` is - // advanced BELOW whether or not the frame survives, so a drop is a - // silence gap and the following frames keep their true PTS. - if let Some(reason) = ac3_drop_reason(&self.tally, frame, bsid) { - self.tally - .record_drop(frame_pts_ns, duration_ns as i64, frame.len(), reason); - } else { - self.tally.record_kept(); - frames.push(Frame { - discontinuity: false, - coding: None, - source: None, - pts_ns: frame_pts_ns, - keyframe: true, - data: frame.to_vec(), - duration_ns: Some(duration_ns), - }); - } - frame_pts_ns += duration_ns as i64; - pos = start + frame_size; - } - - // Keep unconsumed data for the next call. `pos` is the start of the - // last unprocessed search region. On the `start + frame_size > len` - // break it sits exactly at the straddling frame's syncword; on the - // `remaining.len() < 6` break it is the value from the top of that - // iteration, with the syncword possibly sitting after some pre-sync - // junk — so the re-scan below (from `pos`, NOT a recomputed sync) is - // required to locate the carry-over syncword. Carry from `pos`, NOT - // from the next syncword: discarding bytes between `pos` and the next - // sync would drop the partial frame we are deliberately keeping across - // the boundary. - let keep_from = if pos < data.len() { - // A syncword at/after `pos` marks the carry-over start (anything - // before it is junk with no sync). With no full sync, retain the - // whole tail — including a lone trailing 0x0B that may be the first - // half of a syncword split across the PES boundary. - match find_ac3_sync(&data[pos..]) { - Some(o) => pos + o, - None if data.last() == Some(&0x0B) => data.len() - 1, - None => data.len(), - } - } else { - data.len() - }; + let (frames, keep_from, frame_pts_ns) = + self.scan_access_units(data, self.flush_pts_ns, anchor, false); if keep_from < data.len() { let tail = &data[keep_from..]; @@ -294,20 +431,34 @@ impl CodecParser for Ac3Parser { self.buf.clear(); } else { self.buf = tail.to_vec(); - // The carried partial frame, when later completed and emitted by - // flush() at EOS, is timed at the running per-frame PTS reached - // here (the PTS of the next frame in presentation order). + // The carried bytes, when later completed and emitted (next call + // or by flush() at EOS), are timed at the PTS the scanner reached + // here: the PTS of the next access unit in presentation order, or + // — for a HELD access unit — that access unit's own PTS, so the + // hold never shifts it. self.flush_pts_ns = frame_pts_ns; } } else { self.buf.clear(); + // Nothing carried, but keep the cadence so a following PES with no + // PTS (no anchor) continues the timeline instead of reusing a stale + // value. + self.flush_pts_ns = frame_pts_ns; } frames } fn flush(&mut self) -> Vec { - let out = self.flush_tail(); + // Drain the carry-over buffer at EOS: a complete access unit (including + // one held back waiting for a possible dependent substream) may sit there + // with no following PES to close it, and without this drain the last + // ~32 ms of audio is lost. `at_eos` closes the trailing access unit + // instead of holding it; a partial/garbage tail yields nothing. + let buf = std::mem::take(&mut self.buf); + let out = self + .scan_access_units(&buf, self.flush_pts_ns, None, true) + .0; // Aggregate drop report at end-of-stream (warn-level, always visible). self.tally.log_summary(); out @@ -1221,7 +1372,11 @@ mod tests { frame[3] = 0xFF; // frmsiz low → 0x7FF = 2047 → 4096 bytes frame[5] = 16 << 3; // bsid 16 (E-AC-3) finalize_ac3_crc(&mut frame); // pass the decodability gate - let f = parser.parse(&make_eac3_pes(frame)); + // The trailing E-AC-3 access unit is HELD at the end of the call (a + // dependent substream may follow in the next PES), so it is closed by + // flush() at EOS rather than in-call. Content and size are unchanged. + let mut f = parser.parse(&make_eac3_pes(frame)); + f.extend(parser.flush()); assert_eq!(f.len(), 1, "4096-byte E-AC-3 frame within window accepted"); assert_eq!(f[0].data.len(), 4096); } @@ -1489,6 +1644,296 @@ mod tests { assert_eq!(parser.dropped_frames(), 0); } + // --- E-AC-3 substream grouping: one access unit per independent substream --- + + /// Build a synthetic E-AC-3 syncframe of exactly `size` bytes with the given + /// `strmtyp` / `substreamid` (ETSI TS 102 366 Annex E: byte 2 is + /// strmtyp(2) | substreamid(3) | frmsiz[10:8], byte 3 is frmsiz[7:0], and the + /// frame is `(frmsiz + 1) * 2` bytes). byte 4 = fscod 0 (48 kHz), + /// numblkscod 3 (6 blocks → 1536 samples → 32 ms), acmod 7 + lfeon (5.1); + /// byte 5 = bsid 16 so the E-AC-3 paths are taken. CRC finalized so the frame + /// passes the decodability gate. + fn make_eac3_frame(strmtyp: u8, substreamid: u8, size: usize) -> Vec { + assert!(size >= MIN_FRAME_BYTES && size % 2 == 0); + let frmsiz = size / 2 - 1; + let mut f = vec![0u8; size]; + f[0] = 0x0B; + f[1] = 0x77; + f[2] = (strmtyp << 6) | (substreamid << 3) | ((frmsiz >> 8) as u8 & 0x07); + f[3] = (frmsiz & 0xFF) as u8; + f[4] = 0x3F; + f[5] = 16 << 3; + finalize_ac3_crc(&mut f); + f + } + + #[test] + fn eac3_independent_plus_dependent_is_one_access_unit() { + // THE FIX: per ETSI TS 102 366 Annex E an access unit is the independent + // substream (strmtyp 0) plus every dependent substream (strmtyp 1) that + // follows it — the 7.1 Dolby Digital Plus arrangement. Both syncframes + // must emerge as ONE frame carrying the INDEPENDENT substream's PTS and + // exactly ONE frame duration (previously each syncframe was emitted as + // its own access unit and each advanced the clock, doubling the timeline + // and handing decoders a parentless dependent substream). + let mut parser = Ac3Parser::new(); + let indep = make_eac3_frame(0, 0, 160); + let dep = make_eac3_frame(1, 0, 96); + let indep2 = make_eac3_frame(0, 0, 160); + let mut data = indep.clone(); + data.extend_from_slice(&dep); + data.extend_from_slice(&indep2); + + // The next independent substream closes the first access unit in-call; + // the trailing one is held for a possible dependent in the next PES and + // closed by flush(). + let f = parser.parse(&make_eac3_pes(data)); + assert_eq!(f.len(), 1, "independent + dependent = exactly one AU"); + let mut expect = indep.clone(); + expect.extend_from_slice(&dep); + assert_eq!(f[0].data, expect, "AU carries both substreams, in order"); + assert_eq!( + f[0].pts_ns, + pts_to_ns(90000), + "AU is stamped with the INDEPENDENT substream's PTS" + ); + assert_eq!( + f[0].duration_ns, + Some(32_000_000), + "dependent substream adds no duration (same 32 ms time period)" + ); + + let f2 = parser.flush(); + assert_eq!(f2.len(), 1, "held trailing AU drained at EOS"); + assert_eq!( + f2[0].pts_ns, + pts_to_ns(90000) + 32_000_000, + "next AU advances by ONE frame duration, not two" + ); + assert_eq!(parser.dropped_frames(), 0, "nothing dropped"); + } + + #[test] + fn eac3_grouped_timeline_is_not_doubled() { + // Two complete access units (independent + dependent each) in one PES, + // followed by a third independent substream that closes the second. + // The emitted PTS cadence must be one frame duration per access unit — + // the 2x-runtime / A-V-drift symptom of ungrouped substreams. + let mut parser = Ac3Parser::new(); + let mut data = Vec::new(); + for _ in 0..2 { + data.extend_from_slice(&make_eac3_frame(0, 0, 160)); + data.extend_from_slice(&make_eac3_frame(1, 0, 96)); + } + data.extend_from_slice(&make_eac3_frame(0, 0, 160)); + let mut f = parser.parse(&make_eac3_pes(data)); + f.extend(parser.flush()); + assert_eq!(f.len(), 3, "3 independent substreams → 3 access units"); + let base = pts_to_ns(90000); + assert_eq!(f[0].pts_ns, base); + assert_eq!(f[1].pts_ns, base + 32_000_000); + assert_eq!(f[2].pts_ns, base + 64_000_000); + assert_eq!(f[0].data.len(), 160 + 96, "AU = independent + dependent"); + assert_eq!(f[1].data.len(), 160 + 96); + } + + #[test] + fn plain_ac3_frames_are_not_grouped_or_delayed() { + // NO REGRESSION for legacy AC-3 (bsid < 11): it has no substream + // structure (byte 2 is crc1, not strmtyp), so every syncframe is a + // complete access unit, emitted in the SAME call — never merged with its + // neighbour and never held back for a dependent that cannot exist. + let mut parser = Ac3Parser::new(); + let mut data = Vec::new(); + for _ in 0..3 { + data.extend_from_slice(&make_ac3_frame(0, 2)); // 160 bytes, bsid=8 + } + let f = parser.parse(&make_eac3_pes(data)); + assert_eq!(f.len(), 3, "three AC-3 frames, three access units, in-call"); + for (i, fr) in f.iter().enumerate() { + assert_eq!(fr.data.len(), 160, "frame {i} not merged with a neighbour"); + assert_eq!(fr.pts_ns, pts_to_ns(90000) + i as i64 * 32_000_000); + assert_eq!(fr.duration_ns, Some(32_000_000)); + } + assert!(parser.buf.is_empty(), "nothing held back for plain AC-3"); + assert!(parser.flush().is_empty(), "flush has nothing left to drain"); + } + + #[test] + fn eac3_access_unit_split_across_pes_is_grouped() { + // The access unit boundary is only known at the NEXT independent + // substream, so a trailing independent substream is held across the PES + // boundary: the dependent half arriving in the next PES still joins it, + // and the AU keeps the FIRST PES's PTS (its independent substream's). + let mut parser = Ac3Parser::new(); + let indep = make_eac3_frame(0, 0, 160); + let dep = make_eac3_frame(1, 0, 96); + let indep2 = make_eac3_frame(0, 0, 160); + + let mut d1 = indep.clone(); + d1.extend_from_slice(&dep[..40]); // dependent substream split mid-frame + let pes1 = PesPacket { + source: None, + pid: 0, + pts: Some(90000), + dts: None, + data: d1, + discontinuity: false, + }; + assert!( + parser.parse(&pes1).is_empty(), + "AU held: its dependent substream may continue in the next PES" + ); + + let mut d2 = dep[40..].to_vec(); + d2.extend_from_slice(&indep2); + let pes2 = PesPacket { + source: None, + pid: 0, + pts: Some(92880), // base + 32 ms in 90 kHz ticks + dts: None, + data: d2, + discontinuity: false, + }; + let f = parser.parse(&pes2); + assert_eq!(f.len(), 1, "the straddling AU emerges whole, exactly once"); + let mut expect = indep.clone(); + expect.extend_from_slice(&dep); + assert_eq!(f[0].data, expect, "independent + dependent, contiguous"); + assert_eq!( + f[0].pts_ns, + pts_to_ns(90000), + "held AU keeps its own (first PES) PTS, not the second PES's" + ); + } + + #[test] + fn eac3_dependent_syncword_split_across_pes_still_groups() { + // The straddling-syncword path must survive grouping: the dependent + // substream's 0x0B77 is split (0x0B ends PES 1, 0x77 starts PES 2). + let mut parser = Ac3Parser::new(); + let indep = make_eac3_frame(0, 0, 160); + let dep = make_eac3_frame(1, 0, 96); + + let mut d1 = indep.clone(); + d1.push(0x0B); + let pes1 = PesPacket { + source: None, + pid: 0, + pts: Some(90000), + dts: None, + data: d1, + discontinuity: false, + }; + assert!( + parser.parse(&pes1).is_empty(), + "AU held, lone 0x0B retained" + ); + + let mut d2 = vec![0x77]; + d2.extend_from_slice(&dep[2..]); + let pes2 = PesPacket { + source: None, + pid: 0, + pts: Some(92880), + dts: None, + data: d2, + discontinuity: false, + }; + assert!( + parser.parse(&pes2).is_empty(), + "still held: no next independent substream yet" + ); + let f = parser.flush(); + assert_eq!(f.len(), 1, "one grouped AU at EOS"); + let mut expect = indep.clone(); + expect.extend_from_slice(&dep); + assert_eq!( + f[0].data, expect, + "split-sync dependent substream recovered" + ); + assert_eq!(f[0].pts_ns, pts_to_ns(90000)); + } + + #[test] + fn orphan_dependent_substream_is_skipped() { + // A dependent substream with no independent parent (stream joined mid-AU) + // cannot be decoded on its own: skip it instead of shipping it as an + // access unit, and do not let it consume any of the timeline. + let mut parser = Ac3Parser::new(); + let mut data = make_eac3_frame(1, 0, 96); // dependent first — no parent + data.extend_from_slice(&make_ac3_frame(0, 2)); // legacy AC-3 follows + let f = parser.parse(&make_eac3_pes(data)); + assert_eq!(f.len(), 1, "only the parentable frame is emitted"); + assert_eq!(f[0].data.len(), 160, "the AC-3 frame, not the orphan"); + assert_eq!( + f[0].pts_ns, + pts_to_ns(90000), + "orphan consumed no time — following audio keeps its true PTS" + ); + } + + #[test] + fn eac3_strmtyp2_starts_a_new_access_unit() { + // strmtyp 2 is an INDEPENDENT substream (Annex E), and strmtyp 3 is + // reserved — neither may be folded into the preceding access unit. + let mut parser = Ac3Parser::new(); + let mut data = make_eac3_frame(0, 0, 160); + data.extend_from_slice(&make_eac3_frame(2, 0, 96)); + data.extend_from_slice(&make_eac3_frame(3, 0, 96)); + let mut f = parser.parse(&make_eac3_pes(data)); + f.extend(parser.flush()); + assert_eq!(f.len(), 3, "strmtyp 0 / 2 / 3 = three access units"); + assert_eq!(f[0].data.len(), 160); + assert_eq!(f[1].data.len(), 96); + assert_eq!(f[2].data.len(), 96); + } + + #[test] + fn ac3_core_plus_eac3_dependent_is_one_access_unit() { + // The backwards-compatible Dolby Digital Plus arrangement: a legacy AC-3 + // core syncframe followed by an E-AC-3 dependent substream. The dependent + // substream must attach to the AC-3 core, not become its own access unit. + let mut parser = Ac3Parser::new(); + let core = make_ac3_frame(0, 2); // bsid = 8, 160 bytes + let dep = make_eac3_frame(1, 0, 96); + let mut data = core.clone(); + data.extend_from_slice(&dep); + data.extend_from_slice(&make_ac3_frame(0, 2)); // next core closes the AU + let f = parser.parse(&make_eac3_pes(data)); + assert_eq!(f.len(), 1, "core + dependent = one AU (second core held)"); + let mut expect = core.clone(); + expect.extend_from_slice(&dep); + assert_eq!(f[0].data, expect); + assert_eq!(f[0].pts_ns, pts_to_ns(90000), "core's PTS"); + assert_eq!(f[0].duration_ns, Some(32_000_000), "one frame duration"); + } + + #[test] + fn corrupt_dependent_substream_drops_the_whole_access_unit() { + // A dependent substream that fails its native CRC poisons the access unit + // it belongs to: emitting the independent half alone would ship an AU a + // decoder must reassemble from a corrupt pair. The drop accounts for one + // frame duration, so the following AU keeps its true PTS (gap, not shift). + let mut parser = Ac3Parser::new(); + let mut dep = make_eac3_frame(1, 0, 96); + dep[20] ^= 0xFF; // break the dependent substream's CRC + assert!(!frame_crc_ok(&dep)); + let mut data = make_eac3_frame(0, 0, 160); + data.extend_from_slice(&dep); + data.extend_from_slice(&make_eac3_frame(0, 0, 160)); + let mut f = parser.parse(&make_eac3_pes(data)); + f.extend(parser.flush()); + assert_eq!(f.len(), 1, "poisoned AU dropped, the clean one survives"); + assert_eq!(parser.dropped_frames(), 1); + assert_eq!(parser.dropped_duration_ns(), 32_000_000, "one 32 ms gap"); + assert_eq!( + f[0].pts_ns, + pts_to_ns(90000) + 32_000_000, + "survivor keeps its true timeline" + ); + } + // helper: PES with a generic pts for E-AC-3 tests fn make_eac3_pes(data: Vec) -> PesPacket { PesPacket {