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:
@@ -64,6 +64,17 @@ impl CodecParser for Ac3Parser {
|
|||||||
return Vec::new();
|
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
|
// 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
|
// 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
|
// PES that carries several AC-3 frames stamps a monotonically increasing
|
||||||
@@ -506,6 +517,48 @@ mod tests {
|
|||||||
assert_eq!(frames2[0].data.len(), 160);
|
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]
|
#[test]
|
||||||
fn skip_garbage_before_sync() {
|
fn skip_garbage_before_sync() {
|
||||||
let mut parser = Ac3Parser::new();
|
let mut parser = Ac3Parser::new();
|
||||||
|
|||||||
@@ -126,6 +126,17 @@ impl CodecParser for DtsParser {
|
|||||||
if pes.data.is_empty() {
|
if pes.data.is_empty() {
|
||||||
return Vec::new();
|
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);
|
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
|
// 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);
|
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]
|
#[test]
|
||||||
fn two_cores_back_to_back_emit_first_on_boundary() {
|
fn two_cores_back_to_back_emit_first_on_boundary() {
|
||||||
// The first complete unit is emitted as soon as the next core sync is
|
// The first complete unit is emitted as soon as the next core sync is
|
||||||
|
|||||||
@@ -130,6 +130,16 @@ impl CodecParser for TrueHdParser {
|
|||||||
return Vec::new();
|
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
|
// 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
|
// 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)
|
// 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);
|
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]
|
#[test]
|
||||||
fn parse_multiple_units_incrementing_pts() {
|
fn parse_multiple_units_incrementing_pts() {
|
||||||
let mut parser = TrueHdParser::new();
|
let mut parser = TrueHdParser::new();
|
||||||
|
|||||||
Reference in New Issue
Block a user