Four parsers (dvdsub, flac, lpcm, mpegaudio) stamp a source offset on every frame but had no test that read one back, so a regression to `source: None` would have been caught only by the brace-balanced audit in codec/mod.rs — a lint, not a behavioural check. Each now asserts the emitted frame carries the offset of the packet that supplied its first byte. The Blu-ray feed spans had no direct test at all. Add one that walks a multi-item playlist and requires the spans to tile the feed with no gap or overlap; it catches a one-sector-per-clip drift, which is exactly the error class that would misattribute frames near a seam. `no_provenance_still_places_by_marks` asserted only that placement returned something, which passes for a frame placed in the wrong clip. Its probe timestamp lands in an overlap between two clips in the real mark table, so pinning one clip would assert a coin-flip; instead require the offset to be one that a clip actually containing that timestamp would produce.
338 lines
13 KiB
Rust
338 lines
13 KiB
Rust
//! MPEG-1/2/2.5 audio (MP1/MP2/MP3) decodability gate.
|
|
//!
|
|
//! Per ISO/IEC 11172-3 / ISO/IEC 13818-3, an MPEG-audio frame is validated by
|
|
//! header sanity + framing resync, not a payload CRC (the optional 16-bit CRC in
|
|
//! the header protects only the side-information and is absent unless the
|
|
//! protection bit says otherwise). The gate mirrors that header-only check and
|
|
//! ACCEPTS free-format (`bitrate_index == 0`) as a legal decodable mode — it
|
|
//! deliberately does NOT apply the stricter free-format reject that a full
|
|
//! decoder would (see the note at the `bitrate_index` check). So the gate rejects
|
|
//! only the truly invalid headers: a packet that begins with the 11-bit
|
|
//! MPEG-audio sync but whose version / layer / sample-rate fields (or the
|
|
//! reserved bitrate index 15) are reserved/invalid 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 — a conformant header parser
|
|
/// rejects this exactly.
|
|
Invalid,
|
|
}
|
|
|
|
/// Header-only validity check per ISO/IEC 11172-3 / ISO/IEC 13818-3 (which
|
|
/// ACCEPTS free-format, `bitrate_index == 0`) — deliberately NOT the stricter
|
|
/// free-format reject a full decoder applies. 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;
|
|
}
|
|
// Reject per spec: 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;
|
|
}
|
|
// NOTE: bitrate_index == 0 (free format) is NOT rejected. It is a legal,
|
|
// decodable MPEG-audio mode (the spec permits it and a decoder derives the
|
|
// frame size from the sync spacing). Dropping it would be a false positive on
|
|
// a clean stream, so it passes the gate.
|
|
MpaVerdict::Valid
|
|
}
|
|
|
|
pub struct MpegAudioParser {
|
|
tally: DropTally,
|
|
/// Last emitted PTS (ns), carried forward across a PES with no PTS rather than
|
|
/// resetting the timeline to 0 (see the AC-3/DTS parsers) — preserves A/V sync.
|
|
last_pts_ns: i64,
|
|
}
|
|
|
|
impl Default for MpegAudioParser {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl MpegAudioParser {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
tally: DropTally::new("mpegaudio"),
|
|
last_pts_ns: 0,
|
|
}
|
|
}
|
|
|
|
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<Frame> {
|
|
if pes.data.is_empty() {
|
|
return Vec::new();
|
|
}
|
|
let pts_ns = pes
|
|
.pts
|
|
.or(pes.dts)
|
|
.map(pts_to_ns)
|
|
.unwrap_or(self.last_pts_ns);
|
|
self.last_pts_ns = pts_ns;
|
|
|
|
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: super::pesbuf::PesFacts::of(pes).source,
|
|
pts_ns,
|
|
keyframe: true,
|
|
data: pes.data.clone(),
|
|
duration_ns: None,
|
|
}]
|
|
}
|
|
|
|
fn flush(&mut self) -> Vec<Frame> {
|
|
self.tally.log_summary();
|
|
Vec::new()
|
|
}
|
|
|
|
fn codec_private(&self) -> Option<Vec<u8>> {
|
|
None
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn make_pes(data: Vec<u8>, pts: Option<i64>) -> 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<u8> {
|
|
let mut f = vec![0xFF, 0xFB, 0x90, 0x00];
|
|
f.extend(std::iter::repeat_n(0xAA, 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 pes_without_pts_carries_last_timestamp_not_zero() {
|
|
// A PES with no PTS (legal for audio, e.g. after a discontinuity) must
|
|
// carry the last known timestamp forward — resetting to 0 would corrupt
|
|
// A/V sync. Mirrors the adts.rs guard test.
|
|
let mut p = MpegAudioParser::new();
|
|
p.parse(&make_pes(mp3_frame(400), Some(90000)));
|
|
let f = p.parse(&make_pes(mp3_frame(400), None));
|
|
assert_eq!(f.len(), 1);
|
|
assert_eq!(
|
|
f[0].pts_ns,
|
|
pts_to_ns(90000),
|
|
"carried forward, not reset to 0"
|
|
);
|
|
}
|
|
|
|
/// Same contract as the ADTS gate: an MPEG-audio frame is dropped because
|
|
/// its header is invalid, and the header is exactly where a frame duration
|
|
/// (samples-per-frame for the layer, divided by the sampling rate) would
|
|
/// have to come from — see `mpa_verdict`'s doc comment. So the drop's
|
|
/// duration is reported as zero, not derived from rejected fields.
|
|
#[test]
|
|
fn dropped_frames_are_counted_but_their_duration_is_not_invented() {
|
|
let mut parser = MpegAudioParser::new();
|
|
// Reserved layer field (00) — rejected per ISO/IEC 11172-3.
|
|
let mut bad = mp3_frame(32);
|
|
bad[1] &= !0b0000_0110;
|
|
for i in 0..3 {
|
|
let out = parser.parse(&make_pes(bad.clone(), Some(i * 90_000)));
|
|
assert!(out.is_empty(), "an invalid MPEG-audio frame is not emitted");
|
|
}
|
|
assert_eq!(parser.dropped_frames(), 3, "every drop is counted");
|
|
assert_eq!(
|
|
parser.dropped_duration_ns(),
|
|
0,
|
|
"the duration comes from the header that just failed validation, so \
|
|
it is reported as unmeasured rather than guessed"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn reserved_version_field_is_dropped() {
|
|
// version field = 01 (reserved) → rejected. byte1 = 111_01_01_1 = 0xEB
|
|
// keeps the 11-bit sync (0xFF + top 3 bits 111) but sets version bits to 01.
|
|
let mut p = MpegAudioParser::new();
|
|
let mut frame = mp3_frame(400);
|
|
frame[1] = 0xEB;
|
|
let f = p.parse(&make_pes(frame, Some(90000)));
|
|
assert!(f.is_empty(), "reserved version dropped");
|
|
assert_eq!(p.dropped_frames(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn reserved_sample_rate_is_dropped() {
|
|
// Sync present but sample-rate field = 3 (reserved) → rejected per spec.
|
|
// 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_kept() {
|
|
// Free format (bitrate_index == 0) is legal and decodable — it must NOT
|
|
// be dropped (that would be a false positive on a clean stream).
|
|
let mut p = MpegAudioParser::new();
|
|
let mut frame = mp3_frame(400);
|
|
frame[2] = 0x00; // bitrate_index = 0000 (free format); sync/layer/rate ok
|
|
let f = p.parse(&make_pes(frame, Some(0)));
|
|
assert_eq!(f.len(), 1, "free-format frame kept");
|
|
assert_eq!(p.dropped_frames(), 0);
|
|
}
|
|
|
|
#[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"
|
|
);
|
|
}
|
|
|
|
/// This parser is self-framing at PES granularity: `parse` emits (or drops)
|
|
/// every packet immediately and buffers nothing, so end-of-stream has
|
|
/// nothing left to hand over. A `flush` that manufactured a frame would
|
|
/// append a zero-length block at PTS 0 AFTER a track that has already run to
|
|
/// its real end — a Matroska Block whose timestamp jumps backwards past every
|
|
/// cluster before it (RFC 9559 §5.1.3.2 Blocks are relative to their
|
|
/// cluster's timestamp; a phantom 0 lands in the wrong cluster entirely) and
|
|
/// an empty audio frame no decoder can consume.
|
|
#[test]
|
|
fn flush_adds_no_phantom_frame_after_the_last_real_packet() {
|
|
let mut p = MpegAudioParser::new();
|
|
let mut emitted = Vec::new();
|
|
emitted.extend(p.parse(&make_pes(mp3_frame(400), Some(90_000))));
|
|
emitted.extend(p.parse(&make_pes(mp3_frame(400), Some(180_000))));
|
|
// An invalid header (version field 01 = reserved) is dropped, not buffered.
|
|
emitted.extend(p.parse(&make_pes(vec![0xFF, 0xEB, 0x90, 0x00, 0xAA], Some(270_000))));
|
|
assert_eq!(emitted.len(), 2, "two valid packets out, one dropped");
|
|
assert_eq!(p.dropped_frames(), 1);
|
|
|
|
let tail = p.flush();
|
|
assert!(
|
|
tail.is_empty(),
|
|
"nothing is buffered past the last packet; flush produced {:?}",
|
|
tail.iter()
|
|
.map(|f| (f.pts_ns, f.data.len()))
|
|
.collect::<Vec<_>>()
|
|
);
|
|
// Total frame count over the whole stream equals the valid input count —
|
|
// a manufactured tail frame would break this even if it were non-empty.
|
|
assert_eq!(emitted.len() + tail.len(), 2);
|
|
}
|
|
|
|
/// The text guard in `codec/mod.rs` scans for a literal `source: None` and
|
|
/// cannot see a parser that writes `source: facts.source` where the facts
|
|
/// carry no offset. Only a runtime check proves an emitted frame really
|
|
/// carries the byte it was read from, and without it a multi-clip title
|
|
/// places this track by timestamp inference instead of by byte.
|
|
#[test]
|
|
fn an_emitted_frame_carries_the_packets_source_offset() {
|
|
let mut p = MpegAudioParser::new();
|
|
let mut pes = make_pes(mp3_frame(400), Some(90_000));
|
|
pes.source = Some(crate::pes::SourcePos::at_byte(7_777));
|
|
let f = p.parse(&pes);
|
|
assert!(!f.is_empty(), "the frame is emitted");
|
|
assert_eq!(f[0].source.map(|s| s.byte), Some(7_777));
|
|
}
|
|
}
|