New dev-dependency-only crate crates/testkit (L0, no FilmCraft deps): FILMCRAFT_FFMPEG/FILMCRAFT_FFPROBE, then PATH (incl. .exe), then known dirs; SKIPPED lines and FILMCRAFT_REQUIRE_ORACLES=1 to make skips fail; require_ffmpeg!/require_ffprobe!/require_oracles!; fixtures_dir() under <workspace>/target/fixtures with per-thread temp names and atomic generation. Oracle helpers in aac, opus, h264, hevc, h264enc, isobmff, matroska, prores and export migrated (codecs left on its local lookup). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|---|---|---|
| .. | ||
| src | ||
| tests | ||
| tools | ||
| Cargo.toml | ||
| README.md | ||
README.md
filmcraft-aac
A clean-room AAC-LC encoder and decoder (ISO/IEC 14496-3, MPEG-4 Audio, Low Complexity profile),
written from the standard and the public psychoacoustics literature. Layer L0: it depends only on
filmcraft-bitstream and thiserror (plus optional rayon), has no unsafe, and builds for
wasm32-unknown-unknown.
No FFmpeg/FAAC/FDK-AAC/libfaad or other GPL/LGPL code was read or ported. ffmpeg is used only as an external test oracle (decoding our streams, producing reference streams).
API
use filmcraft_aac::{Decoder, Encoder, EncoderConfig, BitrateMode, AdtsHeader, split_adts};
// Encoder: planar f32 (±1.0) → raw access units
let mut enc = Encoder::new(EncoderConfig::cbr(48_000, 2, 128_000))?; // or EncoderConfig::vbr(48_000, 2, 3.0)
let mut aus: Vec<Vec<u8>> = enc.encode(&[&left, &right]); // 0..n AUs per call, any chunk size
aus.extend(enc.flush()); // zero-pads and drains the look-ahead
let asc = enc.audio_specific_config(); // bytes for the MP4 `esds`
let delay = enc.priming_samples(); // 1024 → edit list media_time
let stats = enc.stats(); // frames, bytes, bitrate(), short blocks, M/S, TNS
// Decoder: AudioSpecificConfig + raw AUs → planar f32, 1024 samples per channel per AU
let mut dec = Decoder::new(&asc)?;
let pcm: Vec<Vec<f32>> = dec.decode(&aus[0])?;
// ADTS
let frames = split_adts(&adts_bytes)?; // [(AdtsHeader, raw payload)]
let mut dec = Decoder::from_adts(&frames[0].0)?; // channel config 0 → layout from the in-band PCE
EncoderConfig fields: sample_rate (the 13 AAC rates, 7.35–96 kHz), channels (1–8), mode
(BitrateMode::Cbr(bps) / BitrateMode::Vbr(quality 1.0..=5.0)), bandwidth (Hz, None = automatic
from bitrate/quality), tns, ms_stereo, window_shape (sine/KBD), adts (prefix each AU with a
7-byte ADTS header).
Channel order is AAC element order, for both encoder input and decoder output:
| channels | configuration | order |
|---|---|---|
| 1 | 1 | C |
| 2 | 2 | L R |
| 3 | 3 | C L R |
| 4 | 4 | C L R Cs |
| 5 | 5 | C L R Ls Rs |
| 6 | 6 | C L R Ls Rs LFE |
| 7 | 0 + PCE | C L R Ls Rs Cs LFE (PCE in the ASC; repeated in every AU when ADTS-framed) |
| 8 | 7 | C Lc Rc L R Ls Rs LFE |
Decoder::layout() reports the element layout (it follows a PCE for configuration 0).
Encoder
Per 1024-sample frame and channel element:
- Transient detection: energy of the first-difference (high-passed) signal in 128-sample
sub-blocks against the mean of the previous eight; one frame of look-ahead selects
ONLY_LONG/LONG_START/EIGHT_SHORT/LONG_STOP. Short windows are grouped by energy similarity (an attack starts a new group). LFE is always long. - MDCT (2048/256 points) via a DCT-IV on an N/4-point radix-2 complex FFT; sine or KBD (α = 4 long, 6 short) windows.
- Psychoacoustic model in the MDCT domain: band energies, tonality from the spectral flatness of a ≥16-line neighbourhood, Schroeder's spreading function in the Bark domain, tonality-dependent masking offset (30 dB tonal … 6 dB noise), Terhardt threshold in quiet (capped at 40 dB SPL) as a floor, pre-echo control (threshold ≤ 2× previous frame), perceptual entropy.
- TNS on long windows: Levinson–Durbin (order 8) over the spectrum above 1.4 kHz; applied when the prediction gain exceeds 1.4, with 4-bit reflection coefficients.
- M/S per band (and group) for channel pairs (always common window): chosen when the estimated perceptual cost of M and S (with the stricter L/R threshold, halved) is below that of L and R.
- Rate–distortion loop: one multiplier λ scales every band's allowed noise. Each band gets the
coarsest scalefactor whose measured quantisation error meets its target (binary search,
0.4054rounding), scalefactor differences are kept within ±60, codebooks and sections are chosen by dynamic programming over all 12 codebook states, and λ is bracketed and bisected until the frame fits its budget (CBR) or fixed from the quality (VBR). - Bit reservoir (CBR): frame budgets follow the perceptual entropy (short-window frames get extra),
bounded by the 6144-bit-per-channel decoder buffer; overflow is padded with fill elements and the
final frame drains the reservoir, so the stream is exactly
frames × bitrate × 1024 / rate.
Bandwidth: automatic from bitrate per full-bandwidth channel (e.g. 64 kbps/ch → 16 kHz, 96 → 19 kHz, ≥144 → 22 kHz) or from quality in VBR.
Delay: 1024 priming samples (priming_samples()); after flush() the decoded stream minus the priming
covers every input sample (ceil(n / 1024) + 1 access units).
Performance (Apple Silicon, release, single thread): 10 s of 44.1 kHz stereo encodes at ~85× realtime
and decodes at ~1000× realtime. With the rayon feature channels are quantised in parallel inside the
rate loop.
Decoder
Full AAC-LC syntax: SCE/CPE/LFE, DSE, FIL, PCE (reconfigures a configuration-0 stream), all window
sequences and both window shapes (shape of the previous frame for the left half), section data,
scalefactors, pulse data, TNS (all-pole synthesis, long and short), M/S (per band / all bands),
intensity stereo (in/out of phase, M/S-inverted), PNS (with correlated noise for ms_used noise bands
as specified), IMDCT and overlap-add. Output is planar f32 at ±1.0 full scale. All reads are bounds
checked: malformed access units return Err, never panic.
Test results
All produced by cargo test -p filmcraft-aac -- --nocapture (ffmpeg 9.0 at /opt/homebrew/bin as
oracle; tests skip when it is absent). Fixtures go to target/fixtures/aac/.
Encoder quality / bitrate (2 s synthetic signals, decoded by ffmpeg)
SNR is full-band; bwSNR is measured after low-passing both signals at the coded bandwidth; segSNR averages 1024-sample segments above −50 dBFS (clamped to −10…60 dB). "us-vs-ff" is the largest difference between our decoder and ffmpeg's on the same stream. Every stream decodes without ffmpeg errors, no output clips (peak < 1.0), and CBR is within ±0.1% of target (test limit ±5%).
| signal | kbps | actual | bw Hz | SNR | bwSNR | segSNR | short frames | us-vs-ff |
|---|---|---|---|---|---|---|---|---|
| sweep 2ch 44.1k | 64 | 64.00 | 11000 | 12.1 | 36.1 | 43.6 | 1 | 5e-7 |
| sweep 2ch 44.1k | 128 | 127.92 | 16000 | 26.1 | 27.3 | 54.2 | 1 | 3e-7 |
| sweep 2ch 44.1k | 320 | 319.90 | 21609 | 46.6 | 46.6 | 59.6 | 1 | 3e-7 |
| white noise 2ch 44.1k | 64 | 64.00 | 11000 | 1.3 | 3.3 | 1.3 | 1 | 5e-7 |
| white noise 2ch 44.1k | 320 | 319.91 | 21609 | 16.4 | 16.5 | 16.6 | 1 | 1.5e-6 |
| speech-like 2ch 48k | 64 | 64.00 | 11000 | 13.6 | 14.0 | 15.2 | 13 | 2e-7 |
| speech-like 2ch 48k | 128 | 127.98 | 16000 | 21.3 | 23.0 | 20.7 | 13 | 2e-7 |
| speech-like 2ch 48k | 320 | 319.96 | 22000 | 31.2 | 31.5 | 31.3 | 13 | 2e-7 |
| chords 2ch 44.1k | 64 | 64.01 | 11000 | 12.4 | 12.4 | 20.6 | 4 | 2e-7 |
| chords 2ch 44.1k | 128 | 128.00 | 16000 | 20.1 | 20.1 | 38.5 | 4 | 2e-7 |
| chords 2ch 44.1k | 320 | 319.99 | 21609 | 35.9 | 35.9 | 55.5 | 4 | 2e-7 |
| clicks 2ch 48k | 64 | 64.00 | 11000 | 10.4 | 11.2 | 48.6 | 11 | 9e-8 |
| clicks 2ch 48k | 128 | 128.00 | 16000 | 20.2 | 25.1 | 53.9 | 11 | 2e-7 |
| clicks 2ch 48k | 320 | 319.95 | 22000 | 39.6 | 39.6 | 58.0 | 11 | 1e-7 |
| speech-like 1ch 44.1k | 64 | 64.00 | 16000 | 18.4 | 19.2 | 18.0 | 15 | 2e-7 |
| chords 5.1 48k | 320 | 320.00 | 16000 | 27.2 | 27.2 | 37.9 | 9 | 2e-7 |
| chords 3/4/5/7/8ch 48k | 192–448 | exact | 16000 | 26–28 | 26–28 | 38 | 7–13 | 2e-7 |
| chords 2ch 8 k … 96 k (10 rates) | 24–256 | exact | 3.9–21 k | 13–38 | 17–47 | ≤3e-7 |
Reference points from ffmpeg's own AAC encoder on the same signals (stereo 48 kHz, segSNR): sweep 128k 39.7, speech 128k 21.2, chords 128k 32.8, clicks 128k 43.3 (ours: 52/20/39/54). Pure SNR is not a perceptual measure: the model deliberately leaves noise under the masking threshold, and full-band white noise is the worst case by design.
VBR (Vbr(q), 44.1 kHz stereo): chords q1 62 kbps / q3 139 / q5 225 (segSNR 18 / 39 / 51);
speech-like q1 42 / q3 166 / q5 272 kbps (segSNR 13 / 29 / 41). Bitrate and quality grow
monotonically with q.
Decoder accuracy vs ffmpeg (streams from ffmpeg -c:a aac, PNS off)
| stream | max abs error |
|---|---|
| stereo 44.1k 128k / 48k 256k / 96k 256k | 1.2e-7 / 1.2e-7 / 1.3e-7 |
| mono 48k 64k / 22.05k 32k / 16k 24k / 8k 16k | 1.5e-7 / 1.2e-7 / 1.2e-7 / 8.9e-8 |
| stereo 44.1k 96k with intensity stereo + M/S + TNS | 1.2e-7 |
| stereo 32k 48k with intensity stereo | 1.2e-7 |
| 3.0, 5.1 (320k), 7.1 wide with PCE (448k) | 1.2e-7, 1.5e-7, 1.3e-7 |
stereo 44.1k 160k -aac_coder fast |
1.2e-7 |
Our own encoder's streams: ≤1.6e-6 against ffmpeg for every case above. PNS (ffmpeg's encoder does not
emit it, so streams are hand-built): total energy within 0.15 dB of ffmpeg; per-frame energy within
0.8 dB (random vectors differ). For noise bands flagged ms_used we produce identical L/R noise as
the standard specifies; ffmpeg produces independent noise there.
Robustness
12 000 mutated access units (bit flips, byte overwrites, truncation, extension, splices, random
buffers) over mono/stereo/5.1/7-channel streams, 20 000 random AudioSpecificConfigs and 2 000 random
ADTS streams: no panics. The encoder accepts NaN/±inf/over-range input, any chunk size, and missing
channels.
Clean-room notes
- Syntax, tables (scalefactor bands, TNS limits), filterbank, TNS, PNS, M/S and intensity stereo are implemented from ISO/IEC 14496-3.
- The Huffman codebooks (
src/huffman_tables.rs) are the normative values of the standard's Annex 4.A, recovered by black-box probing of the ffmpeg binary as an oracle:tools/contains the Python (stdlib-only) scripts that craft single-frame streams repeating a candidate bit string, fit the decoded output to the IMDCT basis, and walk the code tree. Every table is verified to be a complete prefix code (Kraft sum exactly 1) covering each index once (unit test), and the decoder matches ffmpeg to ~1e-7 on real streams. - Psychoacoustics follow textbook models (Bark scale, Schroeder spreading, Terhardt ATH, Johnston-style tonality/SFM offsets, perceptual entropy).
Gaps / not implemented
- HE-AAC v1/v2 (SBR/PS): explicit signalling is parsed and only the AAC-LC core is decoded (half-rate, no SBR); implicit SBR fill elements are skipped.
- AAC Main (prediction), SSR (gain control), LTP, ER/LD/ELD object types, coupling channel elements
(CCE →
Err(Unsupported)), 960-sample frames. - Encoder: no PNS or intensity stereo (decoder supports both), no TNS on short windows, no pulse data; window shape is fixed per stream (sine or KBD). Short blocks at onsets cost segmental SNR on speech-like material relative to long windows + TNS; perceptually they avoid pre-echo.
- No error concealment: a corrupt AU returns
Err; the caller may substitute silence. - ADTS with CRC is parsed (header skipped, CRC not verified); multi-block ADTS frames are split per frame only.