From be08e3938b75b15906e3eb2e7929dd3b55ac9a56 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Mon, 29 Jun 2026 11:23:08 -0700 Subject: [PATCH] mux: drop truncated partial audio frame on concealed gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AC-3, DTS and TrueHD parsers buffer access units across PES boundaries. At a concealed-loss gap the buffered unit is truncated: splicing post-gap bytes onto it manufactures a corrupt frame on top of the real loss (FFmpeg "Failed to decode block code(s)" / "Invalid data found" at the gap) and, for TrueHD, strands the PTS cadence into the non-monotonic audio-DTS band seen on multi-clip titles. The video parsers already handle this via the ResyncGate, but the discontinuity signal was only wired into video — audio parsers ignored pes.discontinuity and spliced across the gap. Now, when pes.discontinuity is set, each audio parser drops the partial (clears buf, and for DTS its PTS marks / pending base) so the post-gap PES re-bases a fresh unit. A lost gap degrades to a clean single-frame drop instead of a corrupt spliced frame. No effect on perfect rips: the branch only runs when concealment inserted a discontinuity marker. Adds a per-parser test feeding a partial frame then a discontinuity PES, asserting the truncated partial is dropped (not spliced) and the post-gap PTS is adopted. --- src/mux/codec/ac3.rs | 53 ++++++++++++++++++++++++++++++++++++++++ src/mux/codec/dts.rs | 54 +++++++++++++++++++++++++++++++++++++++++ src/mux/codec/truehd.rs | 54 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 161 insertions(+) diff --git a/src/mux/codec/ac3.rs b/src/mux/codec/ac3.rs index 5471ba5..7aca241 100644 --- a/src/mux/codec/ac3.rs +++ b/src/mux/codec/ac3.rs @@ -64,6 +64,17 @@ impl CodecParser for Ac3Parser { return Vec::new(); } + // B1: a concealed/lost gap means the bytes held in `buf` are a TRUNCATED + // frame. Appending the post-gap bytes would splice them into one corrupt + // frame (wrong frame_size, bad CRC → "exponent out of range" / garbage). + // Drop the partial and resync on the next syncword — a clean single-frame + // gap instead of a frankenstein frame. (The video parsers carry this via + // the ResyncGate; audio has no inter-frame refs, so dropping the spliced + // partial is the whole fix.) + if pes.discontinuity { + self.buf.clear(); + } + // Base PTS for the FIRST frame emitted from this call. Each subsequent // frame in the same call advances by the previous frame's duration, so a // PES that carries several AC-3 frames stamps a monotonically increasing @@ -506,6 +517,48 @@ mod tests { assert_eq!(frames2[0].data.len(), 160); } + #[test] + fn discontinuity_drops_truncated_partial() { + // B1: a partial AC-3 frame is buffered, then a concealed gap arrives + // (PES marked discontinuity) carrying a fresh complete frame. The + // truncated partial must be DROPPED, not spliced — otherwise the parser + // emits one corrupt frame built from [stale partial | head of fresh] and + // strands the tail (FFmpeg: "incomplete frame" / wrong sync). + let mut parser = Ac3Parser::new(); + let frame_data = make_ac3_frame(0, 2); // 160 bytes, starts with 0x0B77 + + // First PES: only the first half of a frame (no boundary marker). + let pes1 = PesPacket { + source: None, + pid: 0, + pts: Some(90000), + dts: None, + data: frame_data[..80].to_vec(), + discontinuity: false, + }; + assert!( + parser.parse(&pes1).is_empty(), + "partial frame should not emit" + ); + + // Concealed gap: a fresh whole frame, marked discontinuity. + let fresh = make_ac3_frame(0, 2); + let pes2 = PesPacket { + source: None, + pid: 0, + pts: Some(99000), + dts: None, + data: fresh.clone(), + discontinuity: true, + }; + let frames = parser.parse(&pes2); + assert_eq!(frames.len(), 1, "exactly one clean frame across the gap"); + assert_eq!( + frames[0].data, fresh, + "emitted frame is the fresh post-gap frame, not a spliced partial" + ); + } + #[test] fn skip_garbage_before_sync() { let mut parser = Ac3Parser::new(); diff --git a/src/mux/codec/dts.rs b/src/mux/codec/dts.rs index 0510f02..8c06479 100644 --- a/src/mux/codec/dts.rs +++ b/src/mux/codec/dts.rs @@ -126,6 +126,17 @@ impl CodecParser for DtsParser { if pes.data.is_empty() { return Vec::new(); } + // B1: a concealed/lost gap means the buffered DTS access unit is + // TRUNCATED. Splicing post-gap bytes onto it corrupts the core/extension + // framing (→ "Failed to decode block code(s)" / "Invalid data found"). + // Drop the partial AU and its PTS marks; the next PES re-bases a fresh + // unit. (Audio has no inter-frame refs — dropping the spliced partial is + // the whole fix; the video ResyncGate handles video.) + if pes.discontinuity { + self.buf.clear(); + self.pts_marks.clear(); + self.pending_pts = PTS_UNSET; + } let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0); // On Blu-ray, a DTS-HD MA/HRA access unit is a DTS core frame @@ -584,6 +595,49 @@ mod tests { assert_eq!(tail[0].data.len(), 512); } + #[test] + fn discontinuity_drops_truncated_partial() { + // B1: a partial DTS core is buffered, then a concealed gap (PES marked + // discontinuity) carries a fresh core. The truncated partial must be + // DROPPED — splicing it makes the framer emit a corrupt sub-core-length + // AU (the Dunkirk `dca` "Failed to decode block code(s)" class) and + // strands the rest. With the fix the post-gap core is the only AU, and it + // carries the post-gap PTS (not the stale pre-gap one). + let mut parser = DtsParser::new(); + + // PES 1: first half of a 512-byte core (no boundary marker). + let core = make_dts_core(512); + let pes1 = make_pes(core[..256].to_vec(), Some(90000)); + assert!(parser.parse(&pes1).is_empty(), "partial core held"); + + // Concealed gap: a fresh whole core, marked discontinuity. + let fresh = make_dts_core(512); + let pes2 = PesPacket { + source: None, + pid: 0x1100, + pts: Some(99000), + dts: None, + data: fresh.clone(), + discontinuity: true, + }; + assert!( + parser.parse(&pes2).is_empty(), + "post-gap core held awaiting next core — NO corrupt partial emitted" + ); + + let tail = parser.flush(); + assert_eq!(tail.len(), 1, "exactly one clean AU across the gap"); + assert_eq!( + tail[0].data, fresh, + "AU is the fresh post-gap core, not a splice" + ); + assert_eq!( + tail[0].pts_ns, + pts_to_ns(99000), + "post-gap AU re-bases to the post-gap PTS, not the stranded pre-gap one" + ); + } + #[test] fn two_cores_back_to_back_emit_first_on_boundary() { // The first complete unit is emitted as soon as the next core sync is diff --git a/src/mux/codec/truehd.rs b/src/mux/codec/truehd.rs index 53043ed..eea2a7d 100644 --- a/src/mux/codec/truehd.rs +++ b/src/mux/codec/truehd.rs @@ -130,6 +130,16 @@ impl CodecParser for TrueHdParser { return Vec::new(); } + // B1: a concealed/lost gap means the buffered TrueHD AU is TRUNCATED. + // Splicing post-gap bytes onto it corrupts the AU framing (→ "Invalid + // data found") and strands the PTS cadence (the non-monotonic audio-DTS + // band at gaps). Drop the partial; with `buf` now empty the PTS-base block + // below re-seeds the cadence from the post-gap PES, monotonic across the + // gap. (Audio has no inter-frame refs — this is the whole audio fix.) + if pes.discontinuity { + self.buf.clear(); + } + // Capture the PTS base ONLY at an access-unit boundary, i.e. when no AU // is mid-assembly in `buf`. TrueHD access units span PES packets; a PES // that merely continues an AU already in progress carries its own (later) @@ -479,6 +489,50 @@ mod tests { assert_eq!(frames[0].data.len(), 200); } + #[test] + fn discontinuity_drops_truncated_partial() { + // B1: a partial TrueHD unit is buffered, then a concealed gap (PES marked + // discontinuity) carries a fresh unit. The truncated partial must be + // DROPPED — splicing it makes the length-prefixed framer emit a + // wrong-size unit (corrupt AU framing) and re-seeds the PTS cadence from + // the post-gap PES rather than stranding it (the non-monotonic audio-DTS + // band at gaps). + let mut parser = TrueHdParser::new(); + + // PES 1: first 150 bytes of a 300-byte unit (length prefix says 300, only + // 150 present) → held, nothing emitted. + let partial = make_truehd_unit(300); + let pes1 = make_pes(partial[..150].to_vec(), Some(90000)); + assert!(parser.parse(&pes1).is_empty(), "partial unit held"); + + // Concealed gap: a fresh 200-byte unit at a forward PTS jump. + let fresh = make_truehd_unit(200); + let pes2 = PesPacket { + source: None, + pid: 0x1100, + pts: Some(180000), + dts: None, + data: fresh.clone(), + discontinuity: true, + }; + let frames = parser.parse(&pes2); + assert_eq!(frames.len(), 1, "exactly one clean unit across the gap"); + assert_eq!( + frames[0].data.len(), + 200, + "emitted unit is the fresh 200-byte one, not a 300-byte splice" + ); + assert_eq!( + frames[0].data, fresh, + "unit bytes are the fresh post-gap unit" + ); + assert_eq!( + frames[0].pts_ns, + pts_to_ns(180000), + "cadence re-bases to the post-gap PTS across the cleared buffer" + ); + } + #[test] fn parse_multiple_units_incrementing_pts() { let mut parser = TrueHdParser::new();