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:
co-authored by
Claude Sonnet 5
parent
35b18af10c
commit
59fededf24
Generated
+101
-4
@@ -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
@@ -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" }
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,15 +8,18 @@ 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 }
|
||||||
rubato = { workspace = true }
|
nanomp3 = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
mp4 = { workspace = true }
|
||||||
|
oxideav-aac = { workspace = true }
|
||||||
|
rubato = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
approx = { workspace = true }
|
approx = { workspace = true }
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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")]
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
@@ -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);
|
||||||
|
|||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
Reference in New Issue
Block a user