feat: MP3 + MP4/M4A (AAC) decode support

Closes the two format gaps v1's README explicitly deferred, after
vetting real pure-Rust alternatives:

MP3 via nanomp3 (MIT/Apache-2.0): a safe-Rust port of the well-
established minimp3 C algorithm. The obvious `minimp3` crate is FFI to
that C library and its own README discloses "multiple memory
unsoundness issues" -- nanomp3 is the pure port its own README points
to as the recommended alternative. Low unsafe density (~0.6% of a
6300-line file), no_std, zero dependencies.

MP4/M4A (AAC-LC/HE-AAC) via mp4 (pure-Rust ISO-BMFF demux, MIT, mature
since 2018) + oxideav-aac (pure-Rust AAC decode, MIT). redlux -- the
most established MP4/AAC crate -- turned out to depend on fdk-aac, FFI
to a C library, disqualifying it on the same grounds as symphonia.
oxideav-aac's top-of-file doc comment is stale (claims "decode bodies
not wired up yet") but its actual decode::StreamDecoder is complete:
SBR/PS support, and a pcm_byte_exact test suite that compares decoded
output sample-for-sample against a reference decoder with an honestly-
reasoned tolerance model (<=1 LSB deterministic, energy-correctness
only for PNS noise). Demux gives per-sample raw AAC access units +
AudioSpecificConfig fields (audioObjectType/samplingFrequencyIndex/
channelConfiguration); decode_raw_data_block is oxideav-aac's
transport-independent entry point that consumes them directly (no
ADTS framing needed for MP4's raw-frame storage).

format.rs sniffs MP4 via the ftyp box signature at offset 4, MP3 via
either a leading ID3v2 tag or a raw MPEG frame sync (0xFF + top 3 bits
set).

Verified: 8 new tests (5 mp3, 5 mp4 unit tests across
clawaudio-decode, minus overlap with sniff tests; 2 new end-to-end
facade tests) against real ffmpeg-generated MP3/M4A fixtures --
decode -> resample -> spectrogram all non-degenerate, dominant
frequency recovered within lossy-codec tolerance. Full workspace
fmt/clippy -D warnings clean.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-10 14:11:49 -07:00
co-authored by Claude Sonnet 5
parent 35b18af10c
commit 59fededf24
15 changed files with 485 additions and 28 deletions
Generated
+101 -4
View File
@@ -90,12 +90,24 @@ version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytemuck"
version = "1.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
[[package]] [[package]]
name = "byteorder" name = "byteorder"
version = "1.5.0" version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]] [[package]]
name = "cast" name = "cast"
version = "0.3.0" version = "0.3.0"
@@ -177,7 +189,7 @@ dependencies = [
"approx", "approx",
"clawaudio-decode", "clawaudio-decode",
"clawaudio-dsp", "clawaudio-dsp",
"thiserror", "thiserror 2.0.20",
] ]
[[package]] [[package]]
@@ -188,8 +200,11 @@ dependencies = [
"claxon", "claxon",
"hound", "hound",
"lewton", "lewton",
"mp4",
"nanomp3",
"oxideav-aac",
"rubato", "rubato",
"thiserror", "thiserror 2.0.20",
] ]
[[package]] [[package]]
@@ -199,7 +214,7 @@ dependencies = [
"approx", "approx",
"criterion", "criterion",
"rustfft", "rustfft",
"thiserror", "thiserror 2.0.20",
] ]
[[package]] [[package]]
@@ -376,6 +391,36 @@ version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "mp4"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9ef834d5ed55e494a2ae350220314dc4aacd1c43a9498b00e320e0ea352a5c3"
dependencies = [
"byteorder",
"bytes",
"num-rational",
"serde",
"serde_json",
"thiserror 1.0.69",
]
[[package]]
name = "nanomp3"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f69bdf7e634dc76798adc292ebd4f6e8e125cde6843a264fe398c52c3f7e8541"
[[package]]
name = "num-bigint"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
dependencies = [
"num-integer",
"num-traits",
]
[[package]] [[package]]
name = "num-complex" name = "num-complex"
version = "0.4.6" version = "0.4.6"
@@ -394,6 +439,18 @@ dependencies = [
"num-traits", "num-traits",
] ]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint",
"num-integer",
"num-traits",
"serde",
]
[[package]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@@ -424,6 +481,26 @@ version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]]
name = "oxideav-aac"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9246dc98ce532f45d3f8d0193996f7947cf06691ea533804641b27e9ee5b2f73"
dependencies = [
"oxideav-core",
]
[[package]]
name = "oxideav-core"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a68d9a74d6d0a7373589eca0c0bb8ab380c46fd1d7fc807e696d716cdad078c"
dependencies = [
"bytemuck",
"serde_json",
"thiserror 2.0.20",
]
[[package]] [[package]]
name = "page_size" name = "page_size"
version = "0.6.0" version = "0.6.0"
@@ -681,13 +758,33 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl 1.0.69",
]
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.20" version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
dependencies = [ dependencies = [
"thiserror-impl", "thiserror-impl 2.0.20",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
] ]
[[package]] [[package]]
+13
View File
@@ -33,6 +33,19 @@ rubato = { version = "4" }
hound = { version = "3" } hound = { version = "3" }
claxon = { version = "0.4" } claxon = { version = "0.4" }
lewton = { version = "0.10" } lewton = { version = "0.10" }
# MP3: a safe-Rust port of the well-established minimp3 C algorithm
# (the `minimp3` crate itself is FFI to that C library and its own
# README disclaims memory-unsoundness issues -- nanomp3 is the pure
# port, no C, minimal unsafe, no_std, no dependencies).
nanomp3 = { version = "0.1" }
# MP4/M4A container demuxing (pure Rust, MIT, mature since 2018) --
# gives us the AAC track's AudioSpecificConfig fields + per-sample raw
# AAC access units.
mp4 = { version = "0.14" }
# AAC-LC/HE-AAC decode. Pure Rust, MIT. The only mature AAC-capable
# alternative found (`redlux`) turned out to depend on `fdk-aac`, FFI
# to a C library -- disqualified on the same grounds as symphonia.
oxideav-aac = { version = "0.1" }
thiserror = { version = "2" } thiserror = { version = "2" }
+13 -11
View File
@@ -5,16 +5,16 @@ unblock omni-cortex's audio pipeline, which needed two things:
1. A production-quality FFT/STFT-based spectral encoder (replacing 1. A production-quality FFT/STFT-based spectral encoder (replacing
per-band Goertzel single-bin extraction). per-band Goertzel single-bin extraction).
2. A format-decoding front end for arbitrary audio files (WAV/FLAC/OGG 2. A format-decoding front end for arbitrary audio files (WAV/FLAC/OGG/
omni-cortex previously had no audio file ingest at all, only live MP3/MP4(AAC) — omni-cortex previously had no audio file ingest at
microphone capture). all, only live microphone capture).
## Crates ## Crates
| Crate | Role | | Crate | Role |
|---|---| |---|---|
| `clawaudio-dsp` | Pure spectral-analysis primitives: window functions, STFT, mel filterbank construction, log-mel spectrogram computation. No I/O, no file formats, no knowledge of any caller's types. | | `clawaudio-dsp` | Pure spectral-analysis primitives: window functions, STFT, mel filterbank construction, log-mel spectrogram computation. No I/O, no file formats, no knowledge of any caller's types. |
| `clawaudio-decode` | File/byte-stream decoding to PCM: WAV (`hound`), FLAC (`claxon`), OGG/Vorbis (`lewton`) — all pure Rust, no C bindings. Plus fixed-target-rate resampling (`rubato`). | | `clawaudio-decode` | File/byte-stream decoding to PCM: WAV (`hound`), FLAC (`claxon`), OGG/Vorbis (`lewton`), MP3 (`nanomp3`), MP4/M4A AAC-LC/HE-AAC (`mp4` demux + `oxideav-aac` decode) — all pure Rust, no C bindings. Plus fixed-target-rate resampling (`rubato`). |
| `clawaudio` | Facade: re-exports both, adds `SpectrogramEncoder<const DIM: usize>` — the const-generic type consumers wrap into their own encoder trait. | | `clawaudio` | Facade: re-exports both, adds `SpectrogramEncoder<const DIM: usize>` — the const-generic type consumers wrap into their own encoder trait. |
## Why not `symphonia`? ## Why not `symphonia`?
@@ -22,10 +22,12 @@ unblock omni-cortex's audio pipeline, which needed two things:
The obvious "one crate, broad format coverage" choice for audio decode is The obvious "one crate, broad format coverage" choice for audio decode is
MPL-2.0 licensed, which fails permissive-license-only allow-lists (e.g. MPL-2.0 licensed, which fails permissive-license-only allow-lists (e.g.
omni-cortex's `cargo-deny` config). This repo instead composes focused, omni-cortex's `cargo-deny` config). This repo instead composes focused,
single-format, permissively-licensed pure-Rust decoders. MP3 is not yet single-format, permissively-licensed pure-Rust decoders:
supported — no sufficiently mature pure-Rust MP3 decoder was vetted at
the time this repo was built; WAV/FLAC/OGG cover the common cases without | Format | Crate | Why not the obvious alternative |
it. |---|---|---|
| MP3 | `nanomp3` | The `minimp3` crate (the obvious name match) is FFI to the C `minimp3` library, and its own README discloses memory-unsoundness issues. `nanomp3` is a safe-Rust port of the same battle-tested algorithm. |
| MP4/M4A (AAC) | `mp4` (demux) + `oxideav-aac` (decode) | `redlux`, the most established MP4/AAC crate, depends on `fdk-aac` — FFI to a C library. `oxideav-aac` is a genuinely pure-Rust AAC-LC/HE-AAC decoder (SBR + PS support, extensive per-bitstream-element correctness tests including byte-exact PCM comparison against a reference decoder). |
## Design ## Design
@@ -45,9 +47,9 @@ cargo test --workspace
cargo bench -p clawaudio-dsp cargo bench -p clawaudio-dsp
``` ```
Test fixtures (`tests/fixtures/*.{wav,flac,ogg}`) are tiny synthesized Test fixtures (`tests/fixtures/*.{wav,flac,ogg,mp3,m4a}`) are tiny
sine tones, generated with `ffmpeg` and checked in as small binaries — synthesized sine tones, generated with `ffmpeg` and checked in as small
not real/copyrighted audio. binaries — not real/copyrighted audio.
## License ## License
+5 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "clawaudio-decode" name = "clawaudio-decode"
description = "clawaudio file-format decoding front end: WAV/FLAC/OGG -> PCM, plus resampling." description = "clawaudio file-format decoding front end: WAV/FLAC/OGG/MP3/MP4(AAC) -> PCM, plus resampling."
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
@@ -8,13 +8,16 @@ license.workspace = true
repository.workspace = true repository.workspace = true
authors.workspace = true authors.workspace = true
readme = "README.md" readme = "README.md"
keywords = ["audio", "decode", "wav", "flac", "vorbis"] keywords = ["audio", "decode", "wav", "flac", "mp3"]
categories = ["multimedia::audio", "encoding"] categories = ["multimedia::audio", "encoding"]
[dependencies] [dependencies]
hound = { workspace = true } hound = { workspace = true }
claxon = { workspace = true } claxon = { workspace = true }
lewton = { workspace = true } lewton = { workspace = true }
nanomp3 = { workspace = true }
mp4 = { workspace = true }
oxideav-aac = { workspace = true }
rubato = { workspace = true } rubato = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
+3 -3
View File
@@ -1,5 +1,5 @@
# clawaudio-decode # clawaudio-decode
File-format decoding front end for `clawaudio`: turns WAV/FLAC/OGG-Vorbis File-format decoding front end for `clawaudio`: turns WAV/FLAC/OGG-Vorbis/
files (or byte buffers) into interleaved `f32` PCM, plus fixed-target-rate MP3/MP4(AAC-LC, HE-AAC) files (or byte buffers) into interleaved `f32` PCM,
resampling. Pure Rust, no C bindings. plus fixed-target-rate resampling. Pure Rust, no C bindings.
+4
View File
@@ -14,6 +14,10 @@ pub enum DecodeError {
Flac(String), Flac(String),
#[error("Vorbis decode error: {0}")] #[error("Vorbis decode error: {0}")]
Vorbis(String), Vorbis(String),
#[error("MP3 decode error: {0}")]
Mp3(String),
#[error("MP4/AAC decode error: {0}")]
Mp4(String),
#[error("resample error: {0}")] #[error("resample error: {0}")]
Resample(String), Resample(String),
#[error("audio has zero samples")] #[error("audio has zero samples")]
+49
View File
@@ -6,6 +6,8 @@ pub enum AudioFormat {
Wav, Wav,
Flac, Flac,
Vorbis, Vorbis,
Mp3,
Mp4,
} }
/// Sniff `bytes`' format from its magic-number header. `None` if none of /// Sniff `bytes`' format from its magic-number header. `None` if none of
@@ -21,6 +23,21 @@ pub fn sniff_format(bytes: &[u8]) -> Option<AudioFormat> {
if bytes.len() >= 4 && &bytes[0..4] == b"OggS" { if bytes.len() >= 4 && &bytes[0..4] == b"OggS" {
return Some(AudioFormat::Vorbis); return Some(AudioFormat::Vorbis);
} }
// ISO-BMFF (MP4/M4A/...): a 4-byte box size followed by an "ftyp"
// box type at offset 4. This also catches M4A (same container,
// different brand/extension).
if bytes.len() >= 8 && &bytes[4..8] == b"ftyp" {
return Some(AudioFormat::Mp4);
}
// MP3: either a leading ID3v2 tag, or a raw MPEG audio frame sync
// (11 set bits: 0xFF followed by the top 3 bits of the next byte
// also set).
if bytes.len() >= 3 && &bytes[0..3] == b"ID3" {
return Some(AudioFormat::Mp3);
}
if bytes.len() >= 2 && bytes[0] == 0xFF && (bytes[1] & 0xE0) == 0xE0 {
return Some(AudioFormat::Mp3);
}
None None
} }
@@ -58,4 +75,36 @@ mod tests {
assert_eq!(sniff_format(&[]), None); assert_eq!(sniff_format(&[]), None);
assert_eq!(sniff_format(&[0u8; 2]), None); assert_eq!(sniff_format(&[0u8; 2]), None);
} }
#[test]
fn recognizes_mp4_via_ftyp() {
let mut header = vec![0, 0, 0, 0x20];
header.extend_from_slice(b"ftyp");
header.extend_from_slice(b"M4A \x00\x00\x00\x00");
assert_eq!(sniff_format(&header), Some(AudioFormat::Mp4));
}
#[test]
fn recognizes_mp3_via_id3_tag() {
assert_eq!(
sniff_format(b"ID3\x04\x00\x00\x00\x00\x00\x00"),
Some(AudioFormat::Mp3)
);
}
#[test]
fn recognizes_mp3_via_raw_frame_sync() {
// 0xFF followed by the top 3 bits set (MPEG version + layer bits) is a valid frame sync.
assert_eq!(
sniff_format(&[0xFF, 0xFB, 0x90, 0x00]),
Some(AudioFormat::Mp3)
);
}
#[test]
fn does_not_confuse_wav_riff_prefix_with_mp3_sync() {
// A single 0xFF byte alone (as could appear anywhere) must not
// false-positive without the companion high bits.
assert_eq!(sniff_format(&[0xFF, 0x00, 0x00]), None);
}
} }
+20 -3
View File
@@ -2,15 +2,16 @@
//! fixed-target-rate resampling. //! fixed-target-rate resampling.
//! //!
//! Deliberately pure Rust, no C bindings: WAV via `hound`, FLAC via //! Deliberately pure Rust, no C bindings: WAV via `hound`, FLAC via
//! `claxon`, OGG/Vorbis via `lewton`. MP3 is not yet supported (no //! `claxon`, OGG/Vorbis via `lewton`, MP3 via `nanomp3`, MP4/M4A (AAC-LC
//! sufficiently mature pure-Rust decoder was available at the time this //! / HE-AAC) via `mp4` (demux) + `oxideav-aac` (decode).
//! crate was built — see the workspace README for the reasoning).
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
mod error; mod error;
mod flac; mod flac;
mod format; mod format;
mod mp3;
mod mp4;
mod resample; mod resample;
mod vorbis; mod vorbis;
mod wav; mod wav;
@@ -55,6 +56,8 @@ pub fn decode_bytes(bytes: &[u8], hint: Option<AudioFormat>) -> Result<DecodedAu
AudioFormat::Wav => wav::decode(bytes), AudioFormat::Wav => wav::decode(bytes),
AudioFormat::Flac => flac::decode(bytes), AudioFormat::Flac => flac::decode(bytes),
AudioFormat::Vorbis => vorbis::decode(bytes), AudioFormat::Vorbis => vorbis::decode(bytes),
AudioFormat::Mp3 => mp3::decode(bytes),
AudioFormat::Mp4 => mp4::decode(bytes),
} }
} }
@@ -64,6 +67,8 @@ mod tests {
const FLAC_FIXTURE: &[u8] = include_bytes!("../tests/fixtures/tone_1000hz.flac"); const FLAC_FIXTURE: &[u8] = include_bytes!("../tests/fixtures/tone_1000hz.flac");
const OGG_FIXTURE: &[u8] = include_bytes!("../tests/fixtures/tone_1000hz.ogg"); const OGG_FIXTURE: &[u8] = include_bytes!("../tests/fixtures/tone_1000hz.ogg");
const MP3_FIXTURE: &[u8] = include_bytes!("../tests/fixtures/tone_1000hz.mp3");
const MP4_FIXTURE: &[u8] = include_bytes!("../tests/fixtures/tone_1000hz.m4a");
#[test] #[test]
fn decode_bytes_sniffs_flac_without_hint() { fn decode_bytes_sniffs_flac_without_hint() {
@@ -77,6 +82,18 @@ mod tests {
assert_eq!(decoded.sample_rate, 16_000); assert_eq!(decoded.sample_rate, 16_000);
} }
#[test]
fn decode_bytes_sniffs_mp3_without_hint() {
let decoded = decode_bytes(MP3_FIXTURE, None).expect("decode");
assert!(!decoded.samples.is_empty());
}
#[test]
fn decode_bytes_sniffs_mp4_without_hint() {
let decoded = decode_bytes(MP4_FIXTURE, None).expect("decode");
assert!(!decoded.samples.is_empty());
}
#[test] #[test]
fn decode_bytes_respects_explicit_hint() { fn decode_bytes_respects_explicit_hint() {
let decoded = decode_bytes(FLAC_FIXTURE, Some(AudioFormat::Flac)).expect("decode"); let decoded = decode_bytes(FLAC_FIXTURE, Some(AudioFormat::Flac)).expect("decode");
+98
View File
@@ -0,0 +1,98 @@
//! MP3 decode via `nanomp3` — a pure-Rust, safe port of the well-
//! established `minimp3` C algorithm. Deliberately not the `minimp3`
//! crate itself, which is FFI to that C library and whose own README
//! disclaims memory-unsoundness issues (see the workspace README for
//! the full reasoning).
use nanomp3::{Decoder, MAX_SAMPLES_PER_FRAME};
use crate::DecodedAudio;
use crate::error::DecodeError;
pub fn decode(bytes: &[u8]) -> Result<DecodedAudio, DecodeError> {
let mut decoder = Decoder::new();
let mut pcm_buf = [0.0f32; MAX_SAMPLES_PER_FRAME];
let mut samples: Vec<f32> = Vec::new();
let mut sample_rate: Option<u32> = None;
let mut channels: Option<u16> = None;
let mut offset = 0usize;
while offset < bytes.len() {
let (consumed, frame) = decoder.decode(&bytes[offset..], &mut pcm_buf);
if consumed == 0 {
// No progress possible (trailing garbage after the last
// frame, or no valid sync found at all) -- stop rather than
// loop forever.
break;
}
offset += consumed;
if let Some(info) = frame {
sample_rate.get_or_insert(info.sample_rate);
channels.get_or_insert(u16::from(info.channels.num()));
samples.extend_from_slice(&pcm_buf[..info.samples_produced]);
}
}
let (Some(sample_rate), Some(channels)) = (sample_rate, channels) else {
return Err(DecodeError::Mp3("no valid MP3 frames found".into()));
};
if samples.is_empty() {
return Err(DecodeError::EmptyAudio);
}
Ok(DecodedAudio {
sample_rate,
channels,
samples,
})
}
#[cfg(test)]
mod tests {
use super::*;
const FIXTURE: &[u8] = include_bytes!("../tests/fixtures/tone_1000hz.mp3");
#[test]
fn decodes_real_fixture() {
// ffmpeg-generated: sine=frequency=1000:sample_rate=16000:duration=0.3, mono.
let decoded = decode(FIXTURE).expect("decode fixture");
assert_eq!(decoded.channels, 1);
assert!(!decoded.samples.is_empty());
assert!(decoded.samples.iter().all(|s| (-1.5..=1.5).contains(s)));
}
#[test]
fn fixture_dominant_frequency_is_roughly_correct() {
let decoded = decode(FIXTURE).expect("decode fixture");
let zero_crossings = decoded
.samples
.windows(2)
.filter(|w| w[0].signum() != w[1].signum())
.count();
let duration_s = decoded.samples.len() as f32 / decoded.sample_rate as f32;
let estimated_freq = zero_crossings as f32 / (2.0 * duration_s);
// Lossy codec (and MP3 pads with encoder/decoder delay) — loose tolerance.
assert!(
(estimated_freq - 1000.0).abs() < 150.0,
"estimated freq {estimated_freq} should be roughly 1000Hz (lossy MP3)"
);
}
#[test]
fn malformed_bytes_error_not_panic() {
let garbage = vec![0u8; 16];
assert!(decode(&garbage).is_err());
}
#[test]
fn empty_bytes_error_not_panic() {
assert!(decode(&[]).is_err());
}
#[test]
fn truncated_fixture_errors_or_decodes_partially_without_panic() {
let truncated = &FIXTURE[..FIXTURE.len() / 2];
let _ = decode(truncated);
}
}
+158
View File
@@ -0,0 +1,158 @@
//! MP4/M4A (AAC-LC / HE-AAC) decode via `mp4` (pure-Rust container
//! demux) + `oxideav-aac` (pure-Rust AAC decode). Deliberately not
//! `redlux` — the most established MP4/AAC crate — which depends on
//! `fdk-aac`, FFI to a C library (see the workspace README for the full
//! reasoning).
use std::io::Cursor;
use mp4::{Mp4Reader, SampleFreqIndex, TrackType};
use oxideav_aac::decode::StreamDecoder;
use crate::DecodedAudio;
use crate::error::DecodeError;
/// ISO/IEC 14496-3 Table 1.18 `samplingFrequencyIndex` → Hz.
const fn freq_hz(idx: SampleFreqIndex) -> u32 {
match idx {
SampleFreqIndex::Freq96000 => 96_000,
SampleFreqIndex::Freq88200 => 88_200,
SampleFreqIndex::Freq64000 => 64_000,
SampleFreqIndex::Freq48000 => 48_000,
SampleFreqIndex::Freq44100 => 44_100,
SampleFreqIndex::Freq32000 => 32_000,
SampleFreqIndex::Freq24000 => 24_000,
SampleFreqIndex::Freq22050 => 22_050,
SampleFreqIndex::Freq16000 => 16_000,
SampleFreqIndex::Freq12000 => 12_000,
SampleFreqIndex::Freq11025 => 11_025,
SampleFreqIndex::Freq8000 => 8_000,
SampleFreqIndex::Freq7350 => 7_350,
}
}
pub fn decode(bytes: &[u8]) -> Result<DecodedAudio, DecodeError> {
let cursor = Cursor::new(bytes);
let mut reader = Mp4Reader::read_header(cursor, bytes.len() as u64)
.map_err(|e| DecodeError::Mp4(e.to_string()))?;
let track_id = reader
.tracks()
.values()
.find(|t| matches!(t.track_type(), Ok(TrackType::Audio)))
.map(mp4::Mp4Track::track_id)
.ok_or_else(|| DecodeError::Mp4("no audio track found".into()))?;
let (fs_index, chan_conf, aot, sample_rate) = {
let track = reader
.tracks()
.get(&track_id)
.ok_or_else(|| DecodeError::Mp4("audio track vanished after lookup".into()))?;
let fs_index = track
.sample_freq_index()
.map_err(|e| DecodeError::Mp4(e.to_string()))?;
let chan_conf = track
.channel_config()
.map_err(|e| DecodeError::Mp4(e.to_string()))?;
let aot = track
.audio_profile()
.map_err(|e| DecodeError::Mp4(e.to_string()))?;
(fs_index, chan_conf, aot, freq_hz(fs_index))
};
let sample_count = reader
.sample_count(track_id)
.map_err(|e| DecodeError::Mp4(e.to_string()))?;
let mut decoder = StreamDecoder::new();
let mut samples: Vec<f32> = Vec::new();
let mut channels: u16 = 0;
// MP4 sample IDs are 1-indexed (ISO/IEC 14496-12 `stsz`/`stco` table convention).
for sample_id in 1..=sample_count {
let Some(sample) = reader
.read_sample(track_id, sample_id)
.map_err(|e| DecodeError::Mp4(e.to_string()))?
else {
continue;
};
let frame = decoder
.decode_raw_data_block(
aot as u8,
fs_index as u8,
sample_rate,
chan_conf as u8,
1,
&sample.bytes,
)
.map_err(|e| DecodeError::Mp4(format!("AAC decode: {e}")))?;
if frame.channels > 0 {
channels = frame.channels as u16;
samples.extend(
frame
.pcm
.iter()
.map(|&s| f32::from(s) / f32::from(i16::MAX)),
);
}
}
if samples.is_empty() {
return Err(DecodeError::EmptyAudio);
}
Ok(DecodedAudio {
sample_rate,
channels,
samples,
})
}
#[cfg(test)]
mod tests {
use super::*;
const FIXTURE: &[u8] = include_bytes!("../tests/fixtures/tone_1000hz.m4a");
#[test]
fn decodes_real_fixture() {
// ffmpeg-generated: sine=frequency=1000:sample_rate=16000:duration=0.3, mono, AAC-LC in MP4.
let decoded = decode(FIXTURE).expect("decode fixture");
assert_eq!(decoded.channels, 1);
assert!(!decoded.samples.is_empty());
assert!(decoded.samples.iter().all(|s| (-1.5..=1.5).contains(s)));
}
#[test]
fn fixture_dominant_frequency_is_roughly_correct() {
let decoded = decode(FIXTURE).expect("decode fixture");
let zero_crossings = decoded
.samples
.windows(2)
.filter(|w| w[0].signum() != w[1].signum())
.count();
let duration_s = decoded.samples.len() as f32 / decoded.sample_rate as f32;
let estimated_freq = zero_crossings as f32 / (2.0 * duration_s);
assert!(
(estimated_freq - 1000.0).abs() < 150.0,
"estimated freq {estimated_freq} should be roughly 1000Hz (lossy AAC)"
);
}
#[test]
fn malformed_bytes_error_not_panic() {
let garbage = vec![0u8; 16];
assert!(decode(&garbage).is_err());
}
#[test]
fn empty_bytes_error_not_panic() {
assert!(decode(&[]).is_err());
}
#[test]
fn truncated_fixture_errors_not_panic() {
let truncated = &FIXTURE[..FIXTURE.len() / 2];
let _ = decode(truncated);
}
}
Binary file not shown.
Binary file not shown.
+16
View File
@@ -79,6 +79,8 @@ mod tests {
const WAV_FIXTURE: &[u8] = include_bytes!("../tests/fixtures/tone_1000hz.wav"); const WAV_FIXTURE: &[u8] = include_bytes!("../tests/fixtures/tone_1000hz.wav");
const FLAC_FIXTURE: &[u8] = include_bytes!("../tests/fixtures/tone_1000hz.flac"); const FLAC_FIXTURE: &[u8] = include_bytes!("../tests/fixtures/tone_1000hz.flac");
const OGG_FIXTURE: &[u8] = include_bytes!("../tests/fixtures/tone_1000hz.ogg"); const OGG_FIXTURE: &[u8] = include_bytes!("../tests/fixtures/tone_1000hz.ogg");
const MP3_FIXTURE: &[u8] = include_bytes!("../tests/fixtures/tone_1000hz.mp3");
const MP4_FIXTURE: &[u8] = include_bytes!("../tests/fixtures/tone_1000hz.m4a");
fn end_to_end(bytes: &[u8], hint: AudioFormat) -> [f32; 16] { fn end_to_end(bytes: &[u8], hint: AudioFormat) -> [f32; 16] {
let decoded = decode_bytes(bytes, Some(hint)).expect("decode"); let decoded = decode_bytes(bytes, Some(hint)).expect("decode");
@@ -112,6 +114,20 @@ mod tests {
assert!(out.iter().any(|&v| v != 0.0)); assert!(out.iter().any(|&v| v != 0.0));
} }
#[test]
fn mp3_end_to_end_is_non_degenerate() {
let out = end_to_end(MP3_FIXTURE, AudioFormat::Mp3);
assert!(out.iter().all(|v| v.is_finite()));
assert!(out.iter().any(|&v| v != 0.0));
}
#[test]
fn mp4_end_to_end_is_non_degenerate() {
let out = end_to_end(MP4_FIXTURE, AudioFormat::Mp4);
assert!(out.iter().all(|v| v.is_finite()));
assert!(out.iter().any(|&v| v != 0.0));
}
#[test] #[test]
fn end_to_end_is_deterministic() { fn end_to_end_is_deterministic() {
let a = end_to_end(WAV_FIXTURE, AudioFormat::Wav); let a = end_to_end(WAV_FIXTURE, AudioFormat::Wav);
Binary file not shown.
Binary file not shown.