Browse / github.com/storytold/soundcraft / patch
Report the decoded length for AAC in MP4 in audio-io probe
audio-io: info reports more frames than the import decodes for AAC in MP4 (since 0.13) **Version / OS:** SoundCraft 0.13.1 windows-x64 portable, Windows 11. 0.12.1 is correct. **Steps** 1. Fixture [`fx_m4a_lastdur.m4a`](https://github.com/KNUFFIXX/artcraft-fixtures/blob/main/soundcraft/fx_m4a_lastdur.m4a): 10 s, 48 kHz stereo AAC-LC in MP4, 470 packets, no edit list; the last sample's stts duration is 1145 instead of 1024 (phone recorders write it like that: the last sample lasts until the track end). Generator: [`make_info_length_fixtures.py`](https://github.com/KNUFFIXX/artcraft-fixtures/blob/main/soundcraft/make_info_length_fixtures.py) (ffmpeg + Python standard library). 2.
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- Silvertree2010 (imported from pull request #1141, open)
- Base
60087e0f46032118bf309ed9ea161a9b4a872713- Size
- +97 −2 in 3 files
- Review
- pending
- Shared
- 2026-10-28
- Id
4f94bba19cbf0925db3442ce
How it went
No other operator has reported using it yet.
Diff
raw · crates/audio-io/src/symph.rs crates/audio-io/tests/it/main.rs crates/audio-io/tests/it/mp4_length.rs
@@ -580,13 +580,46 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf Ok((info, AudioBuffer { sample_rate, channels: planar })) } +/// Frames symphonia's AAC decoder yields for every packet; it supports no other frame length.+const AAC_FRAMES_PER_PACKET: u64 = 1024;++/// Whether `track` of `reader` is AAC in MP4. The MP4 reader reports the stts total as the length,+/// but the decoder yields a whole frame per packet (sample durations are not applied), so a last+/// sample whose duration differs from a frame makes the two disagree.+fn is_mp4_aac(reader: &dyn FormatReader, track_id: u32) -> bool {+ reader.format_info().short_name == "isomp4"+ && reader.tracks().iter().find(|t| t.id == track_id).and_then(audio_params).is_some_and(|p| p.codec == CODEC_ID_AAC)+}++/// The decoded length of AAC in MP4: count the track's packets without decoding them.+fn mp4_aac_frames(reader: &mut dyn FormatReader, track_id: u32) -> Result<u64> {+ let mut frames: u64 = 0;+ loop {+ match reader.next_packet() {+ Ok(Some(packet)) if packet.track_id == track_id => frames = frames.saturating_add(AAC_FRAMES_PER_PACKET),+ Ok(Some(_)) => {}+ Ok(None) => break,+ Err(SymError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,+ Err(SymError::ResetRequired) => break,+ Err(e) => {+ demux_failure(frames, e)?;+ break;+ }+ }+ }+ Ok(frames)+}+ pub(crate) fn probe(bytes: &[u8], detected: FileFormat) -> Result<AudioInfo> { guarded(|| {- let reader = match open(bytes, detected)? {+ let mut reader = match open(bytes, detected)? { Opened::Reader(reader) => reader, Opened::Empty(info) => return Ok(info), };- let (_, info) = info_of(reader.as_ref(), detected)?;+ let (track_id, mut info) = info_of(reader.as_ref(), detected)?;+ if info.frames > 0 && info.channels > 0 && is_mp4_aac(reader.as_ref(), track_id) {+ info.frames = mp4_aac_frames(reader.as_mut(), track_id)?;+ } if info.frames > 0 && info.channels > 0 { return Ok(info); }@@ -3,6 +3,7 @@ mod encode_format; mod float_headroom; mod malformed;+mod mp4_length; mod roundtrip; mod stream; mod wav_empty_data;new file mode 100644@@ -0,0 +1,61 @@+//! The length `probe` reports for AAC in MP4 is the length `decode` produces (issue #1113).++use soundcraft_audio_io::{decode, probe};++fn atom(kind: &[u8; 4], body: &[u8]) -> Vec<u8> {+ [&(8 + body.len() as u32).to_be_bytes()[..], kind, body].concat()+}++/// A full atom (version 0, no flags) whose body is `fields` as big-endian u32s.+fn full(kind: &[u8; 4], fields: &[u32]) -> Vec<u8> {+ let body: Vec<u8> = [0].iter().chain(fields).flat_map(|v: &u32| v.to_be_bytes()).collect();+ atom(kind, &body)+}++/// An MPEG-4 descriptor with a one-byte length.+fn descriptor(tag: u8, body: &[u8]) -> Vec<u8> {+ [&[tag, body.len() as u8][..], body].concat()+}++/// A silent AAC-LC raw frame for one channel: an SCE with `max_sfb` 0, then ID_END.+const SILENT_SCE: [u8; 4] = [0x00, 0xC8, 0x00, 0x07];++/// A mono 48 kHz AAC-LC MP4 without an edit list: `packets` frames, the last lasting `last_dur` in stts.+fn aac_mp4(packets: u32, last_dur: u32) -> Vec<u8> {+ // AudioSpecificConfig: AAC-LC, 48 kHz, one channel.+ let dsi = descriptor(5, &[0x11, 0x88]);+ let dcd = descriptor(4, &[&[0x40, 0x15, 0, 0, 0][..], &[0; 8], &dsi].concat());+ let es = descriptor(3, &[&[0, 1, 0][..], &dcd, &descriptor(6, &[2])].concat());+ let esds = atom(b"esds", &[&[0; 4][..], &es].concat());+ let base = [0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 16, 0, 0, 0, 0];+ let entry = atom(b"mp4a", &[&base[..], &(48_000u32 << 16).to_be_bytes(), &esds].concat());+ let total = (packets - 1) * 1024 + last_dur;+ let moov = |offset: u32| {+ let stsd = atom(b"stsd", &[&[0, 0, 0, 0, 0, 0, 0, 1][..], &entry].concat());+ let stbl = [+ stsd,+ full(b"stts", &[2, packets - 1, 1024, 1, last_dur]),+ full(b"stsc", &[1, 1, packets, 1]),+ full(b"stsz", &[SILENT_SCE.len() as u32, packets]),+ full(b"stco", &[1, offset]),+ ];+ let minf = [full(b"smhd", &[0]), atom(b"stbl", &stbl.concat())].concat();+ let mdia = [full(b"mdhd", &[0, 0, 48_000, total, 0]), full(b"hdlr", &[0, u32::from_be_bytes(*b"soun"), 0, 0, 0, 0]), atom(b"minf", &minf)];+ let trak = [full(b"tkhd", &[0; 20]), atom(b"mdia", &mdia.concat())].concat();+ atom(b"moov", &[full(b"mvhd", &[[0, 0, 48_000, total, 1 << 16].as_slice(), &[0; 18], &[2]].concat()), atom(b"trak", &trak)].concat())+ };+ let ftyp = atom(b"ftyp", b"M4A \0\0\0\0M4A isom");+ let offset = (ftyp.len() + moov(0).len() + 8) as u32;+ [ftyp, moov(offset), atom(b"mdat", &SILENT_SCE.repeat(packets as usize))].concat()+}++#[test]+fn aac_in_mp4_probe_length_matches_the_decoded_length() {+ // A last sample lasting until the track end (longer than a packet), and a control with a short one.+ for last_dur in [1145, 546] {+ let file = aac_mp4(10, last_dur);+ let (_, buf) = decode(&file, Some("m4a")).unwrap();+ assert_eq!(buf.frames(), 10 * 1024, "decoded frames, last stts duration {last_dur}");+ assert_eq!(probe(&file, Some("m4a")).unwrap().frames, buf.frames() as u64, "probed frames, last stts duration {last_dur}");+ }+}