From 3516207de7975e12c404a259cedc2a0bc931fe72 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 20:55:35 +0200 Subject: [PATCH] #1402: import MP1 (A_MPEG/L1) audio from Matroska Problem: the Matroska demuxer mapped A_MPEG/L2 and A_MPEG/L3 only, so a Layer I track was reported as an unsupported codec even though the MP1/MP2 decoder (#809) reads Layer I. Fix: add Codec::Mp1 for A_MPEG/L1 and route it through the same packet duration and decoder path as Mp2. Test remuxes synthetic Layer I frames into Matroska with ffmpeg and compares our decode (max error 3e-5). Co-Authored-By: Claude Sonnet 5.5 --- crates/codecs/src/mkv.rs | 5 +- crates/codecs/tests/mkv_mp1_oracle.rs | 89 +++++++++++++++++++++++++++ crates/matroska/README.md | 2 +- crates/matroska/src/codec.rs | 4 ++ docs/gaps.md | 5 +- 5 files changed, 100 insertions(+), 5 deletions(-) create mode 100644 crates/codecs/tests/mkv_mp1_oracle.rs diff --git a/crates/codecs/src/mkv.rs b/crates/codecs/src/mkv.rs index a91160a7..2f51258a 100644 --- a/crates/codecs/src/mkv.rs +++ b/crates/codecs/src/mkv.rs @@ -83,7 +83,7 @@ fn audio_starts(file: &MkvFile, bytes: &crate::Src, ti: usize, rate: i64) -> Vec let out_rate = u32::try_from(rate).unwrap_or(0); let frame = match &t.codec { Codec::Aac { asc } => fixed_packet_samples(FixedFrames::Aac(asc), &[], out_rate), - Codec::Mp3 | Codec::Mp2 => fixed_packet_samples(FixedFrames::MpegAudio, &first(), out_rate), + Codec::Mp3 | Codec::Mp2 | Codec::Mp1 => fixed_packet_samples(FixedFrames::MpegAudio, &first(), out_rate), Codec::Ac3 => fixed_packet_samples(FixedFrames::Ac3, &first(), out_rate), Codec::Eac3 => fixed_packet_samples(FixedFrames::Eac3, &first(), out_rate), _ => None, @@ -317,7 +317,7 @@ fn codec_label(c: &Codec) -> String { Codec::Flac { .. } => "FLAC".into(), Codec::Pcm { bits, float, .. } => format!("PCM {bits}-bit{}", if *float { " float" } else { "" }), Codec::Mp3 => "MP3".into(), - Codec::Mp2 => "MPEG Audio".into(), + Codec::Mp2 | Codec::Mp1 => "MPEG Audio".into(), Codec::Other(id) if id == "V_MPEG2" => "MPEG-2 Video".into(), Codec::Other(id) if id == "V_MPEG1" => "MPEG-1 Video".into(), other => other.name().to_string(), @@ -486,6 +486,7 @@ impl MkvSource { Codec::Opus { head } => PacketDecoder::opus(filmcraft_opus::OpusHead::parse(head).map_err(|e| CodecError::Unsupported(format!("Opus: {e}")))?), Codec::Mp3 => PacketDecoder::new(CODEC_TYPE_MP3, rate, None), Codec::Mp2 => PacketDecoder::mpeg_audio(2, rate), + Codec::Mp1 => PacketDecoder::mpeg_audio(1, rate), Codec::Ac3 | Codec::Eac3 => PacketDecoder::ac3(), // symphonia wants the STREAMINFO block body: skip `fLaC` + the 4-byte block header. Codec::Flac { private } => PacketDecoder::new(CODEC_TYPE_FLAC, rate, private.get(8..42).map(<[u8]>::to_vec)), diff --git a/crates/codecs/tests/mkv_mp1_oracle.rs b/crates/codecs/tests/mkv_mp1_oracle.rs new file mode 100644 index 00000000..05d9810f --- /dev/null +++ b/crates/codecs/tests/mkv_mp1_oracle.rs @@ -0,0 +1,89 @@ +//! MPEG audio Layer I (MP1) in Matroska (`A_MPEG/L1`, #1402) against ffmpeg's decode. No +//! installed encoder writes Layer I, so the frames are synthetic (valid random Layer I frames, +//! as in `crates/mpa/tests/oracle.rs`) and ffmpeg only remuxes them into Matroska and decodes. + +mod common; + +use common::*; + +struct Bits(Vec, usize); +impl Bits { + fn put(&mut self, v: u32, n: u32) { + for i in (0..n).rev() { + if self.1.is_multiple_of(8) { + self.0.push(0); + } + if (v >> i) & 1 == 1 { + let last = self.0.len() - 1; + self.0[last] |= 0x80 >> (self.1 % 8); + } + self.1 += 1; + } + } +} + +/// Mono Layer I frames, MPEG-1, 48 kHz, 192 kbit/s (192-byte frames): allocations on the first 8 +/// subbands only, so every frame fits. +fn layer1_stream(frames: usize) -> Vec { + let mut seed = 0x2545_F491u32; + let mut rnd = |n: u32| { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + seed % n + }; + let mut out = Vec::new(); + for _ in 0..frames { + let mut b = Bits(Vec::new(), 0); + for byte in [0xFFu32, 0xFF, 6 << 4 | 1 << 2, 3 << 6] { + b.put(byte, 8); + } + let alloc: Vec = (0..32).map(|sb| if sb < 8 { 1 + rnd(8) } else { 0 }).collect(); + for &a in &alloc { + b.put(a, 4); + } + for &a in &alloc { + if a != 0 { + b.put(10 + rnd(30), 6); + } + } + for _ in 0..12 { + for &a in &alloc { + if a != 0 { + b.put(rnd((1 << (a + 1)) - 1), a + 1); + } + } + } + let mut f = b.0; + f.resize(192, 0); + out.extend(f); + } + out +} + +#[test] +fn mp1_in_matroska_matches_ffmpeg() { + let ff = filmcraft_testkit::require_ffmpeg!(); + let raw = dir().join("synthetic_layer1.mp1"); + std::fs::create_dir_all(dir()).unwrap(); + std::fs::write(&raw, layer1_stream(250)).unwrap(); + let Some(mkv) = fixture(&ff, "synthetic_layer1.mkv", &["-f", "mp3", "-i", raw.to_str().unwrap(), "-c:a", "copy"]) else { + eprintln!("SKIPPED: this ffmpeg can't remux Layer I into Matroska"); + return; + }; + let m = filmcraft_matroska::open(bytes(&mkv)).unwrap(); + assert!(matches!(m.tracks.first().map(|t| &t.codec), Some(filmcraft_matroska::Codec::Mp1)), "A_MPEG/L1 maps to Codec::Mp1"); + let src = filmcraft_codecs::open_bytes("synthetic_layer1.mkv", bytes(&mkv)).unwrap(); + let a = src.info().audio().cloned().unwrap(); + assert_eq!((a.sample_rate, a.channels), (48_000, 1)); + let want = ffmpeg_audio_f32(&ff, &mkv, &[]); + let n = want.len().min(250 * 384) - 800; + let got = src.audio(0, n, 48_000).unwrap(); + let peak = want.iter().fold(0f32, |m, x| m.max(x.abs())); + // ffmpeg's CLI may or may not drop the decoder delay of a stream copy; search a few lags + let (worst, lag) = (-2i64..=2) + .map(|lag| ((2..n - 2).map(|i| (want[(i as i64 + lag) as usize] - got.channels[0][i]).abs()).fold(0f32, f32::max), lag)) + .fold((f32::INFINITY, 0), |m, x| if x.0 < m.0 { x } else { m }); + println!("mkv Layer I: max error {worst} at lag {lag}, peak {peak}"); + assert!(peak > 0.01 && worst <= 2e-4, "peak {peak}, max error {worst} at lag {lag}"); +} diff --git a/crates/matroska/README.md b/crates/matroska/README.md index a4805b6f..40210ad0 100644 --- a/crates/matroska/README.md +++ b/crates/matroska/README.md @@ -73,7 +73,7 @@ while let Some(p) = d.next_packet()? { // file order, all tracks | `A_VORBIS` | `Vorbis` | three headers split from Xiph-laced CodecPrivate | | `A_FLAC` | `Flac` | `fLaC` + metadata blocks | | `A_PCM/INT/LIT`, `A_PCM/INT/BIG`, `A_PCM/FLOAT/IEEE` | `Pcm` | bit depth from Audio | -| `A_AC3`, `A_EAC3`, `A_MPEG/L3`, `A_MPEG/L2` | `Ac3`, `Eac3`, `Mp3`, `Mp2` | | +| `A_AC3`, `A_EAC3`, `A_MPEG/L3`, `A_MPEG/L2`, `A_MPEG/L1` | `Ac3`, `Eac3`, `Mp3`, `Mp2`, `Mp1` | | | `S_TEXT/UTF8` | `SubRip` | | | `S_TEXT/WEBVTT`, `D_WEBVTT/*` (WebM) | `WebVtt` | WebM blocks keep `id\nsettings\npayload` | | `S_TEXT/ASS`, `S_TEXT/SSA` | `Ass` | script header | diff --git a/crates/matroska/src/codec.rs b/crates/matroska/src/codec.rs index 21de3715..8d83a974 100644 --- a/crates/matroska/src/codec.rs +++ b/crates/matroska/src/codec.rs @@ -45,6 +45,8 @@ pub enum Codec { Mp3, /// `A_MPEG/L2`. Mp2, + /// `A_MPEG/L1`. + Mp1, /// `S_TEXT/UTF8` (SubRip-style plain text). SubRip, /// `S_TEXT/WEBVTT`, or WebM's `D_WEBVTT/{SUBTITLES,CAPTIONS,DESCRIPTIONS,METADATA}`. @@ -86,6 +88,7 @@ impl Codec { Codec::Eac3 => "eac3".into(), Codec::Mp3 => "mp3".into(), Codec::Mp2 => "mp2".into(), + Codec::Mp1 => "mp1".into(), Codec::SubRip => "subrip".into(), Codec::WebVtt => "webvtt".into(), Codec::Ass { ssa, .. } => if *ssa { "ssa" } else { "ass" }.into(), @@ -173,6 +176,7 @@ pub(crate) fn map_codec(id: &str, private: &[u8], audio: Option<&crate::AudioInf "A_EAC3" => Codec::Eac3, "A_MPEG/L3" => Codec::Mp3, "A_MPEG/L2" => Codec::Mp2, + "A_MPEG/L1" => Codec::Mp1, "S_TEXT/UTF8" => Codec::SubRip, "S_TEXT/WEBVTT" | "D_WEBVTT/SUBTITLES" | "D_WEBVTT/CAPTIONS" | "D_WEBVTT/DESCRIPTIONS" | "D_WEBVTT/METADATA" => Codec::WebVtt, "S_TEXT/ASS" | "S_ASS" => Codec::Ass { header: p, ssa: false }, diff --git a/docs/gaps.md b/docs/gaps.md index 9b2a2796..628b099e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -178,12 +178,12 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** - **Have:** E-AC-3 (Dolby Digital Plus) in MP4, MOV, Matroska and MPEG-TS (#647): independent substream, 1–6 blocks per syncframe, spectral extension. MP1 / MP2 (MPEG audio Layer I / II): - own decoder (#809) in raw files, MP4 and MOV (#800), MPEG-TS / PS and Matroska, including the + own decoder (#809) in raw files, MP4 and MOV (#800), MPEG-TS / PS and Matroska (`A_MPEG/L1` and `L2`, #1402), including the bit rate / mode combinations ISO/IEC 11172-3 disallows but ffmpeg writes (its mono default is 384 kbit/s). OpenEXR stills and image sequences (#828): scan-line, uncompressed / RLE / ZIPS / ZIP, half / float / uint, RGB(A) or luminance, decoded to linear float frames and bit-exact against ffmpeg. -- **Missing:** free-format MPEG audio, MP1 in Matroska (`A_MPEG/L1`), E-AC-3 adaptive hybrid +- **Missing:** free-format MPEG audio, E-AC-3 adaptive hybrid transform, enhanced coupling and the extra channels of 7.1 dependent substreams (played as the 5.1 core), AVI (#598; listed as an extension but no demuxer), WMV / ASF, DV / DVCPRO / DV100, MPEG-4 Part 2, FLAC in MP4 (#603), camera RAW (R3D @@ -306,3 +306,4 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** | Date | Change | Summary | |---|---|---| | 2026-10-10 | major | Created from ROADMAP.md's "Where we are lacking" (2026-10-05); re-ranked against Premiere 26.5.2 and the 188 open issues; added `.prproj`, 10-bit / 4:2:2 camera decode, launch crashes and missing PR CI, localization, delivery formats | +| 2026-10-12 | minor | G10: MP1 (`A_MPEG/L1`) in Matroska imports (#1402) |