Files
filmcraft/crates/aac/README.md
JordyUSAandClaude Opus 5.5 d348dccd21 #922: eight-channel AAC reports and returns eight channels
The MP4 reader took the AudioSpecificConfig's channel configuration as a
count, but it is an index into ISO/IEC 14496-3 Table 1.19: configuration 7
(7.1) reported seven channels and dropped the last decoded one, and 12
(7.1 back, what ffmpeg 9 writes for 7.1) reported twelve. Count from the
table (with Amd 4: 11 → 7, 12 and 14 → 8, 13 → 24), and decode
configurations 11 and 12, reordered to the WAV/SMPTE order used for 5.1.
Checked against ffmpeg's decode channel by channel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:26:28 +07:00

192 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```rust
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 |
The decoder also reads configurations 11 (6.1: C L R Ls Rs Cs LFE) and 12 (7.1: C L R Ls Rs Lsr Rsr
LFE) from ISO/IEC 14496-3 Amd 4; ffmpeg 9 writes 7.1 AAC as configuration 12.
`Decoder::layout()` reports the element layout (it follows a PCE for configuration 0).
## Encoder
Per 1024-sample frame and channel element:
1. **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.
2. **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.
3. **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.
4. **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.
5. **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.
6. **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.4054` rounding), 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).
7. **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 `AudioSpecificConfig`s 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.