Skip to content

craft-audio: AAC encoder (6/10) - #444

Draft
alehel wants to merge 9 commits into
storytold:mainfrom
alehel:craft-audio-split/06-aac-encode
Draft

alehel wants to merge 9 commits into
storytold:mainfrom
alehel:craft-audio-split/06-aac-encode

Conversation

@alehel

@alehel alehel commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Part 6 of 10 of #395, split in the order FredApps recommended there. Each PR in the stack targets main and builds on the one before it, so until the PRs below it merge, GitHub also shows their commits. This PR's own change is the last commit: 80e604c.

What the series is for

SoundCraft, FilmCraft and EffectCraft each wrote their own audio encoders and decoders, and several overlap: three WAV/AIFF writers, two FLAC encoders, and FilmCraft's AAC and MP1/MP2 decoders next to symphonia's. The series moves them into craft-libs so each format has one implementation that every app uses. Where two implementations overlapped, both were measured against ffmpeg and the reference tools and the stronger one was kept; #395 has the comparison.

When all ten parts have merged:

  • craft-media holds the containers (MP4/MOV, Matroska/WebM, Ogg), bit I/O, and the types every codec crate needs to take a track from a demuxer: CodecId, CodecParams, Container, ByteSource.
  • craft-audio holds the audio codecs and WAV/AIFF file I/O, each format behind its own feature, so an app compiles only what it uses. symphonia stays as a fallback for formats with no native decoder yet (MP3, FLAC decoding, ALAC, Vorbis, CAF, ADPCM, A-law/µ-law) until those are written.
  • A video crate can later sit beside craft-audio on craft-media, reusing the demuxers, CodecParams and the testkit. The two codec crates never depend on each other, so audio-only apps such as SoundCraft never compile video code.
%%{init: {"flowchart": {"htmlLabels": false}}}%%
graph TD
    SC["SoundCraft"] --> A
    FC["Film-/EffectCraft"] --> A
    FC -.-> V
    A["craft-audio<br/>audio codecs, file I/O"] --> M
    V["craft-video<br/>video codecs (later)"] -.-> M
    M["craft-media<br/>containers, bit I/O, shared types"]
    classDef later stroke-dasharray: 5 5
    class V later
Loading

Dashed: the planned video crate.

No app changes in the series. Each app keeps its own codec code until it switches to craft-audio in its own follow-up. #395 has the full design, the comparison of overlapping implementations and the migration notes for apps.

The ten parts

# PR Merged
1 #439 craft-media: shared codec identifiers, one ByteSource, the testkit ✅
2 #440 craft-media: Ogg demuxer ✅
3 #441 craft-audio: core types, WAV/AIFF read and write, peaks, symphonia
4 #442 craft-audio: FLAC encoder
5 #443 craft-audio: AAC decoder
6 #444 craft-audio: AAC encoder ← this PR
7 #445 craft-audio: Opus decoder
8 #446 craft-audio: Opus encoder
9 #447 craft-audio: AC-3 and E-AC-3 decoder
10 #448 craft-audio: MP1/MP2 decoder

All ten together are #395's final tree on current main, plus one change from the review: actions/checkout is pinned to a commit SHA in audio.yml. Each PR builds, passes clippy for every feature set and passes its tests on its own.

What this adds

FilmCraft's AAC-LC encoder behind aac-encode, which encode now includes. It has a psychoacoustic model, window switching, M/S, TNS, a bit reservoir, CBR and VBR, 1–8 channels and optional ADTS framing.

  • rayon encodes channels in parallel.
  • aac::aac_order maps WAV order to the encoder's input order.
  • Settings the encoder cannot use are aac::Error::InvalidSettings, which converts to AudioError::Encode.
  • The tests that need AAC written by our own encoder come with it: the encoder oracle and robustness tests, packet concealment, and TrackDecoder on ADTS without extradata.

Known issue in the ported code

From CodeRabbit on #395, ported unchanged from FilmCraft: the encoder accepts a NaN VBR quality. It will get its own issue and fix.

Testing

Run locally on this branch, as audio.yml runs them:

  • cargo fmt --check
  • clippy with -D warnings for each feature in the workflow's loop on its own, and for all features
  • rustdoc with -D warnings (all features, decode-native, lossless-encode)
  • WASM cargo check (all features, lossless-encode)
  • the workflow's subset test runs
  • cargo test -p craft-audio --all-features with CRAFT_MEDIA_REQUIRE_ORACLES=1 and ffmpeg 9.0.2

Not run locally: the MSRV job (1.95).

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

CodeRabbit Verify

  • Run CodeRabbit Verify to test the changes in this pull request.

The new crate without any codec module: what every later codec plugs
into.

- Shared types: `AudioBuffer`, `AudioInfo`, `ChannelLayout`/`Speaker`,
  `AudioError`, `EncodeOptions` (built with `new` and `with_*`),
  `EncodeFormat`, `BitDepth`, `BwfInfo`, `SampleFormat`.
- WAV/BWF/RF64 and AIFF/AIFF-C reading and writing from SoundCraft's
  `soundcraft-audio-io`, kept over EffectCraft's and FilmCraft's
  smaller writers (only it has BWF, dither, any speaker mask and
  streaming without a known length). `encode` and `FileEncoder` behind
  `pcm-encode`; `lossless-encode` and `encode` turn it on.
- `decode`, `probe`, `tracks` and the streaming, seekable `Decoder`
  (feature `decode-native`). The native path reads WAV, AIFF, and PCM in
  MP4/MOV and Matroska through craft-media's demuxers, packet by packet,
  with edit lists and `CodecDelay`/`DiscardPadding` applied.
  `TrackDecoder` decodes PCM; each codec PR adds its codec to it.
- symphonia 0.6 (MPL-2.0, used unmodified) behind the `symphonia`
  feature, part of `decode`: the fallback for every format with no
  native decoder yet. Its size fields are checked before it reads, and
  its panics are caught where they unwind.
- `peaks`: the min/max/RMS pyramid for waveform drawing.

Workspace: craft-media is a workspace dependency without default
features, so an encode-only craft-audio does not build the MP4
demuxer, and `symphonia-bundle-flac` builds without overflow checks in
dev profiles (its 32-bit LPC restore wraps on valid input).

CI: `audio.yml`, with clippy per feature, rustdoc, WASM, MSRV, and the
tests with ffmpeg oracles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@alehel
alehel force-pushed the craft-audio-split/06-aac-encode branch from 972558c to 57b055b Compare November 3, 2026 15:15
- Reading: AIFF that declares frames without an SSND chunk is Malformed; a Matroska PCM track's
  length counts from its first frame; seek targets saturate; an empty FLAC has one track; a
  failed header read is an Io error; deinterleave allocates fallibly; concealed silence is capped
  like a decoded packet, and symphonia's uses the damaged packet's own duration when known.
- Peaks sum squares in f64, so loud float samples give a finite RMS.
- Writing: .aifc maps to AIFF; BWF text is cut at a character boundary; a WAV byte rate past
  32 bits is an error; the FileEncoder docs say nothing may follow the sink's position.
- README: the Limits list says which codecs go through symphonia and which MP4 edit lists apply.
- Tests for each, alias and dither coverage, and a FLAC case in the routing oracle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@alehel
alehel force-pushed the craft-audio-split/06-aac-encode branch from 57b055b to 5c49638 Compare November 3, 2026 16:29
- Every header read is checked: `Native::open`, `native::tracks` and the no-symphonia error read
  it with `head_checked`. craft-media demuxer errors that are I/O failures (not a file ending
  early) are returned as `AudioError::Io` instead of sending the file to the other path.
- A Matroska track's presented length runs from its first frame plus `CodecDelay` (unclamped,
  in timestamp ticks with the delay rounded to one) to its end.
- symphonia's concealment allocates fallibly, and a damaged first packet is concealed when the
  demuxer gives its duration. `deinterleave` reserves its channel list fallibly too.
- `TrackDecoder::conceal` documents its cap at the decode limit.
- Tests: an I/O failure past the first 64 KiB, codec delay with the first block at -20, 0 and
  50 ms (checked with `probe`), a seek back after an overshoot, and the loud-sample RMS value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@alehel
alehel force-pushed the craft-audio-split/06-aac-encode branch from 5c49638 to 0b802e3 Compare November 3, 2026 17:30
Concealment allocates fallibly and fails with TooLarge when the silence would pass the 2^30-sample
decode limit, instead of returning less than the packet's length. The native path, the README,
the packets example and the doc example pass the error on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@alehel
alehel force-pushed the craft-audio-split/06-aac-encode branch from 0b802e3 to 5f2a2c2 Compare November 3, 2026 17:44
- Concealed silence is held to the decode limit inside pcm::silence, so symphonia's path checks
  frames × channels too, and symphonia's decoded audio is copied into fallibly allocated planes.
- TrackDecoder::reset forgets the last packet's length.
- Without symphonia, a damaged MP4, Matroska or Ogg file (truncated, invalid, no moov) is
  Malformed instead of Unsupported; a feature the demuxer does not read still names the build.
- Tests: an MP4 with no moov in a native-only build, conceal after a reset and past the limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@alehel
alehel force-pushed the craft-audio-split/06-aac-encode branch from 5f2a2c2 to 00d132c Compare November 3, 2026 18:11
alehel and others added 4 commits November 3, 2026 20:43
- A file the native demuxers find damaged is Malformed with symphonia too, when symphonia calls
  it unsupported; before, an ftyp-only MP4 was Unsupported in symphonia builds.
- symphonia's decoded blocks are held to the 2^30-sample decode limit before allocating.
- Symph::seek forgets the last packet's length, so a lost packet after a seek is not concealed
  with the length of one before it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FilmCraft's FLAC encoder behind `flac-encode`, kept over SoundCraft's:
linear prediction, stereo decorrelation and the STREAMINFO MD5 give
0.1-37% smaller files on 12 test signals, and the output is bit-exact
through `flac` and ffmpeg. It is 3-5x slower than SoundCraft's, still
about 56x real time.

`lossless-encode` is now `pcm-encode` plus `flac-encode`, and
`EncodeFormat::Flac` writes it through `encode` and `FileEncoder`.
With only one of the two features, the other's formats are refused as
`Unsupported`.

The tests that use FLAC files as decode fixtures (truncation,
corruption, metadata bounds, zero-frame files, container preflight)
come with it, as do the FLAC oracle tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FilmCraft's `filmcraft-aac` decoder as `craft_audio::aac` behind
`aac-decode`, kept over symphonia's: the same quality (about 135 dB
SNR against ffmpeg), and it decodes 5.1, which symphonia refuses.
HE-AAC decodes as its AAC-LC core, upsampled 2x to the stream's rate.

- `TrackDecoder` decodes AAC, including ADTS-framed packets without
  extradata (MPEG-TS, raw `.aac`).
- The native read path takes AAC in MP4 and Matroska and raw ADTS
  streams (frame sync over the file, with resync after damage).
- AAC errors convert into `AudioError`.

The AAC module's shared parts (Huffman, MDCT, TNS, ICS) arrive whole
as ported; their encoder half is unused until the encoder PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FilmCraft's AAC-LC encoder behind `aac-encode`: psychoacoustic model,
window switching, M/S, TNS, bit reservoir, CBR and VBR, 1-8 channels,
optional ADTS framing. `rayon` encodes channels in parallel.
`aac::aac_order` maps WAV order to the encoder's input order. Settings
the encoder cannot use are `aac::Error::InvalidSettings`, which
converts to `AudioError::Encode`. `encode` now turns on `aac-encode`.

Tests that need AAC streams written by our own encoder come with it:
the encoder oracle and robustness tests, packet concealment, and
`TrackDecoder` on ADTS without extradata.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@alehel
alehel force-pushed the craft-audio-split/06-aac-encode branch from 00d132c to 80e604c Compare November 3, 2026 19:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant