mux: drop truncated partial audio frame on concealed gap

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.
This commit is contained in:
Matthew Jackson
2026-06-29 11:23:08 -07:00
parent cb7d78ac6a
commit be08e3938b
3 changed files with 161 additions and 0 deletions
+54
View File
@@ -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