From 5ecfe7c69a8818aecb7f4f5e1329b25d93d90fc6 Mon Sep 17 00:00:00 2001
From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com>
Date: Sun, 19 Jul 2026 13:42:48 -0700
Subject: [PATCH] mux/codec: drop undecodable audio frames, keep A/V sync
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A damaged audio access unit is now dropped rather than muxed as a
decoder-choking glitch. Sync is preserved — a drop becomes a silence
gap, never a shift — and every drop is logged. Detection is per-codec,
each mirroring the format's authoritative integrity check:
DTS core-header validity gates
AC-3/E-AC-3 native frame CRC-16 + bitstream-id range
FLAC whole-frame CRC-16 residue
MP2/MP3 header sanity + free-format reject
AAC-ADTS header sanity (raw AAC passes through untouched)
TrueHD/MLP major-sync CRC-16 + AU parity; corrupt AUs drop forward
to the next major sync, since decode state carries
across access units
LPCM and video are excluded by design (no in-frame integrity data;
inter-frame prediction). A shared DropTally handles counting, logging,
and a whole-track fallback for a mostly-undecodable track.
---
src/mux/codec/ac3.rs | 299 ++++++++++++++++++-----
src/mux/codec/adts.rs | 220 +++++++++++++++++
src/mux/codec/crc.rs | 110 +++++++++
src/mux/codec/dropgate.rs | 168 +++++++++++++
src/mux/codec/dts.rs | 471 +++++++++++++++++++++++++++++++++++--
src/mux/codec/flac.rs | 218 +++++++++++++++++
src/mux/codec/mod.rs | 36 ++-
src/mux/codec/mpegaudio.rs | 222 +++++++++++++++++
src/mux/codec/truehd.rs | 340 +++++++++++++++++++++++++-
9 files changed, 1991 insertions(+), 93 deletions(-)
create mode 100644 src/mux/codec/adts.rs
create mode 100644 src/mux/codec/crc.rs
create mode 100644 src/mux/codec/dropgate.rs
create mode 100644 src/mux/codec/flac.rs
create mode 100644 src/mux/codec/mpegaudio.rs
diff --git a/src/mux/codec/ac3.rs b/src/mux/codec/ac3.rs
index c7d8e2c..45926f7 100644
--- a/src/mux/codec/ac3.rs
+++ b/src/mux/codec/ac3.rs
@@ -41,6 +41,11 @@ pub struct Ac3Parser {
/// the running per-frame PTS at the point the partial tail was retained.
/// Used by `flush()` to time the final buffered frame at EOS.
flush_pts_ns: i64,
+ /// Keep/drop bookkeeping for the CRC decodability gate. A frame that fails
+ /// its native CRC is dropped rather than shipped as a decoder-choking glitch;
+ /// 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,
}
impl Default for Ac3Parser {
@@ -54,8 +59,99 @@ impl Ac3Parser {
Self {
buf: Vec::with_capacity(4096),
flush_pts_ns: 0,
+ tally: super::dropgate::DropTally::new("ac3"),
}
}
+
+ /// Access units dropped as undecodable so far — surfaced to the CLI/mux.
+ pub fn dropped_frames(&self) -> u64 {
+ self.tally.dropped_frames()
+ }
+
+ /// Total decoded duration (ns) of dropped access units.
+ pub fn dropped_duration_ns(&self) -> u64 {
+ 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();
+ };
+ 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),
+ }]
+ }
+}
+
+use super::crc::crc16_ansi;
+
+/// Whether a fully-buffered (E-)AC-3 frame passes its native CRC. ffmpeg's
+/// decoder checks exactly this — `av_crc(AV_CRC_16_ANSI, 0, &buf[2],
+/// frame_size - 2) == 0` (ac3dec.c) — over the frame after the 2-byte syncword;
+/// the trailing crc word makes a clean frame's residue zero. A nonzero residue
+/// is a ~1-in-65536-certain sign of payload corruption, so we drop the frame
+/// (silence gap) rather than ship a glitch. `frame` must be exactly the frame
+/// bytes (syncword .. frame_size).
+fn frame_crc_ok(frame: &[u8]) -> bool {
+ // Need the syncword (2) plus at least one covered byte; the caller only
+ // invokes this on a fully-sized frame, so this is defensive.
+ if frame.len() < 4 {
+ return true;
+ }
+ crc16_ansi(&frame[2..]) == 0
+}
+
+/// Decodability verdict for a fully-sized (E-)AC-3 frame: `Some(reason)` when it
+/// must be dropped, `None` when it decodes. Drops (in order): a poisoned track
+/// (mostly-undecodable → drop the rest), a bitstream id ffmpeg's parser rejects
+/// (`bsid > 16` → `AC3_PARSE_ERROR_BSID`), or a failed native frame CRC.
+fn ac3_drop_reason(
+ tally: &super::dropgate::DropTally,
+ frame: &[u8],
+ bsid: u8,
+) -> Option<&'static str> {
+ if tally.is_poisoned() {
+ Some("track-poisoned")
+ } else if bsid > 16 {
+ Some("bsid")
+ } else if !frame_crc_ok(frame) {
+ Some("crc")
+ } else {
+ None
+ }
}
impl CodecParser for Ac3Parser {
@@ -91,10 +187,12 @@ impl CodecParser for Ac3Parser {
// in practice, so this is defense-in-depth.
let base_pts_ns = pes.pts.map(pts_to_ns).unwrap_or(self.flush_pts_ns);
- // Prepend leftover from previous PES
+ // 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
+ // borrowed from `self`). The unconsumed tail is written back at the end.
self.buf.extend_from_slice(&pes.data);
-
- let data = &self.buf;
+ 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.
@@ -134,15 +232,26 @@ impl CodecParser for Ac3Parser {
}
let duration_ns = frame_duration_ns(remaining, bsid);
- frames.push(Frame {
- discontinuity: false,
- coding: None,
- source: None,
- pts_ns: frame_pts_ns,
- keyframe: true,
- data: data[start..start + frame_size].to_vec(),
- duration_ns: Some(duration_ns),
- });
+ let frame = &data[start..start + frame_size];
+ // Decodability gate: drop a frame ffmpeg's parser rejects (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;
}
@@ -198,38 +307,10 @@ impl CodecParser for Ac3Parser {
}
fn flush(&mut self) -> Vec {
- // End of stream: emit a complete final frame still buffered. During
- // streaming a final frame may sit in `buf` with no following PES to
- // complete/confirm it; without this drain the last ~32 ms of audio is
- // dropped at EOS (mirrors dts.rs::flush). Only a fully-sized frame at a
- // syncword is emitted; a partial/garbage tail is discarded.
- let buf = std::mem::take(&mut self.buf);
- let Some(off) = find_ac3_sync(&buf) else {
- return Vec::new();
- };
- let frame = &buf[off..];
- if frame.len() < 6 {
- return Vec::new();
- }
- let bsid = get_bsid(frame);
- let frame_size = if bsid >= 11 {
- eac3_frame_size(frame)
- } else {
- ac3_frame_size(frame)
- };
- if !(MIN_FRAME_BYTES..=8192).contains(&frame_size) || off + frame_size > buf.len() {
- return Vec::new();
- }
- let duration_ns = frame_duration_ns(frame, bsid);
- vec![Frame {
- discontinuity: false,
- coding: None,
- source: None,
- pts_ns: self.flush_pts_ns,
- keyframe: true,
- data: buf[off..off + frame_size].to_vec(),
- duration_ns: Some(duration_ns),
- }]
+ let out = self.flush_tail();
+ // Aggregate drop report at end-of-stream (warn-level, always visible).
+ self.tally.log_summary();
+ out
}
fn codec_private(&self) -> Option> {
@@ -460,9 +541,24 @@ mod tests {
frame[1] = 0x77;
frame[4] = (fscod << 6) | frmsizecod;
frame[5] = 0x08 << 3; // bsid = 8 (AC-3)
+ finalize_ac3_crc(&mut frame);
frame
}
+ /// Set the trailing CRC word so the whole-frame residue over `[2..]` is zero
+ /// — i.e. the frame passes the decodability gate. Relies on the CRC-16/ANSI
+ /// residue property: appending `crc16([2..n-2])` (big-endian) zeroes the
+ /// register over `[2..n]`. Leaves the crc1 field (bytes 2-3) untouched.
+ fn finalize_ac3_crc(frame: &mut [u8]) {
+ let n = frame.len();
+ if n < 4 {
+ return;
+ }
+ let c = crc16_ansi(&frame[2..n - 2]);
+ frame[n - 2] = (c >> 8) as u8;
+ frame[n - 1] = (c & 0xFF) as u8;
+ }
+
#[test]
fn parse_empty_pes() {
let mut parser = Ac3Parser::new();
@@ -1090,27 +1186,33 @@ mod tests {
// --- frame acceptance / rejection at the size boundaries ---
#[test]
- fn eac3_frame_at_min_frame_bytes_is_accepted() {
- // The smallest acceptable (E-)AC-3 frame is MIN_FRAME_BYTES = 6.
- // Build an E-AC-3 frame whose frmsiz sizes it to exactly 6 bytes
- // (frmsiz=2). bsid >= 11 selects E-AC-3 sizing. The parser must emit it.
+ fn eac3_frame_at_min_frame_bytes_passes_sizing_then_crc_gate() {
+ // The smallest frame the SIZING layer accepts is MIN_FRAME_BYTES = 6
+ // (frmsiz=2). A synthetic all-zero 6-byte frame passes sizing (so it
+ // reaches the decodability gate — proven by it being COUNTED as a drop,
+ // not silently size-skipped) but fails the CRC gate and is dropped; the
+ // following real AC-3 frame (valid CRC) is emitted.
let mut parser = Ac3Parser::new();
// 0x0B 0x77 | byte2=0 byte3=2 (frmsiz=2 → 6 bytes) | byte4=0 | byte5 bsid
let mut data = vec![0x0B, 0x77, 0x00, 0x02, 0x00, 16 << 3];
- // pad to exactly 6 bytes (already 6). Then a trailing real AC-3 frame so
- // the 6-byte frame isn't a tail that needs more data.
data.truncate(6);
data.extend_from_slice(&make_ac3_frame(0, 2));
let f = parser.parse(&make_eac3_pes(data));
- assert_eq!(f.len(), 2, "6-byte E-AC-3 frame accepted + following AC-3");
- assert_eq!(f[0].data.len(), 6);
+ assert_eq!(f.len(), 1, "6-byte frame dropped (CRC), real AC-3 emitted");
+ assert_eq!(f[0].data.len(), 160, "the surviving frame is the real AC-3");
+ assert_eq!(
+ parser.dropped_frames(),
+ 1,
+ "the 6-byte frame reached the gate"
+ );
}
#[test]
fn eac3_max_frmsiz_frame_within_window_accepted() {
// E-AC-3 frmsiz is an 11-bit field (3 bits of byte2 + 8 bits of byte3),
// so its maximum value is 0x7FF = 2047 → (2048)*2 = 4096 bytes, which is
- // inside the MIN_FRAME_BYTES..=8192 accept window and must be emitted.
+ // inside the MIN_FRAME_BYTES..=8192 accept window and, with a valid CRC,
+ // must be emitted.
let mut parser = Ac3Parser::new();
let mut frame = vec![0u8; 4096];
frame[0] = 0x0B;
@@ -1118,6 +1220,7 @@ mod tests {
frame[2] = 0x07; // frmsiz high
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));
assert_eq!(f.len(), 1, "4096-byte E-AC-3 frame within window accepted");
assert_eq!(f[0].data.len(), 4096);
@@ -1296,6 +1399,96 @@ mod tests {
assert_eq!(acmod_channels(&frame), Some(2));
}
+ // --- decodability (CRC) gate: keep clean frames, drop corrupt ones ---
+
+ /// A structurally-valid AC-3 frame with one payload byte corrupted so its
+ /// native CRC fails (header/size intact, so the framer delimits it normally).
+ fn make_corrupt_ac3_frame(fscod: u8, frmsizecod: u8) -> Vec {
+ let mut f = make_ac3_frame(fscod, frmsizecod);
+ f[20] ^= 0xFF; // flip a payload byte → CRC no longer zero
+ assert!(!frame_crc_ok(&f), "corruption must break the CRC");
+ f
+ }
+
+ #[test]
+ fn crc16_residue_zero_after_finalize_nonzero_after_corruption() {
+ // The CRC-16/ANSI residue property the gate relies on: a finalized frame
+ // has residue 0 over [2..]; flipping any covered byte makes it nonzero.
+ let good = make_ac3_frame(0, 2);
+ assert!(frame_crc_ok(&good));
+ let bad = make_corrupt_ac3_frame(0, 2);
+ assert!(!frame_crc_ok(&bad));
+ }
+
+ #[test]
+ fn crc_fail_frame_is_dropped_survivors_kept() {
+ // good / corrupt / good in one PES: the corrupt middle frame is dropped
+ // (CRC), the two clean frames are emitted, and the drop is counted.
+ let mut parser = Ac3Parser::new();
+ let mut data = make_ac3_frame(0, 2);
+ data.extend_from_slice(&make_corrupt_ac3_frame(0, 2));
+ data.extend_from_slice(&make_ac3_frame(0, 2));
+ let f = parser.parse(&make_eac3_pes(data));
+ // Only two of three survive; flush has nothing (all closed in-call).
+ assert_eq!(f.len(), 2, "corrupt frame dropped, two clean survive");
+ assert_eq!(parser.dropped_frames(), 1);
+ assert_eq!(
+ parser.dropped_duration_ns(),
+ 32_000_000,
+ "one 32ms frame of silence"
+ );
+ }
+
+ #[test]
+ fn crc_drop_preserves_pts_sync_no_shift() {
+ // THE INVARIANT: dropping a corrupt frame must not shift the audio after
+ // it. good / corrupt / good in one PES — the corrupt frame is dropped but
+ // the trailing clean frame keeps the EXACT PTS it would have had with no
+ // drop (base + 2 frame durations): a silence gap, not a shift.
+ let mut parser = Ac3Parser::new();
+ let mut data = make_ac3_frame(0, 2); // f0
+ data.extend_from_slice(&make_corrupt_ac3_frame(0, 2)); // dropped
+ data.extend_from_slice(&make_ac3_frame(0, 2)); // f2
+ let f = parser.parse(&make_eac3_pes(data));
+ assert_eq!(f.len(), 2);
+ let base = pts_to_ns(90000);
+ let frame_dur = 32_000_000i64; // 1536 @ 48k
+ assert_eq!(f[0].pts_ns, base, "f0 at PES base");
+ assert_eq!(
+ f[1].pts_ns,
+ base + 2 * frame_dur,
+ "surviving frame keeps its true timeline (base + 2 frames) — gap, not shift"
+ );
+ }
+
+ #[test]
+ fn bsid_over_16_is_dropped() {
+ // ffmpeg's parser rejects bsid > 16 (AC3_PARSE_ERROR_BSID). A frame with
+ // bsid = 17 that still sizes must be dropped, not emitted.
+ let mut frame = vec![0u8; 128];
+ frame[0] = 0x0B;
+ frame[1] = 0x77;
+ frame[3] = 63; // frmsiz = 63 → (63+1)*2 = 128 bytes (E-AC-3 sizing)
+ frame[5] = 17 << 3; // bsid = 17 (> 16)
+ assert_eq!(get_bsid(&frame), 17);
+ let tally = super::super::dropgate::DropTally::new("ac3");
+ assert_eq!(ac3_drop_reason(&tally, &frame, 17), Some("bsid"));
+ }
+
+ #[test]
+ fn clean_stream_drops_nothing() {
+ // A stream of valid frames passes untouched — zero false positives.
+ let mut parser = Ac3Parser::new();
+ let mut data = Vec::new();
+ for _ in 0..5 {
+ data.extend_from_slice(&make_ac3_frame(0, 2));
+ }
+ let mut f = parser.parse(&make_eac3_pes(data));
+ f.extend(parser.flush());
+ assert_eq!(f.len(), 5);
+ assert_eq!(parser.dropped_frames(), 0);
+ }
+
// helper: PES with a generic pts for E-AC-3 tests
fn make_eac3_pes(data: Vec) -> PesPacket {
PesPacket {
diff --git a/src/mux/codec/adts.rs b/src/mux/codec/adts.rs
new file mode 100644
index 0000000..6c127fd
--- /dev/null
+++ b/src/mux/codec/adts.rs
@@ -0,0 +1,220 @@
+//! AAC ADTS decodability gate.
+//!
+//! ffmpeg's `ff_adts_header_parse` (adts_header.c) has exactly three hard
+//! rejects: syncword != 0xFFF, a reserved `sampling_frequency_index`
+//! (`ff_mpeg4audio_sample_rates[sr] == 0`, i.e. index ≥ 13), and
+//! `aac_frame_length < 7`. It does NOT verify the optional ADTS CRC (it only
+//! `skip_bits(16)` past it). So the gate mirrors those three rejects: a packet
+//! that begins with the ADTS sync but is otherwise malformed is dropped; a
+//! packet with no ADTS sync is raw AAC (e.g. from mp4, which carries no ADTS
+//! header) or a continuation and passes through unchanged — never false-dropped.
+//! Raw AAC has no per-frame integrity data, so like LPCM it cannot be gated.
+
+use super::dropgate::DropTally;
+use super::{CodecParser, Frame, PesPacket, pts_to_ns};
+
+/// `ff_mpeg4audio_sample_rates` — 13 valid entries; indices 13/14/15 are 0
+/// (reserved), which is exactly what ffmpeg rejects.
+const ADTS_SAMPLE_RATE_VALID: [u32; 16] = [
+ 96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350, 0, 0,
+ 0,
+];
+
+/// ADTS header verdict for the packet head.
+enum AdtsVerdict {
+ /// No 12-bit ADTS sync at the head — not an ADTS frame we can validate.
+ NoSync,
+ /// Sync present and the three ffmpeg-checked fields are legal.
+ Valid,
+ /// Sync present but a reserved sample-rate index or a sub-header
+ /// frame-length — ffmpeg's parser rejects this.
+ Invalid,
+}
+
+fn adts_verdict(data: &[u8]) -> AdtsVerdict {
+ // Need the full 7-byte fixed+variable header to read frame_length.
+ if data.len() < 7 {
+ return AdtsVerdict::NoSync;
+ }
+ // 12-bit syncword 0xFFF: byte0 == 0xFF and top nibble of byte1 == 0xF.
+ if data[0] != 0xFF || (data[1] & 0xF0) != 0xF0 {
+ return AdtsVerdict::NoSync;
+ }
+ // sampling_frequency_index: byte2 bits 5..2.
+ let sr_index = ((data[2] >> 2) & 0x0F) as usize;
+ if ADTS_SAMPLE_RATE_VALID[sr_index] == 0 {
+ return AdtsVerdict::Invalid;
+ }
+ // aac_frame_length: 13 bits = byte3[1:0] | byte4 | byte5[7:5].
+ let frame_length =
+ ((u32::from(data[3]) & 0x03) << 11) | (u32::from(data[4]) << 3) | (u32::from(data[5]) >> 5);
+ if frame_length < 7 {
+ return AdtsVerdict::Invalid;
+ }
+ AdtsVerdict::Valid
+}
+
+pub struct AdtsParser {
+ tally: DropTally,
+}
+
+impl Default for AdtsParser {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl AdtsParser {
+ pub fn new() -> Self {
+ Self {
+ tally: DropTally::new("aac"),
+ }
+ }
+
+ pub fn dropped_frames(&self) -> u64 {
+ self.tally.dropped_frames()
+ }
+
+ pub fn dropped_duration_ns(&self) -> u64 {
+ self.tally.dropped_duration_ns()
+ }
+}
+
+impl CodecParser for AdtsParser {
+ fn parse(&mut self, pes: &PesPacket) -> Vec {
+ if pes.data.is_empty() {
+ return Vec::new();
+ }
+ let pts_ns = pes.pts.or(pes.dts).map(pts_to_ns).unwrap_or(0);
+
+ let drop =
+ self.tally.is_poisoned() || matches!(adts_verdict(&pes.data), AdtsVerdict::Invalid);
+ if drop {
+ let reason = if self.tally.is_poisoned() {
+ "track-poisoned"
+ } else {
+ "header"
+ };
+ self.tally.record_drop(pts_ns, 0, pes.data.len(), reason);
+ return Vec::new();
+ }
+
+ self.tally.record_kept();
+ vec![Frame {
+ discontinuity: pes.discontinuity,
+ coding: None,
+ source: None,
+ pts_ns,
+ keyframe: true,
+ data: pes.data.clone(),
+ duration_ns: None,
+ }]
+ }
+
+ fn flush(&mut self) -> Vec {
+ self.tally.log_summary();
+ Vec::new()
+ }
+
+ fn codec_private(&self) -> Option> {
+ None
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn make_pes(data: Vec, pts: Option) -> PesPacket {
+ PesPacket {
+ source: None,
+ pid: 0x1100,
+ pts,
+ dts: None,
+ data,
+ discontinuity: false,
+ }
+ }
+
+ /// A valid ADTS header (AAC-LC, 44.1 kHz, stereo) + payload, with
+ /// aac_frame_length set to the total size.
+ fn adts_frame(payload: usize) -> Vec {
+ let total = 7 + payload;
+ let mut f = vec![0u8; total];
+ f[0] = 0xFF;
+ f[1] = 0xF1; // sync + MPEG-4 + no CRC (protection_absent=1)
+ f[2] = 0x50; // profile=AAC-LC, sr_index=4 (44.1 kHz)
+ f[3] = 0x80; // channel_config low + start of frame_length
+ // frame_length (13 bits) = total.
+ let fl = total as u32;
+ f[3] = (f[3] & 0xFC) | ((fl >> 11) & 0x03) as u8;
+ f[4] = ((fl >> 3) & 0xFF) as u8;
+ f[5] = (((fl & 0x07) << 5) as u8) | 0x1F; // low 3 bits of len + buffer-fullness bits
+ f
+ }
+
+ #[test]
+ fn valid_adts_is_kept() {
+ let mut p = AdtsParser::new();
+ let f = p.parse(&make_pes(adts_frame(400), Some(90000)));
+ assert_eq!(f.len(), 1);
+ assert_eq!(f[0].pts_ns, pts_to_ns(90000));
+ assert_eq!(p.dropped_frames(), 0);
+ }
+
+ #[test]
+ fn reserved_sample_rate_index_is_dropped() {
+ // sr_index = 13 (reserved). byte2 bits5..2 = 1101 → 0x34.
+ let mut p = AdtsParser::new();
+ let mut f = adts_frame(400);
+ f[2] = (f[2] & 0xC3) | (13 << 2); // set sr_index = 13
+ assert!(p.parse(&make_pes(f, Some(0))).is_empty());
+ assert_eq!(p.dropped_frames(), 1);
+ }
+
+ #[test]
+ fn subheader_frame_length_is_dropped() {
+ // frame_length < 7 (here 0) is a sub-header length → reject.
+ let mut p = AdtsParser::new();
+ let mut f = adts_frame(400);
+ f[3] &= 0xFC; // clear len high bits
+ f[4] = 0;
+ f[5] &= 0x1F; // clear len low bits → frame_length = 0
+ assert!(p.parse(&make_pes(f, Some(0))).is_empty());
+ assert_eq!(p.dropped_frames(), 1);
+ }
+
+ #[test]
+ fn raw_aac_without_sync_passes_through() {
+ // No ADTS sync (e.g. raw AAC from mp4) → cannot validate → keep.
+ let mut p = AdtsParser::new();
+ let f = p.parse(&make_pes(
+ vec![0x21, 0x00, 0x03, 0x40, 0x00, 0x00, 0x00],
+ Some(0),
+ ));
+ assert_eq!(f.len(), 1);
+ assert_eq!(p.dropped_frames(), 0);
+ }
+
+ #[test]
+ fn drop_preserves_sync_via_own_pts() {
+ let mut p = AdtsParser::new();
+ let mut bad = adts_frame(400);
+ bad[2] = (bad[2] & 0xC3) | (14 << 2); // reserved sr_index
+ assert!(p.parse(&make_pes(bad, Some(90000))).is_empty());
+ let f = p.parse(&make_pes(adts_frame(400), Some(96000)));
+ assert_eq!(f.len(), 1);
+ assert_eq!(
+ f[0].pts_ns,
+ pts_to_ns(96000),
+ "next frame keeps its own PTS"
+ );
+ }
+
+ #[test]
+ fn short_packet_passes_through() {
+ let mut p = AdtsParser::new();
+ let f = p.parse(&make_pes(vec![0xFF, 0xF1, 0x50], Some(0)));
+ assert_eq!(f.len(), 1, "too short to validate → kept");
+ }
+}
diff --git a/src/mux/codec/crc.rs b/src/mux/codec/crc.rs
new file mode 100644
index 0000000..6356118
--- /dev/null
+++ b/src/mux/codec/crc.rs
@@ -0,0 +1,110 @@
+//! Bit-exact CRC helpers shared by the audio codec decodability gates.
+//!
+//! Both match ffmpeg's `av_crc` tables so a frame that ffmpeg's decoder would
+//! flag as a CRC mismatch is flagged identically here. All are MSB-first
+//! (non-reflected), init 0, no final XOR — the ffmpeg `AV_CRC_*` (big-endian)
+//! variants. Each format transmits its CRC so that the residue over
+//! `data + transmitted_crc` is zero, which is exactly how these are used:
+//! compute over the whole frame (including its trailing CRC) and check `== 0`.
+
+/// CRC-16/ANSI (a.k.a. CRC-16/BUYPASS): polynomial 0x8005, init 0x0000,
+/// MSB-first, no reflection, no final XOR — ffmpeg `AV_CRC_16_ANSI`.
+/// Used by AC-3/E-AC-3 (frame CRC), FLAC (frame footer), MPEG-audio and
+/// AAC-ADTS (header CRC).
+pub(crate) fn crc16_ansi(data: &[u8]) -> u16 {
+ let mut crc: u16 = 0;
+ for &b in data {
+ crc ^= (b as u16) << 8;
+ for _ in 0..8 {
+ crc = if crc & 0x8000 != 0 {
+ (crc << 1) ^ 0x8005
+ } else {
+ crc << 1
+ };
+ }
+ }
+ crc
+}
+
+/// CRC-16 with polynomial 0x002D, init 0, MSB-first — ffmpeg's `crc_2D` table
+/// (`av_crc_init(crc_2D, 0, 16, 0x002D)`), used by the MLP/TrueHD major-sync
+/// header checksum. NOTE: MLP's checksum is the "reversed" scheme — ffmpeg
+/// computes `av_crc(...) ^ AV_RL16(trailer)` and compares against `AV_RL16` of
+/// the stored word; equivalently, this standard CRC compared against the stored
+/// bytes read big-endian. The caller handles that comparison
+/// (see `truehd::mlp_major_sync_ok`). Verified against real ffmpeg TrueHD
+/// output (225/225 major-sync AUs).
+pub(crate) fn crc16_mlp(data: &[u8]) -> u16 {
+ let mut crc: u16 = 0;
+ for &b in data {
+ crc ^= (b as u16) << 8;
+ for _ in 0..8 {
+ crc = if crc & 0x8000 != 0 {
+ (crc << 1) ^ 0x002D
+ } else {
+ crc << 1
+ };
+ }
+ }
+ crc
+}
+
+/// CRC-8/ATM (a.k.a. CRC-8/ITU without the final XOR): polynomial 0x07, init 0,
+/// MSB-first, no reflection — ffmpeg `AV_CRC_8_ATM`. Used by the FLAC frame
+/// header.
+pub(crate) fn crc8_atm(data: &[u8]) -> u8 {
+ let mut crc: u8 = 0;
+ for &b in data {
+ crc ^= b;
+ for _ in 0..8 {
+ crc = if crc & 0x80 != 0 {
+ (crc << 1) ^ 0x07
+ } else {
+ crc << 1
+ };
+ }
+ }
+ crc
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn crc16_residue_property_holds() {
+ // Appending the big-endian CRC-16 of a message zeroes the residue over
+ // message+crc — the property every frame gate relies on.
+ let msg = [0x12u8, 0x34, 0x56, 0x78, 0x9A];
+ let c = crc16_ansi(&msg);
+ let mut framed = msg.to_vec();
+ framed.push((c >> 8) as u8);
+ framed.push((c & 0xFF) as u8);
+ assert_eq!(crc16_ansi(&framed), 0);
+ }
+
+ #[test]
+ fn crc16_known_vector_check_bytes() {
+ // CRC-16/BUYPASS check value for the ASCII string "123456789" is 0xFEE8
+ // (the standard catalogue check value for poly 0x8005, init 0).
+ assert_eq!(crc16_ansi(b"123456789"), 0xFEE8);
+ }
+
+ #[test]
+ fn crc8_residue_property_holds() {
+ // Appending the CRC-8 of a message zeroes the residue over message+crc —
+ // how FLAC's header CRC-8 is verified.
+ let msg = [0xDEu8, 0xAD, 0xBE, 0xEF];
+ let c = crc8_atm(&msg);
+ let mut framed = msg.to_vec();
+ framed.push(c);
+ assert_eq!(crc8_atm(&framed), 0);
+ }
+
+ #[test]
+ fn crc8_known_vector_check_byte() {
+ // CRC-8/SMBUS (poly 0x07, init 0, no reflection) check value for
+ // "123456789" is 0xF4 — the catalogue check value.
+ assert_eq!(crc8_atm(b"123456789"), 0xF4);
+ }
+}
diff --git a/src/mux/codec/dropgate.rs b/src/mux/codec/dropgate.rs
new file mode 100644
index 0000000..ba9e18d
--- /dev/null
+++ b/src/mux/codec/dropgate.rs
@@ -0,0 +1,168 @@
+//! Shared "keep what decodes, drop what doesn't" bookkeeping for the audio
+//! codec parsers.
+//!
+//! The user's rule: a clean mux keeps every frame it can and drops the ones it
+//! can't — video always survives (it's inter-frame predicted; a per-frame drop
+//! would cascade, so video resyncs/conceals instead), audio keeps every
+//! decodable access unit, and a damaged audio AU is dropped rather than shipped
+//! as a decoder-choking glitch.
+//!
+//! The DETECTION is inherently per-codec — each format carries its own
+//! authoritative corruption check (DTS: ffmpeg's core-header parse; AC-3: the
+//! header CRC; FLAC: the frame CRC-16; …). This type only carries the UNIFORM
+//! response so every audio parser behaves identically:
+//!
+//! 1. **Count** kept vs dropped AUs and the dropped duration.
+//! 2. **Log** every drop (fail-loud, never silent) — a per-drop trace plus a
+//! once-per-track aggregate at `warn` so it surfaces without debug logging.
+//! 3. **Whole-track fallback**: once a track is judged mostly undecodable, latch
+//! a poison flag so the remainder is dropped too (a track that damaged isn't
+//! worth muxing).
+//!
+//! **Sync preservation is the caller's responsibility**, not this type's: the
+//! parser must advance its PTS clock across a dropped AU exactly as it would for
+//! an emitted one, so a drop becomes a silence gap and never a shift of the
+//! following audio. See `DtsParser`'s `stamp_pts` call ordering for the pattern.
+
+/// Minimum access units observed before the whole-track drop verdict can fire.
+/// Below this, a short damaged burst can't poison an otherwise-good track.
+const TRACK_VERDICT_MIN_AUS: u64 = 200;
+
+/// Per-track drop bookkeeping shared by the audio codec parsers.
+pub(crate) struct DropTally {
+ /// Static codec label for log lines (e.g. `"dts"`, `"ac3"`).
+ codec: &'static str,
+ kept: u64,
+ dropped: u64,
+ dropped_dur_ns: u64,
+ poisoned: bool,
+}
+
+impl DropTally {
+ pub(crate) fn new(codec: &'static str) -> Self {
+ Self {
+ codec,
+ kept: 0,
+ dropped: 0,
+ dropped_dur_ns: 0,
+ poisoned: false,
+ }
+ }
+
+ /// Whether the track has been judged too damaged to mux. Once `true`, the
+ /// caller should drop every remaining AU (passing them to [`record_drop`]
+ /// with a poison reason) rather than emit them.
+ pub(crate) fn is_poisoned(&self) -> bool {
+ self.poisoned
+ }
+
+ /// Access units dropped as undecodable so far — surfaced to the CLI/mux.
+ pub(crate) fn dropped_frames(&self) -> u64 {
+ self.dropped
+ }
+
+ /// Total decoded duration (ns) of dropped AUs — the audio silence introduced.
+ pub(crate) fn dropped_duration_ns(&self) -> u64 {
+ self.dropped_dur_ns
+ }
+
+ /// Record an emitted (decodable) access unit.
+ pub(crate) fn record_kept(&mut self) {
+ self.kept += 1;
+ }
+
+ /// Record a dropped (undecodable) access unit and log it. `reason` is a
+ /// short static label for the specific corruption check that failed.
+ pub(crate) fn record_drop(&mut self, pts_ns: i64, dur_ns: i64, bytes: usize, reason: &str) {
+ self.dropped += 1;
+ self.dropped_dur_ns += dur_ns.max(0) as u64;
+ tracing::debug!(
+ target: "mux",
+ "{}: dropped undecodable AU #{} pts_ns={} dur_ns={} bytes={} reason={}",
+ self.codec,
+ self.dropped,
+ pts_ns,
+ dur_ns,
+ bytes,
+ reason
+ );
+ self.maybe_poison();
+ }
+
+ /// Whole-track fallback: after enough AUs to judge, if more than half were
+ /// dropped the track is too damaged to be worth muxing — latch `poisoned`
+ /// and log it loudly once. The minimum-sample gate keeps a short damaged
+ /// burst from poisoning an otherwise-good track.
+ fn maybe_poison(&mut self) {
+ if self.poisoned {
+ return;
+ }
+ let total = self.kept + self.dropped;
+ if total >= TRACK_VERDICT_MIN_AUS && self.dropped * 2 > total {
+ self.poisoned = true;
+ tracing::warn!(
+ target: "mux",
+ "{}: track too damaged to mux — {}/{} AUs undecodable (>50%); dropping the whole track",
+ self.codec,
+ self.dropped,
+ total
+ );
+ }
+ }
+
+ /// End-of-stream aggregate report, logged at `warn` so a track's dropped
+ /// audio is never hidden even without debug logging. No-op if nothing was
+ /// dropped.
+ pub(crate) fn log_summary(&self) {
+ if self.dropped > 0 {
+ tracing::warn!(
+ target: "mux",
+ "{}: dropped {} undecodable AU(s) totaling {} ns of audio ({} kept)",
+ self.codec,
+ self.dropped,
+ self.dropped_dur_ns,
+ self.kept
+ );
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn counts_kept_and_dropped() {
+ let mut t = DropTally::new("test");
+ t.record_kept();
+ t.record_drop(0, 1000, 512, "bad");
+ t.record_kept();
+ assert_eq!(t.dropped_frames(), 1);
+ assert_eq!(t.dropped_duration_ns(), 1000);
+ assert!(!t.is_poisoned());
+ }
+
+ #[test]
+ fn poisons_after_min_aus_over_half_dropped() {
+ let mut t = DropTally::new("test");
+ // 199 AUs, all dropped: below the min-AU gate, must NOT poison yet.
+ for _ in 0..199 {
+ t.record_drop(0, 1000, 512, "bad");
+ }
+ assert!(!t.is_poisoned(), "below the 200-AU minimum, no verdict");
+ // The 200th drop reaches the minimum with >50% dropped → poison.
+ t.record_drop(0, 1000, 512, "bad");
+ assert!(t.is_poisoned());
+ }
+
+ #[test]
+ fn does_not_poison_a_mostly_good_track() {
+ let mut t = DropTally::new("test");
+ // 400 AUs, 1 dropped: nowhere near 50%.
+ t.record_drop(0, 1000, 512, "bad");
+ for _ in 0..399 {
+ t.record_kept();
+ }
+ assert!(!t.is_poisoned());
+ }
+}
diff --git a/src/mux/codec/dts.rs b/src/mux/codec/dts.rs
index 1d8f972..e10883a 100644
--- a/src/mux/codec/dts.rs
+++ b/src/mux/codec/dts.rs
@@ -49,6 +49,12 @@ pub struct DtsParser {
/// running cursor: previous emit + its duration). Only consulted when
/// `front_pts` is unchanged from `last_front_pts`. `PTS_UNSET` = no base yet.
next_pts_ns: i64,
+ /// Keep/drop bookkeeping for the decodability gate: counts, per-drop and
+ /// aggregate logging, and the whole-track poison fallback. A dropped AU is
+ /// NEVER emitted, but the PTS clock is still advanced across it (see
+ /// [`stamp_pts`] usage) so every SURVIVING AU keeps the exact timestamp it
+ /// would have had — a drop becomes a silence gap, never a shift.
+ tally: super::dropgate::DropTally,
}
impl Default for DtsParser {
@@ -65,6 +71,50 @@ impl DtsParser {
pts_marks: std::collections::VecDeque::new(),
last_front_pts: PTS_UNSET,
next_pts_ns: PTS_UNSET,
+ tally: super::dropgate::DropTally::new("dts"),
+ }
+ }
+
+ /// Number of access units dropped as undecodable so far. The mux/CLI reads
+ /// this to surface the count ("dropped N damaged DTS frames").
+ pub fn dropped_frames(&self) -> u64 {
+ self.tally.dropped_frames()
+ }
+
+ /// Total decoded duration (ns) of all dropped access units — the length of
+ /// audio silence introduced by dropping undecodable frames.
+ pub fn dropped_duration_ns(&self) -> u64 {
+ self.tally.dropped_duration_ns()
+ }
+
+ /// Gate an assembled access unit through the decodability check and either
+ /// push it or drop it. `au_pts`/`dur_ns` are already stamped on the shared
+ /// PTS clock (which the caller advances whether or not the AU survives), so
+ /// a drop leaves the following audio on its true timeline — a gap, not a
+ /// shift. Every drop is logged (fail-loud, never silent).
+ fn emit_or_drop(&mut self, au: Vec, au_pts: i64, dur_ns: i64, out: &mut Vec) {
+ let verdict = if self.tally.is_poisoned() {
+ Err(DropReason::TrackPoisoned)
+ } else {
+ core_header_drop_reason(&au).map_or(Ok(()), Err)
+ };
+ match verdict {
+ Ok(()) => {
+ self.tally.record_kept();
+ out.push(Frame {
+ discontinuity: false,
+ coding: None,
+ source: None,
+ pts_ns: au_pts,
+ keyframe: true,
+ data: au,
+ duration_ns: Some(dur_ns as u64),
+ });
+ }
+ Err(reason) => {
+ self.tally
+ .record_drop(au_pts, dur_ns, au.len(), reason.as_str());
+ }
}
}
@@ -332,16 +382,12 @@ impl CodecParser for DtsParser {
// running clock (UHD one-AU-per-PES), but never allowed to collide
// with the previous AU when several cores share ONE PES (DVD).
let dur_ns = dts_core_duration_ns(&au) as i64;
+ // Advance the PTS clock for this AU BEFORE the decodability gate, so
+ // a dropped AU still advances the timeline exactly as an emitted one
+ // would: the following AU keeps its true PTS and the drop is a gap,
+ // never a shift. `emit_or_drop` decides whether to actually push it.
let au_pts = self.stamp_pts(self.front_pts(), dur_ns);
- frames.push(Frame {
- discontinuity: false,
- coding: None,
- source: None,
- pts_ns: au_pts,
- keyframe: true,
- data: au,
- duration_ns: Some(dur_ns as u64),
- });
+ self.emit_or_drop(au, au_pts, dur_ns, &mut frames);
self.drain_front(au_end);
// After draining, the marker covering the new front (if any) carries
// the next AU's PTS; `pending_pts` is only the fallback when no
@@ -368,10 +414,23 @@ impl CodecParser for DtsParser {
}
fn flush(&mut self) -> Vec {
- // End of stream: emit the final access unit still buffered (the last
- // core + its extension substreams, which had no following core sync to
- // close it during streaming). Require a complete core frame; drop a
- // bare partial sync tail.
+ let out = self.flush_tail();
+ // Aggregate drop report at end-of-stream (warn-level, always visible).
+ self.tally.log_summary();
+ out
+ }
+
+ fn codec_private(&self) -> Option> {
+ None
+ }
+}
+
+impl DtsParser {
+ /// Emit the final buffered access unit (the last core + its extension
+ /// substreams, which had no following core sync to close it during
+ /// streaming), gated through the decodability check. Require a complete core
+ /// frame; drop a bare partial sync tail.
+ fn flush_tail(&mut self) -> Vec {
if find_sync(&self.buf, &DTS_CORE_SYNC) != Some(0) || self.buf.len() < CORE_HEADER_MIN_BYTES
{
self.buf.clear();
@@ -390,19 +449,9 @@ impl CodecParser for DtsParser {
let dur_ns = dts_core_duration_ns(&au) as i64;
let pts_ns = self.stamp_pts(self.front_pts(), dur_ns);
self.pts_marks.clear();
- vec![Frame {
- discontinuity: false,
- coding: None,
- source: None,
- pts_ns,
- keyframe: true,
- data: au,
- duration_ns: Some(dur_ns as u64),
- }]
- }
-
- fn codec_private(&self) -> Option> {
- None
+ let mut out = Vec::new();
+ self.emit_or_drop(au, pts_ns, dur_ns, &mut out);
+ out
}
}
@@ -624,6 +673,128 @@ fn dts_core_duration_ns(data: &[u8]) -> u64 {
(samples * 1_000_000_000 + rate / 2) / rate
}
+/// DCA core-header constants, mirrored from ffmpeg `libavcodec/dca_core.h`.
+/// `deficit_samples` must equal this (`DCA_PCMBLOCK_SAMPLES`); `npcmblocks`
+/// must be a multiple of `DCA_SUBBAND_SAMPLES`; `audio_mode` must be below
+/// `DCA_AMODE_COUNT`; `lfe_present == DCA_LFE_FLAG_INVALID` is rejected.
+const DTS_PCMBLOCK_SAMPLES: u32 = 32;
+const DTS_SUBBAND_SAMPLES: u32 = 8;
+const DTS_AMODE_COUNT: u32 = 10;
+const DTS_LFE_FLAG_INVALID: u32 = 3;
+
+/// `ff_dca_sample_rates[16]` — sample rate (Hz) per core `SFREQ` code; a `0`
+/// entry marks a reserved code that ffmpeg's parser rejects
+/// (`DCA_PARSE_ERROR_SAMPLE_RATE`). Valid entries are locked to the spec by
+/// `dts_core_sfreq_table_matches_the_dca_spec`; the reserved codes are
+/// {0, 4, 5, 9, 10}.
+const DTS_CORE_SR_VALID: [u32; 16] = [
+ 0, 8_000, 16_000, 32_000, 0, 0, 11_025, 22_050, 44_100, 0, 0, 12_000, 24_000, 48_000, 96_000,
+ 192_000,
+];
+
+/// `ff_dca_bits_per_sample[8]` — a `0` entry marks a reserved `PCMR` code that
+/// ffmpeg's parser rejects (`DCA_PARSE_ERROR_PCM_RES`); reserved codes are
+/// {4, 7}.
+const DTS_CORE_PCMR_BITS: [u8; 8] = [16, 16, 20, 20, 0, 24, 24, 0];
+
+/// Why an access unit was judged undecodable. Each core-header variant is the
+/// exact condition under which ffmpeg's `ff_dca_parse_core_frame_header` returns
+/// the matching `DCA_PARSE_ERROR_*`; `TrackPoisoned` is our whole-track drop.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum DropReason {
+ DeficitSamples,
+ PcmBlocks,
+ FrameSize,
+ Amode,
+ SampleRate,
+ ReservedBit,
+ LfeFlag,
+ PcmRes,
+ TrackPoisoned,
+}
+
+impl DropReason {
+ /// Short static label for the drop log (the shared tally logs `&str`).
+ fn as_str(&self) -> &'static str {
+ match self {
+ DropReason::DeficitSamples => "deficit-samples",
+ DropReason::PcmBlocks => "pcm-blocks",
+ DropReason::FrameSize => "frame-size",
+ DropReason::Amode => "audio-mode",
+ DropReason::SampleRate => "sample-rate",
+ DropReason::ReservedBit => "reserved-bit",
+ DropReason::LfeFlag => "lfe-flag",
+ DropReason::PcmRes => "pcm-resolution",
+ DropReason::TrackPoisoned => "track-poisoned",
+ }
+ }
+}
+
+/// Decodability gate: a faithful port of ffmpeg's `ff_dca_parse_core_frame_header`
+/// validity checks (libavcodec/dca.c). Returns `Some(reason)` when ffmpeg's own
+/// parser would reject this core frame's header — in which case the packet is
+/// undecodable ("Invalid data found") and dropping it loses nothing a decoder
+/// could have used. Returns `None` (keep) for a decodable header OR if the
+/// header can't be fully read (never false-drop on our own buffer underrun; the
+/// framer only emits AUs whose core is fully buffered and ≥ 96 bytes).
+///
+/// The 4-byte core sync is already validated by the framer, so this reads the
+/// header fields that follow it. ffmpeg's parser does NOT verify the CPF header
+/// CRC (it `skip_bits(16)` past it — dca.c) and the core decoder likewise skips
+/// the audio-header/side-info CRCs (dca_core.c), so no CRC check is mirrored
+/// here: doing so would drop frames ffmpeg decodes fine (false positives).
+fn core_header_drop_reason(au: &[u8]) -> Option {
+ let mut r = BitReader::new(au.get(SYNCWORD_BYTES..)?);
+
+ let _ftype = r.read_bit()?;
+ let deficit_samples = r.read_bits(5)? + 1;
+ if deficit_samples != DTS_PCMBLOCK_SAMPLES {
+ return Some(DropReason::DeficitSamples);
+ }
+ let crc_present = r.read_bit()? == 1;
+ let npcmblocks = r.read_bits(7)? + 1;
+ if npcmblocks & (DTS_SUBBAND_SAMPLES - 1) != 0 {
+ return Some(DropReason::PcmBlocks);
+ }
+ let frame_size = r.read_bits(14)? + 1;
+ if frame_size < MIN_CORE_FRAME_BYTES as u32 {
+ return Some(DropReason::FrameSize);
+ }
+ let audio_mode = r.read_bits(6)?;
+ if audio_mode >= DTS_AMODE_COUNT {
+ return Some(DropReason::Amode);
+ }
+ let sr_code = r.read_bits(4)? as usize;
+ if DTS_CORE_SR_VALID[sr_code] == 0 {
+ return Some(DropReason::SampleRate);
+ }
+ let _br_code = r.read_bits(5)?;
+ if r.read_bit()? != 0 {
+ return Some(DropReason::ReservedBit);
+ }
+ // drc, ts, aux, hdcd (1 each) → ext_audio_type (3) → ext_present, aspf (1 each).
+ r.skip_bits(4)?;
+ r.skip_bits(3)?;
+ r.skip_bits(2)?;
+ let lfe_present = r.read_bits(2)?;
+ if lfe_present == DTS_LFE_FLAG_INVALID {
+ return Some(DropReason::LfeFlag);
+ }
+ let _predictor_history = r.read_bit()?;
+ if crc_present {
+ // ffmpeg only skips the 16-bit header CRC here — it is not verified.
+ r.skip_bits(16)?;
+ }
+ let _filter_perfect = r.read_bit()?;
+ let _encoder_rev = r.read_bits(4)?;
+ let _copy_hist = r.read_bits(2)?;
+ let pcmr_code = r.read_bits(3)? as usize;
+ if DTS_CORE_PCMR_BITS[pcmr_code] == 0 {
+ return Some(DropReason::PcmRes);
+ }
+ None
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -644,6 +815,11 @@ mod tests {
let fsize = size - 1;
let mut data = vec![0u8; size];
data[0..4].copy_from_slice(&DTS_CORE_SYNC);
+ // byte4: FTYPE(0) SHORT(5) CPF(0) NBLKS-high(0). SHORT = 31 makes
+ // deficit_samples = 32 = DCA_PCMBLOCK_SAMPLES, which ffmpeg's parser
+ // (and our decodability gate) require of a real core frame. NBLKS high
+ // bit (byte4 bit0) stays 0 for NBLKS = 15.
+ data[4] = 31u8 << 2;
// NBLKS = 15 → (15+1)*32 = 512 samples/frame (the DVD/UHD DTS-core norm).
// NBLKS is byte4 bit0 + byte5 bits7-2; here byte4 bit0 = 0, byte5 = 15<<2.
data[5] = (15u8 << 2) | ((fsize >> 12) & 0x03) as u8;
@@ -1553,4 +1729,247 @@ mod tests {
"sub-floor sync skipped, real 512 core is AU1"
);
}
+
+ /// A structurally-framed but UNDECODABLE core: a valid `make_dts_core`
+ /// whose reserved header bit is set. It still sizes and syncs correctly (so
+ /// the framer delimits it normally), but ffmpeg's `ff_dca_parse_core_frame_header`
+ /// — and our port — reject it (`DCA_PARSE_ERROR_RESERVED_BIT`). The reserved
+ /// bit is byte9 bit4 in the core header (after SYNC..RATE).
+ fn make_bad_dts_core(size: usize) -> Vec {
+ let mut d = make_dts_core(size);
+ assert!(
+ core_header_drop_reason(&d).is_none(),
+ "base core is decodable"
+ );
+ d[9] |= 0x10; // set the reserved bit
+ assert_eq!(
+ core_header_drop_reason(&d),
+ Some(DropReason::ReservedBit),
+ "reserved-bit core must be judged undecodable"
+ );
+ d
+ }
+
+ #[test]
+ fn valid_stream_drops_nothing() {
+ // A clean stream of decodable cores must pass the gate untouched — the
+ // detector is an exact ffmpeg-parity port, so zero false positives.
+ let mut parser = DtsParser::new();
+ let mut stream = Vec::new();
+ for _ in 0..5 {
+ stream.extend_from_slice(&make_dts_core(512));
+ }
+ let mut frames = parser.parse(&make_pes(stream, Some(90000)));
+ frames.extend(parser.flush());
+ assert_eq!(frames.len(), 5, "all five cores emitted");
+ assert_eq!(parser.dropped_frames(), 0, "nothing dropped");
+ assert_eq!(parser.dropped_duration_ns(), 0);
+ }
+
+ #[test]
+ fn undecodable_core_is_dropped_and_counted() {
+ // A single undecodable core between good ones is dropped; the survivors
+ // are emitted and the drop is counted.
+ let mut parser = DtsParser::new();
+ let mut stream = make_dts_core(512);
+ stream.extend_from_slice(&make_bad_dts_core(512));
+ stream.extend_from_slice(&make_dts_core(640));
+ let mut frames = parser.parse(&make_pes(stream, Some(90000)));
+ frames.extend(parser.flush());
+ assert_eq!(frames.len(), 2, "the bad core is dropped, two survive");
+ assert_eq!(frames[0].data.len(), 512);
+ assert_eq!(frames[1].data.len(), 640);
+ assert_eq!(parser.dropped_frames(), 1);
+ assert_eq!(
+ parser.dropped_duration_ns(),
+ DTS_CORE_DUR_NS as u64,
+ "one frame's worth of audio silence introduced"
+ );
+ }
+
+ #[test]
+ fn drop_preserves_av_sync_no_shift() {
+ // THE INVARIANT: dropping an undecodable AU must never shift the audio
+ // that follows. A good/bad/good run in ONE PES — the bad middle core is
+ // dropped, but the trailing good core must keep the EXACT PTS it would
+ // have had with no drop (base + 2 frame durations), so the drop is a
+ // silence gap, not a shift.
+ let mut parser = DtsParser::new();
+ let mut stream = make_dts_core(512); // c1: good
+ stream.extend_from_slice(&make_bad_dts_core(512)); // c2: undecodable
+ stream.extend_from_slice(&make_dts_core(640)); // c3: good
+ let mut frames = parser.parse(&make_pes(stream, Some(90000)));
+ frames.extend(parser.flush());
+
+ assert_eq!(frames.len(), 2, "c2 dropped; c1 and c3 survive");
+ let base = pts_to_ns(90000);
+ assert_eq!(frames[0].pts_ns, base, "c1 keeps the PES base PTS");
+ assert_eq!(
+ frames[1].pts_ns,
+ base + 2 * DTS_CORE_DUR_NS,
+ "c3 keeps its TRUE timeline (base + 2 frames) — the drop is a gap, not a shift"
+ );
+ // The gap between the survivors is exactly the dropped frame's duration
+ // beyond the normal one-frame spacing.
+ assert_eq!(
+ frames[1].pts_ns - frames[0].pts_ns,
+ 2 * DTS_CORE_DUR_NS,
+ "surviving AUs are spaced by the real timeline including the dropped frame's slot"
+ );
+ assert_eq!(parser.dropped_frames(), 1);
+ }
+
+ #[test]
+ fn whole_track_poison_drops_remainder() {
+ // A track dominated by undecodable frames is judged too damaged to mux:
+ // once the >50% verdict fires (after the minimum sample count), the
+ // whole track — including any later good frames — is dropped.
+ let mut parser = DtsParser::new();
+ let mut stream = Vec::new();
+ // 300 AUs, ~2/3 undecodable → well over the 50% threshold and the
+ // 200-AU minimum.
+ for i in 0..300 {
+ if i % 3 == 0 {
+ stream.extend_from_slice(&make_dts_core(512));
+ } else {
+ stream.extend_from_slice(&make_bad_dts_core(512));
+ }
+ }
+ // A trailing burst of GOOD cores that must be dropped once poisoned.
+ for _ in 0..20 {
+ stream.extend_from_slice(&make_dts_core(512));
+ }
+ let mut frames = parser.parse(&make_pes(stream, Some(90000)));
+ frames.extend(parser.flush());
+ assert!(
+ parser.tally.is_poisoned(),
+ "track poisoned by >50% drop rate"
+ );
+ // Once poisoned, later good cores are dropped too, so the kept count
+ // (kept = emitted survivors) is far below the ~120 good cores present.
+ let kept = frames.len() as u64;
+ assert!(
+ kept < 120,
+ "post-poison good frames also dropped (kept={kept})"
+ );
+ assert!(parser.dropped_frames() > 150, "majority dropped");
+ }
+
+ #[test]
+ fn sr_validity_table_marks_reserved_codes() {
+ // The core-header sample-rate validity table must have ZERO (reject) at
+ // exactly the reserved SFREQ codes {0,4,5,9,10} and a real rate
+ // elsewhere — this is what mirrors ffmpeg's DCA_PARSE_ERROR_SAMPLE_RATE.
+ for code in 0..16usize {
+ let reserved = matches!(code, 0 | 4 | 5 | 9 | 10);
+ assert_eq!(
+ DTS_CORE_SR_VALID[code] == 0,
+ reserved,
+ "SFREQ code {code} reserved={reserved}"
+ );
+ }
+ }
+
+ #[test]
+ fn every_core_header_error_class_is_detected() {
+ // Exercise each ffmpeg-parity rejection so the port stays faithful.
+ // Start from a decodable core and corrupt one field at a time.
+ let good = make_dts_core(512);
+ assert_eq!(core_header_drop_reason(&good), None);
+
+ // deficit_samples != 32: clear SHORT (byte4 bits6-2) → deficit = 1.
+ let mut d = good.clone();
+ d[4] &= !0x7C;
+ assert_eq!(
+ core_header_drop_reason(&d),
+ Some(DropReason::DeficitSamples)
+ );
+
+ // npcmblocks not a multiple of 8: NBLKS low bits (byte5 bits7-2) → 14
+ // (npcmblocks=15, 15 & 7 = 7 ≠ 0).
+ let mut d = good.clone();
+ d[5] = (d[5] & 0x03) | (14u8 << 2);
+ assert_eq!(core_header_drop_reason(&d), Some(DropReason::PcmBlocks));
+
+ // audio_mode >= 10: AMODE = byte7 bits3-0 (high 4) + byte8 bits7-6. Set
+ // AMODE high nibble to 0xF → audio_mode >= 60.
+ let mut d = good.clone();
+ d[7] |= 0x0F;
+ assert_eq!(core_header_drop_reason(&d), Some(DropReason::Amode));
+
+ // sample_rate reserved: SFREQ (byte8 bits5-2) = 0.
+ let mut d = good.clone();
+ d[8] &= !0x3C;
+ assert_eq!(core_header_drop_reason(&d), Some(DropReason::SampleRate));
+
+ // reserved bit set: byte9 bit4.
+ let mut d = good.clone();
+ d[9] |= 0x10;
+ assert_eq!(core_header_drop_reason(&d), Some(DropReason::ReservedBit));
+
+ // lfe_present == 3: LFE is byte10 bits2-1.
+ let mut d = good.clone();
+ d[10] |= 0x06;
+ assert_eq!(core_header_drop_reason(&d), Some(DropReason::LfeFlag));
+
+ // pcmr_code reserved (7): pcmr is byte11 bit0 + byte12 bits7-6 → set all
+ // three to 1 (code 7 → ff_dca_bits_per_sample[7] = 0).
+ let mut d = good.clone();
+ d[11] |= 0x01;
+ d[12] |= 0xC0;
+ assert_eq!(core_header_drop_reason(&d), Some(DropReason::PcmRes));
+ }
+
+ /// Real-data fixture (ignored). Re-parses a raw `.dts` elementary stream
+ /// through `DtsParser` and writes the emitted access units back out, so the
+ /// garbage-extension → core-only drop can be validated against an actual
+ /// damaged stream (e.g. the extracted Bourne DTS-HD MA track) end-to-end
+ /// with ffmpeg. Env: `DTS_IN` (input), `DTS_OUT` (output).
+ /// cargo test --lib dts::tests::reparse_real_dts_file -- --ignored --nocapture
+ #[test]
+ #[ignore]
+ fn reparse_real_dts_file() {
+ use std::io::Write;
+ let inp = std::env::var("DTS_IN").expect("DTS_IN");
+ let outp = std::env::var("DTS_OUT").expect("DTS_OUT");
+ let bytes = std::fs::read(&inp).expect("read DTS_IN");
+ let mut parser = DtsParser::new();
+ let mut out =
+ std::io::BufWriter::new(std::fs::File::create(&outp).expect("create DTS_OUT"));
+ let mut au_count = 0usize;
+ let mut out_bytes = 0usize;
+ // 90 kHz PTS advancing per chunk; arbitrary chunking is faithful because
+ // the framer resyncs on core sync and buffers across PES boundaries.
+ let mut pts: i64 = 90_000;
+ const CHUNK: usize = 64 * 1024;
+ for chunk in bytes.chunks(CHUNK) {
+ let pes = PesPacket {
+ source: None,
+ pid: 0x1100,
+ pts: Some(pts),
+ dts: None,
+ data: chunk.to_vec(),
+ discontinuity: false,
+ };
+ pts += 2_100; // ~one AU worth; value irrelevant to AU framing/bytes
+ for f in parser.parse(&pes) {
+ au_count += 1;
+ out_bytes += f.data.len();
+ out.write_all(&f.data).expect("write AU");
+ }
+ }
+ for f in parser.flush() {
+ au_count += 1;
+ out_bytes += f.data.len();
+ out.write_all(&f.data).expect("write AU");
+ }
+ out.flush().expect("flush");
+ eprintln!(
+ "REPARSE in={} bytes -> out={} bytes across {} AUs ({} bytes dropped)",
+ bytes.len(),
+ out_bytes,
+ au_count,
+ bytes.len().saturating_sub(out_bytes)
+ );
+ }
}
diff --git a/src/mux/codec/flac.rs b/src/mux/codec/flac.rs
new file mode 100644
index 0000000..eaebd01
--- /dev/null
+++ b/src/mux/codec/flac.rs
@@ -0,0 +1,218 @@
+//! FLAC elementary-stream decodability gate.
+//!
+//! FLAC frames carry no length field, so a raw stream is delimited only by
+//! sync-scanning + CRC validation. In freemkv, though, FLAC never arrives raw:
+//! it comes from mp4/mkv, where each packet is exactly one container-delimited
+//! FLAC frame (the `PARSER_FLAG_COMPLETE_FRAMES` case in ffmpeg). So this parser
+//! is a per-packet gate, not a framer: every FLAC frame ends with a 16-bit CRC
+//! (poly 0x8005) computed so the residue over the whole frame is zero
+//! (ffmpeg `flac_decode_frame`, `av_crc(AV_CRC_16_ANSI, 0, buf, len) == 0`). A
+//! nonzero residue is definitive corruption → drop the frame (a silence gap,
+//! never a shift — each packet keeps its own PTS), logged via the shared tally.
+//!
+//! A packet that does not begin with the FLAC frame sync is not a delimited
+//! frame we can validate, so it is passed through unchanged (never false-dropped).
+
+use super::crc::crc16_ansi;
+use super::dropgate::DropTally;
+use super::{CodecParser, Frame, PesPacket, pts_to_ns};
+
+/// FLAC frame sync: 14-bit code `0x3FFE` + a mandatory-0 reserved bit; the next
+/// bit (blocking strategy) is masked off. ffmpeg tests `(AV_RB16 & 0xFFFE) ==
+/// 0xFFF8` (flac_parser.c).
+fn has_flac_sync(data: &[u8]) -> bool {
+ data.len() >= 2 && ((u16::from(data[0]) << 8 | u16::from(data[1])) & 0xFFFE) == 0xFFF8
+}
+
+/// Block-size code → samples, `ff_flac_blocksize_table` (0 = reserved/explicit).
+const FLAC_BLOCKSIZE_TABLE: [u32; 16] = [
+ 0, 192, 576, 1152, 2304, 4608, 0, 0, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768,
+];
+/// Sample-rate code → Hz, `ff_flac_sample_rate_table` (0 = STREAMINFO/explicit).
+const FLAC_SAMPLE_RATE_TABLE: [u32; 16] = [
+ 0, 88_200, 176_400, 192_000, 8_000, 16_000, 22_050, 24_000, 32_000, 44_100, 48_000, 96_000, 0,
+ 0, 0, 0,
+];
+
+/// Best-effort duration (ns) of a FLAC frame from its header block-size and
+/// sample-rate codes (byte 2). Only the table-coded cases are resolved; the
+/// explicit-in-trailing-bytes codes (block 6/7, rate 12/13/14) and
+/// STREAMINFO-derived (code 0) return `None`. Used only for the dropped-audio
+/// accounting, so a `None` (→ 0) is harmless.
+fn flac_frame_duration_ns(frame: &[u8]) -> Option {
+ if frame.len() < 3 {
+ return None;
+ }
+ let bs_code = (frame[2] >> 4) & 0x0F;
+ let sr_code = frame[2] & 0x0F;
+ let blocksize = FLAC_BLOCKSIZE_TABLE[bs_code as usize];
+ let rate = FLAC_SAMPLE_RATE_TABLE[sr_code as usize];
+ if blocksize == 0 || rate == 0 {
+ return None;
+ }
+ Some((blocksize as i64 * 1_000_000_000 + rate as i64 / 2) / rate as i64)
+}
+
+pub struct FlacParser {
+ tally: DropTally,
+}
+
+impl Default for FlacParser {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl FlacParser {
+ pub fn new() -> Self {
+ Self {
+ tally: DropTally::new("flac"),
+ }
+ }
+
+ /// Access units dropped as undecodable so far.
+ pub fn dropped_frames(&self) -> u64 {
+ self.tally.dropped_frames()
+ }
+
+ /// Total decoded duration (ns) of dropped access units.
+ pub fn dropped_duration_ns(&self) -> u64 {
+ self.tally.dropped_duration_ns()
+ }
+}
+
+impl CodecParser for FlacParser {
+ fn parse(&mut self, pes: &PesPacket) -> Vec {
+ if pes.data.is_empty() {
+ return Vec::new();
+ }
+ let pts_ns = pes.pts.or(pes.dts).map(pts_to_ns).unwrap_or(0);
+
+ // Gate: a packet that begins with a FLAC frame sync but whose whole-frame
+ // CRC-16 residue is nonzero is corrupt → drop. Anything else passes
+ // through (a non-sync packet is not a frame we can validate; a poisoned
+ // track drops everything).
+ let corrupt = has_flac_sync(&pes.data) && crc16_ansi(&pes.data) != 0;
+ if self.tally.is_poisoned() || corrupt {
+ let reason = if self.tally.is_poisoned() {
+ "track-poisoned"
+ } else {
+ "crc"
+ };
+ let dur = flac_frame_duration_ns(&pes.data).unwrap_or(0);
+ self.tally.record_drop(pts_ns, dur, pes.data.len(), reason);
+ return Vec::new();
+ }
+
+ self.tally.record_kept();
+ vec![Frame {
+ discontinuity: pes.discontinuity,
+ coding: None,
+ source: None,
+ pts_ns,
+ keyframe: true,
+ data: pes.data.clone(),
+ duration_ns: None,
+ }]
+ }
+
+ fn flush(&mut self) -> Vec {
+ self.tally.log_summary();
+ Vec::new()
+ }
+
+ fn codec_private(&self) -> Option> {
+ None
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn make_pes(data: Vec, pts: Option) -> PesPacket {
+ PesPacket {
+ source: None,
+ pid: 0x1100,
+ pts,
+ dts: None,
+ data,
+ discontinuity: false,
+ }
+ }
+
+ /// A minimal FLAC-frame-shaped buffer: sync `0xFFF8`, a plausible header
+ /// (block code 1 = 192 samples, rate code 9 = 44.1 kHz), some payload, and a
+ /// trailing CRC-16 so the whole-frame residue is zero (a valid frame).
+ fn make_flac_frame(payload_len: usize) -> Vec {
+ let mut f = vec![0u8; 6 + payload_len + 2];
+ f[0] = 0xFF;
+ f[1] = 0xF8; // sync + fixed blocksize
+ f[2] = (1 << 4) | 9; // bs_code=1 (192), sr_code=9 (44100)
+ // bytes 3..end-2 arbitrary; last two bytes carry the CRC-16.
+ let n = f.len();
+ let c = crc16_ansi(&f[..n - 2]);
+ f[n - 2] = (c >> 8) as u8;
+ f[n - 1] = (c & 0xFF) as u8;
+ assert_eq!(crc16_ansi(&f), 0, "finalized frame has zero residue");
+ f
+ }
+
+ #[test]
+ fn valid_frame_is_kept() {
+ let mut p = FlacParser::new();
+ let f = p.parse(&make_pes(make_flac_frame(100), Some(90000)));
+ assert_eq!(f.len(), 1);
+ assert_eq!(f[0].pts_ns, pts_to_ns(90000));
+ assert_eq!(p.dropped_frames(), 0);
+ }
+
+ #[test]
+ fn corrupt_frame_is_dropped() {
+ let mut p = FlacParser::new();
+ let mut frame = make_flac_frame(100);
+ frame[20] ^= 0xFF; // corrupt a payload byte → CRC residue nonzero
+ assert!(crc16_ansi(&frame) != 0);
+ let f = p.parse(&make_pes(frame, Some(90000)));
+ assert!(f.is_empty(), "corrupt FLAC frame dropped");
+ assert_eq!(p.dropped_frames(), 1);
+ // 192 samples @ 44.1 kHz ≈ 4.354 ms of silence accounted.
+ assert_eq!(
+ p.dropped_duration_ns(),
+ (192u64 * 1_000_000_000 + 44_100 / 2) / 44_100
+ );
+ }
+
+ #[test]
+ fn corrupt_drop_preserves_sync_via_own_pts() {
+ // Each packet carries its own PTS, so dropping one leaves the next frame
+ // on its true timeline — a gap, not a shift.
+ let mut p = FlacParser::new();
+ let mut bad = make_flac_frame(100);
+ bad[20] ^= 0xFF;
+ assert!(p.parse(&make_pes(bad, Some(90000))).is_empty());
+ let f = p.parse(&make_pes(make_flac_frame(100), Some(96000)));
+ assert_eq!(f.len(), 1);
+ assert_eq!(
+ f[0].pts_ns,
+ pts_to_ns(96000),
+ "surviving frame keeps its own container PTS — the drop is a gap"
+ );
+ }
+
+ #[test]
+ fn non_flac_packet_passes_through() {
+ // A packet without the FLAC sync isn't a frame we can validate — never
+ // false-drop it.
+ let mut p = FlacParser::new();
+ let f = p.parse(&make_pes(vec![0x00, 0x01, 0x02, 0x03], Some(0)));
+ assert_eq!(f.len(), 1, "unrecognized packet passed through");
+ assert_eq!(p.dropped_frames(), 0);
+ }
+
+ #[test]
+ fn empty_pes_emits_nothing() {
+ let mut p = FlacParser::new();
+ assert!(p.parse(&make_pes(Vec::new(), Some(0))).is_empty());
+ }
+}
diff --git a/src/mux/codec/mod.rs b/src/mux/codec/mod.rs
index 3ab4aac..3d8579a 100644
--- a/src/mux/codec/mod.rs
+++ b/src/mux/codec/mod.rs
@@ -9,12 +9,19 @@
/// AC-3 / E-AC-3 (Dolby Digital / Digital Plus) elementary-stream parser.
pub mod ac3;
+
+pub mod adts;
/// Codec-agnostic per-picture coding carrier (`PictureInfo` + accessors).
pub mod coding;
/// DTS / DTS-HD elementary-stream parser.
+pub(crate) mod crc;
+pub(crate) mod dropgate;
+
pub mod dts;
/// DVD bitmap subtitle (VobSub) parser.
pub mod dvdsub;
+
+pub mod flac;
/// H.264 (AVC) Annex-B elementary-stream parser.
pub mod h264;
/// HEVC (H.265) Annex-B elementary-stream parser.
@@ -23,6 +30,8 @@ pub mod hevc;
pub mod lpcm;
/// MPEG-2 Video elementary-stream parser.
pub mod mpeg2;
+
+pub mod mpegaudio;
/// HDMV PGS (Presentation Graphics Stream) subtitle parser.
pub mod pgs;
/// Display-order PTS reconstruction for sparse-PTS program-stream video.
@@ -154,6 +163,26 @@ impl CodecParser for PassthroughParser {
}
}
+/// Drop-on-undecodable policy across codecs ("clean muxes always"):
+///
+/// - **Audio with independent access units** (DTS, AC-3/E-AC-3, …) gates each AU
+/// through a per-codec corruption check and drops the ones that fail, keeping
+/// A/V sync (a drop is a silence gap, never a shift) and logging every drop
+/// via the shared [`dropgate::DropTally`]. DTS uses ffmpeg's core-header parse;
+/// AC-3 uses its native frame CRC.
+/// - **LPCM is excluded on purpose**: raw PCM carries no framing or integrity
+/// data, so a corrupt sample is indistinguishable from a quiet one — there is
+/// nothing to detect, so nothing can be honestly dropped.
+/// - **Video is excluded on purpose**: H.264/HEVC/MPEG-2/VC-1 are inter-frame
+/// predicted, so dropping one frame corrupts every frame that references it
+/// until the next keyframe. Video instead resyncs at GOP/IDR boundaries (the
+/// ResyncGate) and lets the decoder conceal — a fundamentally different model
+/// than per-frame audio dropping.
+/// - TrueHD/MLP and the rare passthrough audio codecs (FLAC/MP2/AAC) do not yet
+/// gate: MLP carries inter-AU restart state so a safe drop must land on a
+/// major-sync boundary, and the passthrough codecs are essentially never seen
+/// on optical media.
+///
/// Create the appropriate parser for a codec, with optional codec private data.
///
/// For DvdSub, `codec_data` should be the pre-formatted VobSub .idx palette header.
@@ -177,6 +206,9 @@ pub fn parser_for_codec(
Codec::Mpeg2 => Box::new(mpeg2::Mpeg2Parser::new()),
Codec::Vc1 => Box::new(vc1::Vc1Parser::new().with_ps_reorder(is_dvd_ps)),
Codec::Ac3 | Codec::Ac3Plus => Box::new(ac3::Ac3Parser::new()),
+ Codec::Flac => Box::new(flac::FlacParser::new()),
+ Codec::Mp2 | Codec::Mp3 => Box::new(mpegaudio::MpegAudioParser::new()),
+ Codec::Aac => Box::new(adts::AdtsParser::new()),
Codec::DtsHdMa | Codec::DtsHdHr | Codec::Dts => Box::new(dts::DtsParser::new()),
Codec::TrueHd => Box::new(truehd::TrueHdParser::new()),
Codec::Pgs => Box::new(pgs::PgsParser::new()),
@@ -200,9 +232,7 @@ pub fn parser_for_codec(
// Remaining audio-only codecs (Aac, Mp2, Mp3, Flac, Opus) where PES =
// frame: all-keyframe passthrough is correct. Subtitle/Unknown also land
// here; keyframe flag is irrelevant for them.
- Codec::Aac | Codec::Mp2 | Codec::Mp3 | Codec::Flac | Codec::Opus => {
- Box::new(PassthroughParser::new(true))
- }
+ Codec::Opus => Box::new(PassthroughParser::new(true)),
Codec::Srt | Codec::Ssa | Codec::Unknown(_) => Box::new(PassthroughParser::new(true)),
}
}
diff --git a/src/mux/codec/mpegaudio.rs b/src/mux/codec/mpegaudio.rs
new file mode 100644
index 0000000..c652106
--- /dev/null
+++ b/src/mux/codec/mpegaudio.rs
@@ -0,0 +1,222 @@
+//! MPEG-1/2/2.5 audio (MP1/MP2/MP3) decodability gate.
+//!
+//! ffmpeg validates MPEG-audio frames by header sanity + framing resync, not a
+//! payload CRC (`mpegaudiodecheader.c` `ff_mpa_check_header`; the optional CRC
+//! covers only side-info and is off by default). Its `ff_mpa_decode_header`
+//! additionally rejects free-format (`bitrate_index == 0`). So the gate mirrors
+//! exactly those header rejects: a packet that begins with the 11-bit MPEG-audio
+//! sync but whose version / layer / bitrate-index / sample-rate fields are the
+//! reserved/invalid values is undecodable → drop it (a silence gap; each packet
+//! keeps its own PTS). A packet with no leading sync is not a frame we can
+//! validate (raw payload / continuation), so it passes through unchanged —
+//! never false-dropped.
+
+use super::dropgate::DropTally;
+use super::{CodecParser, Frame, PesPacket, pts_to_ns};
+
+/// Decoded validity of a candidate MPEG-audio header.
+enum MpaVerdict {
+ /// No 11-bit sync at the packet head — not a frame we can validate.
+ NoSync,
+ /// Sync present and every field is legal — decodable.
+ Valid,
+ /// Sync present but a field is reserved/invalid (or free-format) — ffmpeg's
+ /// parser rejects this exactly.
+ Invalid,
+}
+
+/// Mirror ffmpeg's `ff_mpa_check_header` + the `ff_mpa_decode_header`
+/// free-format reject. A dropped MPEG-audio frame has a corrupt header, so no
+/// duration is computed (the fields it would come from are the invalid ones).
+fn mpa_verdict(data: &[u8]) -> MpaVerdict {
+ if data.len() < 4 {
+ return MpaVerdict::NoSync;
+ }
+ let h = u32::from_be_bytes([data[0], data[1], data[2], data[3]]);
+ // 11-bit sync (0x7FF at the top).
+ if (h & 0xffe0_0000) != 0xffe0_0000 {
+ return MpaVerdict::NoSync;
+ }
+ // ff_mpa_check_header rejects: version field 01, layer field 00,
+ // bitrate_index 15, sample-rate field 3.
+ if (h & (3 << 19)) == (1 << 19)
+ || (h & (3 << 17)) == 0
+ || (h & (0xf << 12)) == (0xf << 12)
+ || (h & (3 << 10)) == (3 << 10)
+ {
+ return MpaVerdict::Invalid;
+ }
+ // Free format (bitrate_index == 0): ff_mpa_decode_header returns 1, which the
+ // framing wrapper treats as failure. Reject for consistency.
+ if (h >> 12) & 0xf == 0 {
+ return MpaVerdict::Invalid;
+ }
+ MpaVerdict::Valid
+}
+
+pub struct MpegAudioParser {
+ tally: DropTally,
+}
+
+impl Default for MpegAudioParser {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl MpegAudioParser {
+ pub fn new() -> Self {
+ Self {
+ tally: DropTally::new("mpegaudio"),
+ }
+ }
+
+ pub fn dropped_frames(&self) -> u64 {
+ self.tally.dropped_frames()
+ }
+
+ pub fn dropped_duration_ns(&self) -> u64 {
+ self.tally.dropped_duration_ns()
+ }
+}
+
+impl CodecParser for MpegAudioParser {
+ fn parse(&mut self, pes: &PesPacket) -> Vec {
+ if pes.data.is_empty() {
+ return Vec::new();
+ }
+ let pts_ns = pes.pts.or(pes.dts).map(pts_to_ns).unwrap_or(0);
+
+ let drop =
+ self.tally.is_poisoned() || matches!(mpa_verdict(&pes.data), MpaVerdict::Invalid);
+ if drop {
+ let reason = if self.tally.is_poisoned() {
+ "track-poisoned"
+ } else {
+ "header"
+ };
+ self.tally.record_drop(pts_ns, 0, pes.data.len(), reason);
+ return Vec::new();
+ }
+
+ self.tally.record_kept();
+ vec![Frame {
+ discontinuity: pes.discontinuity,
+ coding: None,
+ source: None,
+ pts_ns,
+ keyframe: true,
+ data: pes.data.clone(),
+ duration_ns: None,
+ }]
+ }
+
+ fn flush(&mut self) -> Vec {
+ self.tally.log_summary();
+ Vec::new()
+ }
+
+ fn codec_private(&self) -> Option> {
+ None
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn make_pes(data: Vec, pts: Option) -> PesPacket {
+ PesPacket {
+ source: None,
+ pid: 0x1100,
+ pts,
+ dts: None,
+ data,
+ discontinuity: false,
+ }
+ }
+
+ /// A valid MPEG-1 Layer III header: sync 0xFFF, version MPEG-1 (11), layer
+ /// III (01), bitrate_index 9, sample-rate 0 (44.1 kHz), no CRC. Bytes:
+ /// 0xFF 0xFB 0x90 0x00 — the canonical MP3 frame header.
+ fn mp3_frame(payload: usize) -> Vec {
+ let mut f = vec![0xFF, 0xFB, 0x90, 0x00];
+ f.extend(std::iter::repeat(0xAA).take(payload));
+ f
+ }
+
+ #[test]
+ fn valid_header_is_kept() {
+ let mut p = MpegAudioParser::new();
+ let f = p.parse(&make_pes(mp3_frame(400), Some(90000)));
+ assert_eq!(f.len(), 1);
+ assert_eq!(f[0].pts_ns, pts_to_ns(90000));
+ assert_eq!(p.dropped_frames(), 0);
+ }
+
+ #[test]
+ fn reserved_sample_rate_is_dropped() {
+ // Sync present but sample-rate field = 3 (reserved) → ffmpeg rejects.
+ // 0xFF 0xFB then byte2 with bits 11..10 = 11: 0x9C.
+ let mut p = MpegAudioParser::new();
+ let mut frame = mp3_frame(400);
+ frame[2] = 0x9C; // freq field = 3
+ let f = p.parse(&make_pes(frame, Some(90000)));
+ assert!(f.is_empty(), "reserved sample rate dropped");
+ assert_eq!(p.dropped_frames(), 1);
+ }
+
+ #[test]
+ fn reserved_layer_is_dropped() {
+ // Layer field 00 (reserved). byte1 bits 2..1 = 00 → 0xF9 keeps sync
+ // (0xFFF needs byte1 top 3 bits set) and sets layer=00.
+ let mut p = MpegAudioParser::new();
+ let mut frame = mp3_frame(400);
+ frame[1] = 0xF9; // 1111_1001: sync ok (top 3 =111), version 11, layer 00
+ let f = p.parse(&make_pes(frame, Some(0)));
+ assert!(f.is_empty(), "reserved layer dropped");
+ assert_eq!(p.dropped_frames(), 1);
+ }
+
+ #[test]
+ fn bad_bitrate_index_15_is_dropped() {
+ let mut p = MpegAudioParser::new();
+ let mut frame = mp3_frame(400);
+ frame[2] = 0xF0; // bitrate_index = 1111
+ assert!(p.parse(&make_pes(frame, Some(0))).is_empty());
+ assert_eq!(p.dropped_frames(), 1);
+ }
+
+ #[test]
+ fn free_format_bitrate_zero_is_dropped() {
+ let mut p = MpegAudioParser::new();
+ let mut frame = mp3_frame(400);
+ frame[2] = 0x00; // bitrate_index = 0000 (free format)
+ assert!(p.parse(&make_pes(frame, Some(0))).is_empty());
+ assert_eq!(p.dropped_frames(), 1);
+ }
+
+ #[test]
+ fn non_sync_packet_passes_through() {
+ // No 11-bit sync → not a validatable frame → keep (conservative).
+ let mut p = MpegAudioParser::new();
+ let f = p.parse(&make_pes(vec![0x00, 0x11, 0x22, 0x33, 0x44], Some(0)));
+ assert_eq!(f.len(), 1);
+ assert_eq!(p.dropped_frames(), 0);
+ }
+
+ #[test]
+ fn drop_preserves_sync_via_own_pts() {
+ let mut p = MpegAudioParser::new();
+ let mut bad = mp3_frame(400);
+ bad[2] = 0x9C; // reserved sample rate
+ assert!(p.parse(&make_pes(bad, Some(90000))).is_empty());
+ let f = p.parse(&make_pes(mp3_frame(400), Some(96000)));
+ assert_eq!(f.len(), 1);
+ assert_eq!(
+ f[0].pts_ns,
+ pts_to_ns(96000),
+ "next frame keeps its own PTS"
+ );
+ }
+}
diff --git a/src/mux/codec/truehd.rs b/src/mux/codec/truehd.rs
index fa84c52..0a5ce09 100644
--- a/src/mux/codec/truehd.rs
+++ b/src/mux/codec/truehd.rs
@@ -12,6 +12,8 @@
//! AC-3 frames (interleaved, same PID): start with sync word 0x0B77.
//! We skip AC-3 frames and only emit TrueHD access units.
+use super::crc::crc16_mlp;
+use super::dropgate::DropTally;
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
use crate::mux::timeline::DISCONTINUITY_BACKSTEP_NS;
@@ -43,6 +45,18 @@ pub struct TrueHdParser {
/// yet seen (head of stream) — preserving byte-identical timing for the
/// common 48 kHz case.
au_duration_ns: i64,
+ /// Keep/drop bookkeeping for the decodability gate.
+ tally: DropTally,
+ /// `num_substreams` from the most recent major sync — needed to size the
+ /// substream directory for the per-AU parity check. `None` until the first
+ /// major sync is seen (before which no AU can be parity-checked).
+ num_substreams: Option,
+ /// True while dropping forward to the next clean resync point. MLP/TrueHD
+ /// carries filter/predictor + restart state ACROSS access units, so a corrupt
+ /// AU cannot be excised in place — it poisons decoding until the next major
+ /// sync re-initialises state. On corruption we set this and drop every AU
+ /// until a major sync whose header CRC validates, which we then emit.
+ resync_pending: bool,
}
impl Default for TrueHdParser {
@@ -57,9 +71,50 @@ impl TrueHdParser {
buf: Vec::with_capacity(32768),
next_pts_ns: 0,
au_duration_ns: AU_DURATION_NS,
+ tally: DropTally::new("truehd"),
+ num_substreams: None,
+ resync_pending: false,
}
}
+ /// Access units dropped as undecodable so far.
+ pub fn dropped_frames(&self) -> u64 {
+ self.tally.dropped_frames()
+ }
+
+ /// Total decoded duration (ns) of dropped access units.
+ pub fn dropped_duration_ns(&self) -> u64 {
+ self.tally.dropped_duration_ns()
+ }
+
+ /// Decide whether an access unit is corrupt, updating `num_substreams` from a
+ /// valid major sync. Mirrors ffmpeg `read_access_unit`: a major sync with a
+ /// bad header CRC, or any AU whose header parity fails, is undecodable.
+ /// Returns `false` (not corrupt) when the AU is too short to judge or no
+ /// major sync has established `num_substreams` yet — we never drop what we
+ /// cannot verify. Verified against real ffmpeg TrueHD (3600/3600 AUs).
+ fn au_is_corrupt(&mut self, au: &[u8], is_major_sync: bool) -> bool {
+ let mut header_size = 4;
+ if is_major_sync {
+ let ms = &au[4..];
+ let Some(mshdr) = mlp_major_sync_header_size(ms) else {
+ return false; // too short to hold a major-sync header — can't judge
+ };
+ if !mlp_major_sync_crc_ok(ms, mshdr) {
+ return true; // corrupt major-sync header
+ }
+ self.num_substreams = mlp_num_substreams(ms);
+ header_size += mshdr;
+ }
+ let Some(nss) = self.num_substreams else {
+ return false; // no major sync seen yet — nothing to check against
+ };
+ let Some(shs) = mlp_substr_header_size(au, header_size, nss) else {
+ return false; // directory runs off the AU — can't judge
+ };
+ !mlp_parity_ok(au, header_size, shs)
+ }
+
/// Size (bytes) of the AC-3 frame at the buffer head.
///
/// Distinguishes three cases the caller must treat differently:
@@ -124,6 +179,80 @@ enum Ac3Size {
Frame(usize),
}
+// --- MLP/TrueHD access-unit integrity (mirrors ffmpeg mlpdec.c / mlp_parse.c) ---
+
+/// Major-sync header size in bytes: base 28, plus `2 + extensions*2` when the
+/// extension flag (major-sync byte 25, bit 0) is set (`extensions` = byte 26
+/// high nibble). `ms` is the major-sync header, i.e. AU bytes `[4..]`. `None`
+/// when the AU is too short to contain the full header.
+fn mlp_major_sync_header_size(ms: &[u8]) -> Option {
+ if ms.len() < 28 {
+ return None;
+ }
+ let mut size = 28;
+ if ms[25] & 1 != 0 {
+ size += 2 + ((ms[26] >> 4) as usize) * 2;
+ }
+ if ms.len() < size {
+ return None;
+ }
+ Some(size)
+}
+
+/// Validate the MLP/TrueHD major-sync header checksum (ffmpeg `ff_mlp_checksum16`,
+/// CRC-16 poly 0x002D). The stored trailer is the last 2 header bytes; because
+/// MLP's checksum is byte-reversed relative to a standard CRC, a standard CRC of
+/// the header body XOR the little-endian word before the trailer must equal the
+/// trailer read big-endian.
+fn mlp_major_sync_crc_ok(ms: &[u8], mshdr: usize) -> bool {
+ if mshdr < 4 || ms.len() < mshdr {
+ return false;
+ }
+ let crc = crc16_mlp(&ms[..mshdr - 4]) ^ u16::from_le_bytes([ms[mshdr - 4], ms[mshdr - 3]]);
+ crc == u16::from_be_bytes([ms[mshdr - 2], ms[mshdr - 1]])
+}
+
+/// `num_substreams` from a major-sync header: it sits at bit 128 (byte 16, top
+/// nibble) for both MLP (0xbb) and TrueHD (0xba) — the fields before it total
+/// the same 128 bits in either layout.
+fn mlp_num_substreams(ms: &[u8]) -> Option {
+ ms.get(16).map(|&b| b >> 4)
+}
+
+/// Size in bytes of the substream directory that follows the AU header: each of
+/// the `num_substreams` entries is 2 bytes, plus 2 more when its extraword flag
+/// (entry's top bit) is set. `None` if the directory runs past the AU.
+fn mlp_substr_header_size(au: &[u8], header_size: usize, num_substreams: u8) -> Option {
+ let mut off = header_size;
+ let mut shs = 0;
+ for _ in 0..num_substreams {
+ if off + 2 > au.len() {
+ return None;
+ }
+ let extraword = au[off] & 0x80 != 0;
+ shs += 2;
+ off += 2;
+ if extraword {
+ shs += 2;
+ off += 2;
+ }
+ }
+ Some(shs)
+}
+
+/// MLP/TrueHD AU-header parity check (ffmpeg `ff_mlp_calculate_parity`): the XOR
+/// of the 4-byte AU header with the substream directory, folded, must have its
+/// two nibbles XOR to 0xF.
+fn mlp_parity_ok(au: &[u8], header_size: usize, substr_header_size: usize) -> bool {
+ let end = header_size + substr_header_size;
+ if end > au.len() {
+ return false;
+ }
+ let xor_fold = |d: &[u8]| d.iter().fold(0u8, |a, &b| a ^ b);
+ let p = xor_fold(&au[0..4]) ^ xor_fold(&au[header_size..end]);
+ ((p >> 4) ^ p) & 0xF == 0xF
+}
+
impl CodecParser for TrueHdParser {
fn parse(&mut self, pes: &PesPacket) -> Vec {
// B1: a concealed/lost gap means the buffered TrueHD AU is TRUNCATED.
@@ -267,15 +396,52 @@ impl CodecParser for TrueHdParser {
self.au_duration_ns = truehd_au_duration_ns(format_info);
}
- frames.push(Frame {
- discontinuity: false,
- coding: None,
- source: None,
- pts_ns: self.next_pts_ns,
- keyframe: is_major_sync,
- data: self.buf[..unit_bytes].to_vec(),
- duration_ns: None,
- });
+ // Decodability gate. MLP/TrueHD state persists across AUs, so a
+ // corrupt AU is dropped FORWARD to the next major sync (the clean
+ // re-init point) rather than excised in place; the PTS clock advances
+ // across every dropped AU so the drop is a silence gap, never a shift.
+ let au = self.buf[..unit_bytes].to_vec();
+ let pts = self.next_pts_ns;
+ let corrupt = self.tally.is_poisoned() || self.au_is_corrupt(&au, is_major_sync);
+ let emit = if self.resync_pending {
+ // Only a valid major sync clears the resync and is emitted.
+ if is_major_sync && !corrupt {
+ self.resync_pending = false;
+ true
+ } else {
+ false
+ }
+ } else if corrupt {
+ self.resync_pending = true;
+ false
+ } else {
+ true
+ };
+
+ if emit {
+ self.tally.record_kept();
+ frames.push(Frame {
+ discontinuity: false,
+ coding: None,
+ source: None,
+ pts_ns: pts,
+ keyframe: is_major_sync,
+ data: au,
+ duration_ns: None,
+ });
+ } else {
+ let reason = if self.tally.is_poisoned() {
+ "track-poisoned"
+ } else if is_major_sync && corrupt {
+ "major-sync-crc"
+ } else if corrupt {
+ "parity"
+ } else {
+ "resync"
+ };
+ self.tally
+ .record_drop(pts, self.au_duration_ns, au.len(), reason);
+ }
self.buf.drain(..unit_bytes);
self.next_pts_ns += self.au_duration_ns;
}
@@ -289,6 +455,11 @@ impl CodecParser for TrueHdParser {
frames
}
+ fn flush(&mut self) -> Vec {
+ self.tally.log_summary();
+ Vec::new()
+ }
+
fn codec_private(&self) -> Option> {
None
}
@@ -451,6 +622,145 @@ mod tests {
data
}
+ /// Turn a synthetic major-sync AU (sync bytes already set at offset 4, any
+ /// `format_info` set) into one that passes the decodability gate: 1 substream,
+ /// a clean substream directory, a valid major-sync CRC-16, and a valid header
+ /// parity nibble. Mirrors what a real encoder writes (verified against real
+ /// ffmpeg TrueHD). The AU must be ≥ 36 bytes (4 AU header + 28 major-sync
+ /// header + 2 directory + slack), which every `make_truehd_unit(≥200)` is.
+ fn finalize_major_sync(au: &mut [u8]) {
+ const MSHDR: usize = 28; // no extension (byte 25 clear)
+ // num_substreams = 1 → major-sync byte 16 (AU[20]) top nibble.
+ au[20] = (au[20] & 0x0F) | 0x10;
+ // Substream directory entry at AU[4+MSHDR] = AU[32]: extraword flag clear.
+ au[32] &= 0x7F;
+ // Major-sync CRC over the header body, stored big-endian in the trailer.
+ let body_end = 4 + MSHDR - 4; // AU[4..28]
+ let crc = super::crc16_mlp(&au[4..body_end])
+ ^ u16::from_le_bytes([au[body_end], au[body_end + 1]]);
+ au[4 + MSHDR - 2] = (crc >> 8) as u8;
+ au[4 + MSHDR - 1] = (crc & 0xFF) as u8;
+ // Parity: choose the AU check nibble (AU[0] high bits) so the header +
+ // directory fold to 0xF. The length low nibble (AU[0] low bits) is kept.
+ let hi = au[0] & 0x0F;
+ let p0 = (hi ^ au[1] ^ au[2] ^ au[3]) ^ (au[32] ^ au[33]);
+ let c = ((p0 >> 4) ^ (p0 & 0x0F) ^ 0x0F) & 0x0F;
+ au[0] = (c << 4) | hi;
+ }
+
+ /// Give a synthetic NON-major-sync AU a valid header parity nibble (1
+ /// substream, directory at AU[4..6]), so it passes the gate once a preceding
+ /// major sync has established `num_substreams`.
+ fn finalize_normal_parity(au: &mut [u8]) {
+ au[4] &= 0x7F; // no extraword
+ let hi = au[0] & 0x0F;
+ let p0 = (hi ^ au[1] ^ au[2] ^ au[3]) ^ (au[4] ^ au[5]);
+ let c = ((p0 >> 4) ^ (p0 & 0x0F) ^ 0x0F) & 0x0F;
+ au[0] = (c << 4) | hi;
+ }
+
+ fn valid_major_sync() -> Vec {
+ let mut u = make_truehd_unit(200);
+ u[4..8].copy_from_slice(&0xF872_6FBAu32.to_be_bytes());
+ finalize_major_sync(&mut u);
+ u
+ }
+
+ fn valid_normal_au() -> Vec {
+ let mut u = make_truehd_unit(200);
+ finalize_normal_parity(&mut u);
+ u
+ }
+
+ #[test]
+ fn corrupt_major_sync_drops_forward_to_next_valid() {
+ // MLP state carries across AUs, so a corrupt AU is dropped FORWARD to the
+ // next valid major sync (the clean re-init point). Sequence: valid MS,
+ // corrupt MS (bad CRC), a normal AU, then a valid MS. Only the two valid
+ // major syncs survive; the corrupt MS and the intervening normal AU are
+ // dropped (the latter because decode state is poisoned until re-init).
+ let mut parser = TrueHdParser::new();
+ let ms1 = valid_major_sync();
+ let mut ms_bad = valid_major_sync();
+ ms_bad[10] ^= 0xFF; // corrupt a CRC-covered header byte
+ let normal = valid_normal_au(); // clean parity, but arrives mid-resync
+ let ms2 = valid_major_sync();
+
+ let mut data = ms1.clone();
+ data.extend_from_slice(&ms_bad);
+ data.extend_from_slice(&normal);
+ data.extend_from_slice(&ms2);
+ let mut frames = parser.parse(&make_pes(data, Some(90000)));
+ frames.extend(parser.flush());
+
+ assert_eq!(frames.len(), 2, "only the two valid major syncs survive");
+ assert!(frames[0].keyframe && frames[1].keyframe);
+ assert_eq!(
+ parser.dropped_frames(),
+ 2,
+ "corrupt MS + poisoned normal AU"
+ );
+ }
+
+ #[test]
+ fn parity_failure_is_dropped() {
+ // A normal AU whose header parity is broken (after a major sync sets
+ // num_substreams) is undecodable → dropped.
+ let mut parser = TrueHdParser::new();
+ let ms1 = valid_major_sync();
+ let mut bad = valid_normal_au();
+ // A single-nibble flip: MLP's nibble-fold parity (like ffmpeg's) is blind
+ // to a full-byte flip, which changes both nibbles equally and cancels.
+ bad[2] ^= 0x01;
+ let ms2 = valid_major_sync();
+ let mut data = ms1;
+ data.extend_from_slice(&bad);
+ data.extend_from_slice(&ms2);
+ let mut frames = parser.parse(&make_pes(data, Some(90000)));
+ frames.extend(parser.flush());
+ assert_eq!(frames.len(), 2, "the parity-broken AU is dropped");
+ assert_eq!(parser.dropped_frames(), 1);
+ }
+
+ #[test]
+ fn drop_forward_preserves_av_sync_no_shift() {
+ // THE INVARIANT: the resumed major sync keeps the exact PTS it would have
+ // had with no drop — base + 3 AU durations (MS1, corrupt-MS, normal, MS2)
+ // — so the drop is a silence gap, never a shift.
+ let mut parser = TrueHdParser::new();
+ let ms1 = valid_major_sync();
+ let mut ms_bad = valid_major_sync();
+ ms_bad[10] ^= 0xFF;
+ let normal = valid_normal_au();
+ let ms2 = valid_major_sync();
+ let mut data = ms1;
+ data.extend_from_slice(&ms_bad);
+ data.extend_from_slice(&normal);
+ data.extend_from_slice(&ms2);
+ let mut frames = parser.parse(&make_pes(data, Some(90000)));
+ frames.extend(parser.flush());
+ assert_eq!(frames.len(), 2);
+ assert_eq!(
+ frames[1].pts_ns - frames[0].pts_ns,
+ 3 * AU_DURATION_NS,
+ "resumed major sync keeps its true timeline (gap, not shift)"
+ );
+ }
+
+ #[test]
+ fn clean_truehd_stream_drops_nothing() {
+ // A run of valid AUs passes untouched — zero false positives (the CRC and
+ // parity are verified against real ffmpeg TrueHD output).
+ let mut parser = TrueHdParser::new();
+ let mut data = valid_major_sync();
+ for _ in 0..5 {
+ data.extend_from_slice(&valid_normal_au());
+ }
+ let frames = parser.parse(&make_pes(data, Some(90000)));
+ assert_eq!(frames.len(), 6);
+ assert_eq!(parser.dropped_frames(), 0);
+ }
+
fn make_ac3_frame() -> Vec {
// Minimal AC-3 frame: sync 0x0B77, fscod=0 (48kHz), frmsizecod=0 (64 words = 128 bytes)
let mut data = vec![0u8; 128];
@@ -878,6 +1188,7 @@ mod tests {
let mut parser = TrueHdParser::new();
let mut unit = make_truehd_unit(200);
unit[4..8].copy_from_slice(&0xF872_6FBAu32.to_be_bytes());
+ finalize_major_sync(&mut unit);
let f = parser.parse(&make_pes(unit, Some(90000)));
assert_eq!(f.len(), 1);
assert!(f[0].keyframe, "major-sync AU must be flagged keyframe");
@@ -899,6 +1210,7 @@ mod tests {
let mut parser = TrueHdParser::new();
let mut unit = make_truehd_unit(200);
unit[4..8].copy_from_slice(&0xF872_6FBBu32.to_be_bytes());
+ finalize_major_sync(&mut unit);
let f = parser.parse(&make_pes(unit, Some(90000)));
assert_eq!(f.len(), 1);
assert!(f[0].keyframe, "major-sync variant 0xFB also a keyframe");
@@ -1101,8 +1413,11 @@ mod tests {
let mut a1 = make_truehd_unit(200);
a1[4..8].copy_from_slice(&0xF872_6FBAu32.to_be_bytes()); // major sync
a1[8..12].copy_from_slice(&format_info_with(0x8).to_be_bytes()); // 44.1 k
+ finalize_major_sync(&mut a1);
+ let mut a2 = make_truehd_unit(200);
+ finalize_normal_parity(&mut a2);
let mut data = a1;
- data.extend_from_slice(&make_truehd_unit(200));
+ data.extend_from_slice(&a2);
let frames = parser.parse(&make_pes(data, Some(90000)));
assert_eq!(frames.len(), 2);
assert_eq!(
@@ -1120,8 +1435,11 @@ mod tests {
let mut a1 = make_truehd_unit(200);
a1[4..8].copy_from_slice(&0xF872_6FBAu32.to_be_bytes());
a1[8..12].copy_from_slice(&format_info_with(0x0).to_be_bytes()); // 48 k
+ finalize_major_sync(&mut a1);
+ let mut a2 = make_truehd_unit(200);
+ finalize_normal_parity(&mut a2);
let mut data = a1;
- data.extend_from_slice(&make_truehd_unit(200));
+ data.extend_from_slice(&a2);
let frames = parser.parse(&make_pes(data, Some(90000)));
assert_eq!(frames.len(), 2);
assert_eq!(frames[1].pts_ns - frames[0].pts_ns, 833_333);