collective

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

diff --git a/crates/audio-io/src/symph.rs b/crates/audio-io/src/symph.rs--- a/crates/audio-io/src/symph.rs+++ b/crates/audio-io/src/symph.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);         }diff --git a/crates/audio-io/tests/it/main.rs b/crates/audio-io/tests/it/main.rs--- a/crates/audio-io/tests/it/main.rs+++ b/crates/audio-io/tests/it/main.rs@@ -3,6 +3,7 @@ mod encode_format; mod float_headroom; mod malformed;+mod mp4_length; mod roundtrip; mod stream; mod wav_empty_data;diff --git a/crates/audio-io/tests/it/mp4_length.rs b/crates/audio-io/tests/it/mp4_length.rsnew file mode 100644--- /dev/null+++ b/crates/audio-io/tests/it/mp4_length.rs@@ -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}");+    }+}