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);