* craft-audio: AAC decoder
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>
* craft-audio: review fixes, round 1 (#443)
Raw ADTS and TrackDecoder:
- Channel configuration 0 (a PCE in the stream, as ffmpeg's -aac_pce and
AudioToolbox's 7.x write) decodes; it was refused.
- Frames with several raw data blocks are refused; only their first block
was decoded, silently losing the rest.
- A frame that changes the sample rate, profile or channel configuration is
concealed; it played at the wrong speed.
- A source that fails during the frame scan is AudioError::Io; it looked
like the end of the file.
- A stream after an ID3 tag longer than the file head is found.
- conceal counts all the ADTS frames of a packet, and a packet of more than
1024 frames is refused before it is decoded into memory.
AAC decoder:
- Indexed channel configurations place elements by arrival order, not tag,
as ffmpeg does; tagged differently, channels were swapped.
- A PCE in an access unit that fails no longer changes the layout, which
left every later packet failing.
- Non-finite samples are zeroed before the output and the overlap.
MP4: probe and decode agree: the samples' durations end the track when no
single edit does, and an edit cannot outlast the media.
The routing oracle fails on a fixture ffmpeg cannot write when oracles are
required. The README's Limits names HE-AAC v2 as mono, multi-block ADTS,
iTunSMPB, Matroska's tick precision and AAC in CAF.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* craft-audio: review fixes, round 2 (#443)
Corrects round 1:
- A multi-block ADTS frame after the stream has started is concealed at its
whole length; round 1's filter skipped it, shifting the audio after it.
- Sample durations only trim an MP4's last packet: wrong ones (some count a
HE-AAC packet's core frames) were cutting audio. Durations of 0 with an edit
use the edit.
Raw ADTS packets now keep their headers, so TrackDecoder alone handles format
changes, multi-block frames and concealment, for apps as for files, and nested
ADTS payloads are not split again. Headers come from the frame scan's reads,
not one read per frame. The stream's format is the one most of its first frames
share, so one damaged header does not silence the file. The ID3 walk and a
packet's ADTS split are bounded.
AAC:
- HE-AAC is delayed by the SBR filterbanks' 481 core samples; it played 19.5 ms
early against ffmpeg in every container.
- A failed access unit leaves the decoder's state as it was, so the next packet
decodes as if it had not been there.
- reset restarts the noise generator, so a seek into PNS repeats.
- A signalled rate that is not twice the core's plays the core; it reported the
signalled rate with the core's samples.
The decoder oracle's tolerance tightens from 1e-3 to 1e-5. Tests cover HE-AAC.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* craft-audio: review fixes, round 3 (#443)
Corrects round 2:
- ADTS headers are compared with the stream's format only when the first header
gave it. With extradata the format comes from there, so ADTS in MP4 whose
headers say something else decodes again; every packet had been concealed.
- A stream's only program config element is found in its first frames even when
that frame's header is damaged.
- The test of wrong sample durations patched the second run's count, not the
delta, so "durations of one" changed nothing. It now patches the delta, and
checks a file was changed and that seeks land on the same audio.
MP4 seeks place a packet by its index when every packet decodes to the same
length, as decoding does; they used the durations round 2 had stopped trusting
for the length, and returned the wrong audio. A last delta of 0 is overridden too.
Docs: what TrackInfo says for raw ADTS, that opening one reads the whole file, and
which codecs symphonia reads.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* craft-audio: review fixes, round 4 (#443)
- TrackDecoder takes an ADTS stream's format from the one most of the lookahead
shares, as the file path did, so one damaged first header does not fail every
packet of an MPEG-TS stream. open_adts now just asks it.
- MP4 samples of several ADTS frames keep the sample table's timing; a packet
count of one frame each truncated them silently.
- Sample durations past what the packets decode to no longer over-report the
length.
- split_adts_upto is not behind aac-decode, as split_adts is not: an aac-encode
build in the next PR did not compile.
Removes an unreachable ID_END check and open_adts's unused core rate.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* craft-audio: review fixes, round 5 (#443)
No change in decoding. An aac-encode build in the next PR needs the
split_adts_upto re-export behind aac-decode, and ID_END only in tests.
Docs say the lookahead's headers give an ADTS stream's configuration, that AAC in
CAF still goes through symphonia, and that ProgramConfig is not #[non_exhaustive].
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* craft-audio: review fixes, round 6 (#443)
Two tests from round 1 failed on ffmpeg before 9, including CI's 8.0. Neither was
a decoding fault:
- ffmpeg 9 ends an MP4 with no edit list at its sample durations and earlier
versions present the whole last packet, so the length is checked against the
file's known duration, not ffmpeg's decode.
- The order ffmpeg gives channels with -aac_pce differs by version, so each decoded
channel must match a different one of ffmpeg's, whatever the order.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* craft-audio: review fixes, round 7 (#443)
Reading:
- an element of another type or a tag a program config does not list is
refused, not written to another channel; only an LFE coded as an SCE
is still allowed, as ffmpeg allows it
- an explicit sampling rate of 0 is refused
- a packet's later ADTS frames are searched for SBR
- a multi-frame ADTS packet that fails leaves the decoder as it was, and
one whose frames change the channel count is an error
- the whole ADTS frames before a damaged end of a packet decode
- a failed read of a raw stream's first frames is an I/O error
- an MP4 sample of several ADTS frames past the lookahead keeps its length
- an Ogg file with leading bytes keeps its track order and mask comments
Writing:
- ProgramConfig::write, AudioSpecificConfig::to_bytes and
AdtsHeader::write refuse what their fields cannot hold
Tests:
- the routing test checks one alignment for the ffmpeg version under test
- a default decode past an Ogg stream symphonia has no track for
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* craft-audio: review fixes, round 8 (#443)
From wanikhawar's review:
- AudioSpecificConfig::parse reads backward-compatible SBR and PS signalling
(a 0x2b7 sync extension after the config), which craft-media's Matroska
demuxer writes for legacy HE-AAC IDs. Without lookahead those streams decoded
as their core.
- An MP4 media edit after one empty edit is applied: its priming skip and its
end. The empty edit's delay is not added as silence.
- A packet lost whole is concealed as long as the last one decoded, so a stream
of multi-frame ADTS packets keeps its timing.
- ADTS-framed MP4 samples keep their sample table's timing, instead of every
sample being read at open to look for several frames in one.
- AdtsHeader::write refuses an object type, sampling frequency index or channel
configuration its fields cannot hold; it masked them into other values.
- AudioSpecificConfig::lc no longer panics for a sampling frequency index with no
rate, and the decoder refuses such a config.
- AudioSpecificConfig::to_bytes refuses channel configuration 0 without a program
config element, which would not parse back.
- Upsample2x starts each channel's history afresh when the channel count
changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* craft-audio: review fixes, round 9 (#443)
From CodeRabbit's review of round 8:
- AudioSpecificConfig::parse consumes extensionFlag3 after the config when
extensionFlag is set, so the 0x2b7 sync extension after it is found.
- AudioSpecificConfig::to_bytes refuses a sampling rate of 0, which would not
parse back.
- ADTS-framed HE-AAC in MP4 whose sample durations count core frames is not cut
to the durations' half length: durations short of one frame per sample no
longer end an ADTS-framed track, and its length is unknown until decoded.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* craft-audio: review fixes, round 10 (#443)
ADTS-framed AAC in MP4: durations short of the frames in the samples read
ahead no longer end the track. With HE-AAC samples of two ADTS frames each,
timed as two core frames, the durations matched one output frame per sample,
so the undercount check passed and the track stopped at half its length,
which probe reported as known. The frames each read-ahead sample holds are
now counted, and durations short of them leave the length unknown until
decoded, as durations short of one frame per sample already did. Correctly
timed AAC-LC samples of several frames keep their end trim.
Reported by FredApps.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* craft-audio: review fixes, round 11 (#443)
Corrects round 10. A short ADTS-framed MP4 track, whose last sample is in
the read-ahead, lost that sample's trim when it cut more than one frame:
the trim counted as durations falling short, so the length was left to
decoding and its padding was presented. The track's last sample is now left
out of the read-ahead comparison.
Reported by CodeRabbit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* craft-audio: document the ADTS-in-MP4 seek and edit limits (#443)
AAC in MP4 stored as ADTS frames, with sample durations that count fewer
frames than the samples hold, decodes in full from the start, but seeks
place samples by those durations and land later than asked, and an edit
list measured in them can end the track early. The README's Limits section
now says so, and the seek example points to it.
Reported by FredApps.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* craft-audio: say why the ADTS-in-MP4 seek limit is kept (#443)
The Limits entry from 79f5839 said what goes wrong but not why it is left.
It now says that such a file is outside the MP4 specification: an MP4 AAC
sample is one raw access unit with the configuration in the sample
description (ISO/IEC 14496-14, 14496-3), so ADTS headers do not belong in
it, and a sample's duration is the time it decodes to (ISO/IEC 14496-12).
It says why files within the specification are not affected, what still
works, and why seeks and edits in such files are not corrected: finding
where each sample's audio starts means counting the ADTS frames in every
sample before it, one read per sample, which would slow every ADTS-framed
MP4 at open to work around files outside the specification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* craft-audio: AAC encoder
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>
* craft-audio: review fixes, round 1 (#444)
From CodeRabbit's first review of the AAC encoder:
- CBR bitrate limits use the exact bits per frame (bitrate * 1024
against channels * sample_rate) instead of the truncated quotient, so
288001 b/s at 48 kHz mono is rejected.
- Rates below 48 bits per channel per frame are rejected. That is the
largest silent frame per channel measured across 1-8 channels; below
it the encoder overshot the requested rate (96 kHz mono at 1000 b/s
came out near 4500 b/s).
- Fill elements are capped so the byte-aligned access unit stays within
6144 bits per channel. write_fill can write 8 bits over its request,
which gave 769-byte mono units at the maximum rate and when the
reservoir was flushed on the last frame.
- encode() buffers one frame of input at a time. Appending the whole
slice and draining 1024 samples per frame copied the rest of the
input once per frame, quadratic in the slice length. Output is
byte-identical to feeding the input in chunks.
- README: 8 channels are taken in channel configuration 7's element
order; a WAV 7.1 buffer has no equivalent there.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* craft-audio: review fixes, round 2 (#444)
From CodeRabbit's review of round 1:
- The CBR minimum adds the program config's bits when ADTS is on. The
7-channel layout repeats it in every ADTS frame, and at full
bandwidth a silent frame took 344 bits against the 336 bits that
31,500 b/s gives at 96 kHz. Bandwidth alone stays within 48 bits
per channel.
- write_fill never writes more than it is asked for: each element's
size comes from the bits left, so the total ends 0-6 bits short.
The old version could pass the request by 8 bits and could also
stop early (asked for 14, it wrote 7).
- The encoder fills to the unit's target end, capped at
max_frame_bits, instead of keeping round 1's 8-bit margin. Frames at
the maximum rate were 767 bytes when there was little to code; they
are now exactly 768 bytes per channel.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* craft-audio: review fixes, round 3 (#444)
Encoder::new rejects a NaN VBR quality, the known issue ported from
FilmCraft and listed in the PR description.
NaN passes f32::clamp, so the rate-distortion multiplier became NaN.
The frame-size loop compares it with `<` and never ran, and no band
was skipped, so every frame was coded at full precision: mono units
reached 2465 bytes against the 768-byte limit, and 8-channel units of
up to 17024 bytes did not fit the ADTS length field, so they went out
without headers. Infinities and other out-of-range values are clamped
and stay within the limit; the test checks both.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* craft-audio: review fixes, round 4 (#444)
From CodeRabbit's review of round 3:
- The CBR fill target is rounded down to a whole byte. Round 2 filled
to an unaligned target, so after byte alignment a unit could end up
to 7 bits past the bits it had: in a sweep of silent CBR encodes,
1188 of 13200 frames overspent, all of them frames with fill, hidden
by the level clamp at 0. The unit now ends exactly at the rounded
target. The 0-7 bits rounded off stay in the reservoir and are left
unspent after the last frame; the comment says so instead of
claiming an exact total.
- The NaN-quality test checks that infinities and out-of-range values
encode exactly as the nearest end of the 0.5-6.0 range.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
craft-audio
Audio file I/O and codecs for the Craft apps. Native implementations: WAV and AIFF read/write, FLAC encoder, AAC decoder/encoder. Formats without a native decoder are decoded by symphonia, which is to be replaced by native decoders.
- Input is a byte slice or any
ByteSource(a file, a shared buffer); output is byte vectors or anyWrite + Seek. The crate opens no files itself. - Decoded audio is planar
f32, full scale ±1, inAudioBuffer(sample_rate, oneVec<f32>per channel), in WAV channel order (L R C LFE, back, side) whenever the channel layout is known. - Malformed input returns an error, never a panic (see symphonia for the one
exception). No
unsafe. Readers and decoders are tested with corrupted and truncated input. - Implemented from the published specifications; tested against ffmpeg.
Builds for
wasm32-unknown-unknown. - Containers (MP4/MOV, Matroska/WebM, Ogg), bit I/O and the
Container/CodecId/CodecParamstypes come fromcraft-media. This crate never depends on video codecs.
Cargo features
No default features. The shared types (AudioBuffer, AudioInfo, ChannelLayout, AudioError,
EncodeOptions, ByteSource, …) build with none.
| Feature | Provides | Pulls in |
|---|---|---|
decode |
decode, probe, Decoder, tracks, TrackDecoder; every format in Format support |
decode-native, symphonia |
decode-native |
the same, native formats only (others are Unsupported) |
aac-decode, craft-media isobmff/matroska/ogg, log |
symphonia |
the fallback for formats with no native decoder; turning it on is the same as decode |
decode-native, symphonia |
encode |
every encoder: the WAV, AIFF and FLAC file writers, and the AAC packet encoder | lossless-encode, aac-encode |
lossless-encode |
WAV, AIFF and FLAC writing | pcm-encode, flac-encode |
pcm-encode |
encode, FileEncoder for WAV and AIFF; wav_float_header |
none |
flac-encode |
encode, FileEncoder for FLAC |
craft-media bitstream |
aac-decode, aac-encode |
aac::Decoder, aac::Encoder |
craft-media bitstream |
peaks |
peaks::Peaks |
none |
rayon |
parallel AAC encoding (with aac-encode) |
rayon |
serde |
Serialize/Deserialize on the shared types |
serde |
aac-decode also gives TrackDecoder, for AAC and PCM.
An app that decodes 32-bit FLAC with decode needs a profile override in its workspace; see
symphonia.
WAV, AIFF and FLAC export only (no decoders, no demuxers, no symphonia):
craft-audio = { git = "https://github.com/storytold/craft-libs", rev = "<commit>", features = ["lossless-encode"] }
Decode, every encoder and waveform overviews:
craft-audio = { git = "https://github.com/storytold/craft-libs", rev = "<commit>", features = ["decode", "encode", "peaks"] }
Packets from an app's own demuxer, AAC both ways:
craft-audio = { git = "https://github.com/storytold/craft-libs", rev = "<commit>", features = ["aac-decode", "aac-encode"] }
craft-audio re-exports the craft-media types its API uses (ByteSource, ReadSeekSource,
CodecParams, …), and craft-media itself as craft_audio::craft_media. Use that to reach the
containers directly (for example to pass an isobmff::Track's codec_params() to
TrackDecoder); decode-native turns on all three. A separate craft-media dependency must use
the same rev as craft-audio: with two revisions Cargo builds two copies, and their types do
not match.
Usage
Runnable versions are in examples/:
cargo run -p craft-audio --example info --features decode -- take.m4a
cargo run -p craft-audio --example encode --features lossless-encode,aac-encode -- out
cargo run -p craft-audio --example stream --features decode -- long-recording.m4a 60
cargo run -p craft-audio --example packets --features decode -- take.m4a
stream takes a file and a start time in seconds; packets takes an MP4 file. The entry points
(decode, probe, tracks, Decoder, TrackDecoder, encode, FileEncoder) and the
functions around them have rustdoc examples.
Decode a file
use craft_audio::{decode, probe};
let bytes = std::fs::read("take.m4a")?;
// Container, codec, rate, speaker layout and length. Read from the headers; when they give no
// length (an MP3 without a Xing frame, PCM in Matroska), the file is decoded to count it.
let info = probe(&bytes, Some("m4a"))?;
println!("{} in {}, {} Hz, {}", info.codec.name(), info.container.name(), info.sample_rate, info.layout);
// The first audio track: planar f32 in WAV channel order, trimmed to its presentation length.
let (info, buf) = decode(&bytes, Some("m4a"))?;
let left: &[f32] = &buf.channels[0];
decode returns TooLarge above 2³⁰ samples (4 GiB of f32). info.damaged counts packets
that were damaged and replaced by silence of their length.
Choose a track
use craft_audio::{DecodeOptions, decode_with, tracks};
let list = tracks(&bytes, None)?; // codec, rate and channels of every audio track
if let Some(second) = list.get(1) {
let opts = DecodeOptions::default().with_track(second.index);
let (info, buf) = decode_with(&bytes, None, &opts)?;
}
A track index the file does not have is Unsupported. DecodeOptions::with_max_samples sets
another size limit. tracks_of lists the tracks of a file read from a ByteSource;
detect_container names a file's container from its first bytes.
Stream and seek
use craft_audio::{DecodeOptions, Decoder};
let file = std::fs::File::open("long-recording.m4a")?;
let mut dec = Decoder::open(file, Some("m4a"), &DecodeOptions::default())?;
dec.seek(48_000 * 60)?; // sample-exact (see Limits): the next block starts here
while let Some(block) = dec.next_block()? {
// block.channels: planar f32, WAV channel order
}
Decoder::open takes any ByteSource that is Send + Sync (SharedSource): a Vec<u8> or
Arc<[u8]>, a ReadSeekSource around any Read + Seek, or a std::fs::File. Prefer
ReadSeekSource for files: it takes the length once and reports a failure when it is made. On
single-threaded WebAssembly the source need not be Send, so a reader over a JS Blob works.
Decoder::from_bytes does the same for a borrowed buffer.
seek returns the frame decoding resumes at. A seek to or past the end of a file whose length is
known stops at the end, and the next next_block returns None. When length_known() is false,
info().frames is 0: add up the block lengths, or call Decoder::probe, which decodes the rest
of the file to count them. Decoder::probe is also how to probe a file on disk.
Native formats read their samples from the source packet by packet. The container's packet index stays in memory (tens of bytes per packet), and opening a Matroska file or a raw ADTS stream reads through the whole file to build it. Formats symphonia decodes are read whole first.
A damaged packet is concealed and counted in info().damaged. A source that fails a read (a
network drop) returns AudioError::Io from next_block instead, and that packet is skipped. To
retry it, keep the position: start from the frame the last seek returned (0 without one), add
each block's frames, and seek to that position.
Decode packets from your own demuxer
TrackDecoder takes a track's CodecParams and the first packets. The parameters come from any
craft-media demuxer's codec_params() (see
craft-media's codec parameters), or
are built with CodecParams::new for another container:
use craft_audio::{CodecId, CodecParams, TrackDecoder};
let params = CodecParams::new(CodecId::Aac).with_extradata(asc).with_sample_rate(48_000);
let lookahead: Vec<&[u8]> = packets.iter().take(craft_audio::LOOKAHEAD_PACKETS).map(Vec::as_slice).collect();
let mut dec = TrackDecoder::new(¶ms, &lookahead)?;
for p in &packets {
let block = dec.decode(p).or_else(|_| dec.conceal(Some(p)))?;
}
It decodes AAC (finding HE-AAC in the lookahead) and PCM, and returns AudioBuffers in WAV
order when layout() is ordered. codec(), sample_rate() and layout() describe the output. Container timing is the caller's.
Streams without codec configuration work where the packets carry it. AAC with empty extradata
takes its configuration from the ADTS headers (MPEG-TS, raw .aac), which are stripped from each
packet.
To seek: position your demuxer at the packet that starts preroll() frames or more before the
target, call reset(), decode from there, and drop the output before the target.
The codec modules are also usable on their own. aac::Decoder returns the codec's channel order;
aac::wav_order(channel_config) maps it to WAV order.
Encode a file
use craft_audio::{AudioBuffer, BitDepth, ChannelLayout, EncodeFormat, EncodeOptions, FileEncoder, encode};
// One Vec of samples per channel, full scale ±1. `AudioBuffer::new(rate, channels, frames)`
// makes a silent one to fill.
let buf = AudioBuffer { sample_rate: 48_000, channels: vec![left, right] };
let opts = EncodeOptions::new(EncodeFormat::Flac).with_bit_depth(BitDepth::Int24).with_dither(true);
let flac: Vec<u8> = encode(&buf, &opts)?;
// 5.1 with side surrounds: a layout other than the default for 6 channels, recorded in the WAV.
let wav = encode(&surround, &EncodeOptions::default().with_layout(ChannelLayout::SURROUND_5_1_SIDE))?;
// Block by block into any Write + Seek with nothing after its position; the same bytes as `encode`.
let mut enc = FileEncoder::new(std::fs::File::create("take.wav")?, &EncodeOptions::default(), 48_000, 2)?;
enc.write(&buf.channels, buf.frames())?; // one slice (or Vec) per channel
enc.finish()?;
EncodeOptions::default() is 24-bit WAV. with_bwf adds a BWF bext chunk and
with_force_rf64 writes RF64 below 4 GiB; both apply to WAV only. encode_format_for("aiff")
maps an extension to an EncodeFormat, and ENCODE_EXTENSIONS lists the extensions the enabled
features can write. write refuses a block without one slice per channel, or with a slice
shorter than frames, and writes nothing. After a write fails for another reason, every later
write and finish returns an error.
Encode AAC
use craft_audio::AudioError;
use craft_audio::aac::{Encoder, EncoderConfig, aac_order};
let mut enc = Encoder::new(EncoderConfig::cbr(48_000, 6, 384_000))?;
let order = aac_order(6).ok_or_else(|| AudioError::Unsupported("no AAC order for 6 channels".into()))?;
let input: Vec<&[f32]> = order.iter().map(|&c| wav_5_1[c].as_slice()).collect();
let mut units = enc.encode(&input); // zero or more access units
units.extend(enc.flush());
let asc = enc.audio_specific_config(); // for the MP4 `esds` box
let priming = enc.priming_samples(); // for the edit list (1024)
The sample rate must be one of the 13 AAC rates (7350–96000 Hz). aac_order returns None for
mono and stereo, which are already in order, and for 8 channels. Eight channels are written as AAC
channel configuration 7 and taken in its element order: C, Lc Rc, L R, Ls Rs, LFE (front centre,
front left and right of centre, front left and right, surrounds). A WAV 7.1 buffer (L R C LFE Lb Rb
Ls Rs) has no equivalent in that layout; passed unchanged, its channels go to the wrong speakers.
EncoderConfig::cbr(..).with_adts(true) makes every access unit an ADTS frame; concatenated, they
are a playable .aac file. To write MP4 or Matroska, pass the access units to craft-media's
muxers (MP4, Matroska).
Waveform overviews
use craft_audio::peaks::Peaks;
let peaks = Peaks::build(&left); // once per channel
// Start sample, samples per column, overlap, columns; pass the same samples each time. One
// (min, max) per column, `None` past the end of the data.
let columns = peaks.spans(&left, 0.0, 512.0, 0.0, 800);
Peaks is a min/max/sum-of-squares pyramid (32-sample blocks, fan-out 4). Range queries are
exact, O(log n). rms_spans returns one RMS value per column.
Errors
Functions return craft_audio::Result<T>. The codec modules have their own Error types, which
convert into AudioError with ?.
AudioError |
Cause |
|---|---|
Unsupported |
valid input using an unsupported codec, feature or option, or a format whose feature is off |
Malformed |
corrupt or truncated input, or not the claimed format |
TooLarge |
over a size limit (DecodeOptions::max_samples, an allocation) |
Encode |
an encoder rejected the input or its settings (a buffer with no channels or a 0 Hz rate, a block shorter than its frame count, a wrong layout, a rate AAC has no index for) |
Io |
the source failed a read, or the FileEncoder writer failed |
AudioError is #[non_exhaustive], so a match needs a _ arm:
match craft_audio::decode(&bytes, None) {
Ok((info, buf)) => { /* … */ }
Err(craft_audio::AudioError::Unsupported(what)) => eprintln!("cannot decode: {what}"),
Err(e) => eprintln!("{e}"),
}
Format support
| Format | Decode | Encode |
|---|---|---|
| WAV, BWF, RF64/BW64 | native | native |
| AIFF, AIFF-C | native | native |
| FLAC | symphonia | native |
| AAC-LC | native | native |
| HE-AAC v1/v2 | native, core only (see Limits) | — |
| MP1, MP2, MP3 (MPEG audio Layer I/II/III) | symphonia | — |
| ALAC, Vorbis, ADPCM, A-law, µ-law | symphonia | — |
| Opus | — | — |
| Container | Demuxer | Timing |
|---|---|---|
| MP4, M4A, MOV | craft_media::isobmff for native codecs; symphonia for the others (ALAC, MP3, FLAC) |
edit lists (AAC priming) |
| Matroska, WebM | craft_media::matroska for native codecs; symphonia for the others (Vorbis, FLAC, MP3) |
CodecDelay, DiscardPadding |
| Ogg | symphonia (Vorbis, FLAC) | — |
| Raw ADTS AAC | native frame sync | — |
| Raw MP1/MP2/MP3 | symphonia | — |
| FLAC | symphonia | — |
| CAF | symphonia | — |
Limits
-
HE-AAC: the AAC-LC core is decoded.
decode,DecoderandTrackDecoderupsample it 2×;aac::Decoderalone outputs the core rate. SBR is not reconstructed, so there is no content above the core's Nyquist frequency. -
HE-AAC v2: parametric stereo is not reconstructed either, so the mono core decodes as mono.
-
AAC: Main, SSR and LTP profiles, coupling channels and 960-sample frames are unsupported.
-
Raw ADTS: frames with several raw data blocks are unsupported: a stream of them is refused, and one among other frames decodes as silence of its length and counts as damaged, as does a frame that changes the stream's sample rate, profile or channel configuration.
-
A seek into AAC with PNS gives the same samples each time, but its noise differs from a continuous decode's, at the same band energy.
-
MP4 gapless information outside an edit list is not read: a file from Apple's encoders with
iTunSMPBand noelstdecodes with its priming and padding. -
AAC channel configuration 7 and program-config layouts, and AIFF with more than four channels, decode with an unordered layout, in the codec's order.
-
A Matroska track with no block durations (AAC from ffmpeg 6) ends at its
DURATIONtag, when that ends inside the last packet. Otherwise all its audio is presented, including any padding. -
ffmpeg before 9 ends some codecs' tracks in MP4 and Matroska up to 11 ms early. The audio past the end a file declares is not presented.
-
A Matroska track's end comes from its timestamps, so it is known to one timestamp tick (usually 1 ms) unless the last block has
DiscardPadding. -
A FLAC layout that decoders would not assume for the channel count depends on its
WAVEFORMATEXTENSIBLE_CHANNEL_MASKcomment. This includes the usual layouts for 5 and 6 channels: without the comment, back surrounds are read as side surrounds. It includes the usual 7-channel layout too: without the comment, its back left, back right and back centre are read as back centre, side left and side right. -
FLAC at 32 bits codes stereo channels independently, because a side channel would need 33 bits.
-
Outside RFC 9639's streamable subset (§7), which some decoders require: FLAC at 32 bits, at a sample rate the frame header cannot name, or with a layout that needs the mask comment.
-
ChannelLayout::unorderedis written to FLAC as mask0x0000, as RFC 9639 §8.6.2 allows, but ffmpeg andflac -dignore that mask and use their default layout for the channel count. -
Not decoded at all: AC-3, E-AC-3, Opus and Speex.
-
TrackDecoderreturnsUnsupportedfor MP3, FLAC, ALAC and Vorbis;decodeandDecoderread those through symphonia. -
MP4 edit lists: a single media edit (encoder priming and the end) is applied, alone or after one empty edit; the empty edit's delay is not added as silence. Other edit lists are not applied, and the media plays as it is stored.
-
AAC in MP4 stored as ADTS frames, when the sample durations count fewer frames than the samples hold: HE-AAC durations that count only the core frames, or several frames in a sample timed as one. Such a file is broken: it is outside the MP4 specification in two ways.
- An MP4 AAC sample is one raw access unit, with the stream's configuration in the sample
description (ISO/IEC 14496-14 and 14496-3). ADTS is a transport framing for streams such as
MPEG-TS and raw
.aac, and its headers do not belong in an MP4 sample. - A sample's duration in the time-to-sample table is the time it decodes to (ISO/IEC 14496-12). Durations that count fewer frames than the samples hold do not describe the track.
ffmpeg, Apple's encoders, phones and cameras write raw AAC with matching durations, and a correct remux from MPEG-TS or raw
.aacconverts the framing, so files within the specification are not affected. In a file outside it:- Read from the start, the track decodes in full: durations that fall short of the frames in the samples read at open do not end it.
Decoder::seekplaces samples by those durations, so it lands later than asked, and an edit list measured in them can end the track early.- These are not corrected because where each sample's audio starts is only known by counting the ADTS frames in every sample before it: one read per sample, hundreds of thousands on a two-hour track. Done at open, that would slow every ADTS-framed MP4 to work around files that are outside the specification.
- An MP4 AAC sample is one raw access unit, with the stream's configuration in the sample
description (ISO/IEC 14496-14 and 14496-3). ADTS is a transport framing for streams such as
MPEG-TS and raw
Codecs
Each codec module's rustdoc lists the coding tools it implements.
| Codec | Spec | Scope | Verified against |
|---|---|---|---|
| WAV, AIFF | Microsoft RIFF, Apple AIFF/AIFF-C | read: integer 8–32-bit, float 32/64-bit, WAVE_FORMAT_EXTENSIBLE masks, RF64/BW64, BWF bext, truncated files; write: 16/24/32-bit integer and 32-bit float, RF64 above 4 GiB, any layout, TPDF dither |
round trips; hostile-input tests |
| FLAC encoder | RFC 9639 | 16, 24, 32 bits; 1–8 channels, layouts decoders would not assume for the count as a WAVEFORMATEXTENSIBLE_CHANNEL_MASK comment; up to 655,350 Hz; LPC, stereo decorrelation, wasted bits, STREAMINFO MD5 |
bit-exact through ffmpeg, STREAMINFO MD5 checked against ffmpeg |
AAC (aac) |
ISO/IEC 14496-3 | AAC-LC decode and encode (CBR, VBR, ADTS), 1–8 channels; HE-AAC: see Limits | ffmpeg: ~135 dB SNR; PNS noise differs per sample at equal band energy |
symphonia
symphonia 0.6 (MPL-2.0, used unmodified as the Craft licensing rules require) decodes the formats
marked "symphonia" in Format support. Each is to be replaced by a native
decoder, after which the dependency is removed. The native path is tried first, so a new native
decoder takes its formats over without an API change. Build with decode-native instead of
decode to see what still needs symphonia.
Notes while symphonia is in use:
-
symphonia reads from memory: a file it decodes is read whole first. Its decoded audio still streams through
Decoder. -
symphonia allocates sizes taken from the file. Container size fields of WAV, FLAC, MP4 and CAF files are checked against the file length before symphonia reads them.
-
AAC in CAF: the priming is not trimmed and
Decoder::seekis not sample-exact, as CAF goes through symphonia. -
A panic inside symphonia is caught and returned as
AudioError::Malformedonly where panics unwind: native builds with the defaultpanic = "unwind". Onwasm32-unknown-unknown, and in an app built withpanic = "abort", such a panic aborts. The native decoders do not panic. -
symphonia-bundle-flacoverflows ani32add on 32-bit LPC FLAC; the wrapped value is correct. Release builds decode correctly; debug builds panic (returned asMalformed). Apps that decode 32-bit FLAC need this in the workspaceCargo.toml(craft-libs has it):[profile.dev.package.symphonia-bundle-flac] overflow-checks = false
Tests
Both integration test binaries need features, so a plain cargo test -p craft-audio runs only
the unit tests. Run everything with:
cargo test -p craft-audio --all-features
Tests that need ffmpeg are skipped when it is missing. To fail instead, set
CRAFT_MEDIA_REQUIRE_ORACLES=1. The helpers and their variables are shared with craft-media (see
its tests). The test files describe what they check;
tests/codecs/routing_oracle.rs is the end-to-end check of containers, codecs, timing, seeking
and track choice against ffmpeg.
CI (.github/workflows/audio.yml) runs clippy for each feature on its own, rustdoc with warnings
as errors, the WASM build, encode-only and native-only test runs, and an MSRV check. It also runs
the full tests with ffmpeg required.
Origin
| Part | Source |
|---|---|
WAV, AIFF, decode/probe, peaks |
SoundCraft soundcraft-audio-io 7dc56a4 |
| FLAC encoder, AAC | FilmCraft 8fda2f2 |
Where apps had overlapping implementations, the one that measured better was kept, with the tests of both:
| Format | Kept | Reason |
|---|---|---|
| FLAC encoding | FilmCraft | 0.1–37% smaller output than SoundCraft's on 12 test signals; STREAMINFO MD5 |
| WAV/AIFF writing | SoundCraft | only one with BWF, dither, arbitrary speaker masks, streaming without known length |
| AAC-LC decoding | FilmCraft (over symphonia) | equal SNR against ffmpeg; also decodes 5.1 AAC, which symphonia rejects |
Migration notes for apps
For the apps switching from their own code; to be removed once all three have switched.
The codec modules' public items keep their names, so apps can re-export them
(pub use craft_audio::aac::*;). Names kept only for that are #[doc(hidden)]:
aac::Upsample2x, aac::ProgramConfig::parse, aac::config_layout and aac::sample_rate_index. What else an app adjusts:
AudioInfohascontainer,codec,layoutanddamagedinstead of SoundCraft'sformat.detect_containerreplacesdetect_format.probereports the layout instead ofwav_channel_mask.EncodeOptionsis built withEncodeOptions::new(format)andwith_*methods. It carries the speaker layout and RF64, which replaceencode_with_layout,encode_wav_rf64andFileEncoder::new's layout argument.BwfInfoandaac::EncoderConfigare built the same way (with_adts, …).ENCODE_EXTENSIONSis a list.- Arguments go sample rate, then channels, and channel counts are
u16:FileEncoder::new(out, &opts, sample_rate, channels),wav_float_header(sample_rate, channels, frames),AudioBuffer::new(sample_rate, channels, frames),AudioBuffer::from_interleavedandnum_channels, and theChannelLayoutconstructors andchannels(). AudioInfo,AudioError,EncodeOptions,BwfInfo,SampleFormat,BitDepth, the codecErrorenums and the codec modules' header, frame and config structs (but foraac::ProgramConfig) are#[non_exhaustive].Speaker::ALLis a slice.TrackInfo::sample_rateandchannelsareOptions.- Features are per codec. SoundCraft's
encode(WAV, AIFF, FLAC) is nowlossless-encode, made ofpcm-encode(WAV, AIFF) andflac-encode.encodeturns on every encoder, AAC included. Writing a format whose feature is off returnsAudioError::Unsupported. - AAC encoder settings it cannot use are
aac::Error::InvalidSettings(AudioError::Encode). FileEncoder::writetakes any per-channel slices, but refuses a block with missing channels or short slices instead of filling them with silence.DecodeOptionsis notCopy.- The FLAC encoder's vendor string is
craft-audio <version>. decodetries native decoders first. AAC no longer goes through symphonia, except in CAF. MP4 and Matroska files decode to presentation length. A packet that fails is concealed, not dropped. A failed source read isAudioError::Io.