Files
craft-libs/crates/audio/src/read/aiff.rs
T
Aleksander HelgakerandClaude Opus 5.5 38740b43a9 craft-audio: core types, WAV/AIFF read and write, peaks, symphonia (#441)
* craft-audio: core types, WAV/AIFF read and write, peaks, symphonia

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>

* craft-audio: review fixes (CodeRabbit on #441)

- 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>

* craft-audio: review fixes, second round (CodeRabbit on #441)

- 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>

* craft-audio: TrackDecoder::conceal returns a Result

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>

* craft-audio: review fixes, third round (CodeRabbit on #441)

- 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>

* craft-audio: review fixes, fourth round (CodeRabbit on #441)

- 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>

* craft-audio: review fixes, fifth round (CodeRabbit on #441)

- The damaged-file test covers Matroska as well as MP4, and fails when either branch of
  native::damage is removed.
- native::damage leaves out Ogg: ogg::open reports no damage (it skips damaged pages and calls a
  file with none it can read not Ogg), and symphonia reports damaged Ogg as malformed itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* craft-audio: run the ffmpeg oracle tests on ubuntu-26.04

ubuntu-latest's apt ships ffmpeg 6.1.1, which writes MP2 and E-AC-3 in
Matroska with ends a few milliseconds early, so the routing tests on the
later codec PRs cannot pass on it. ubuntu-26.04 ships ffmpeg 8.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 10:25:57 +01:00

150 lines
6.1 KiB
Rust

//! Native AIFF / AIFF-C reader (Apple "Audio Interchange File Format" 1.3 and the AIFF-C
//! draft: big-endian IFF chunks, 80-bit IEEE extended sample rate).
use super::source::Src;
use crate::pcm::{PcmKind, be_u16, be_u32, tag};
use crate::{AudioError, AudioInfo, ChannelLayout, CodecId, Container, Result, SampleFormat};
const MAX_SAMPLE_RATE: f64 = 10_000_000.0;
pub(crate) struct AiffLayout {
pub info: AudioInfo,
pub kind: PcmKind,
pub data_start: u64,
pub data_len: u64,
}
pub(crate) enum AiffParse {
Native(AiffLayout),
/// Compressed AIFF-C (ulaw, alaw, ima4, ...): handed to symphonia.
Foreign,
}
/// True when the bytes start like an AIFF or AIFF-C file.
pub(crate) fn is_aiff(b: &[u8]) -> bool {
tag(b, 0).is_some_and(|t| &t == b"FORM") && tag(b, 8).is_some_and(|t| &t == b"AIFF" || &t == b"AIFC")
}
/// Decode an 80-bit IEEE 754 extended-precision float.
pub(crate) fn extended_to_f64(b: &[u8]) -> Option<f64> {
let se = be_u16(b, 0)?;
let hi = u64::from(be_u32(b, 2)?);
let lo = u64::from(be_u32(b, 6)?);
let mant = (hi << 32) | lo;
if mant == 0 {
return Some(0.0);
}
let exp = i32::from(se & 0x7FFF);
if exp == 0x7FFF {
return None; // inf / NaN
}
let v = mant as f64 * 2f64.powi(exp - 16383 - 63);
Some(if se & 0x8000 != 0 { -v } else { v })
}
/// Parse an AIFF file's chunks: its `COMM` format and where its `SSND` samples are. Only chunk
/// headers and the first bytes of `COMM` and `SSND` are read.
pub(crate) fn parse(src: &Src<'_>) -> Result<AiffParse> {
let head = src.bytes_upto(0, 12);
if !is_aiff(&head) {
return Err(AudioError::Malformed("not an AIFF/AIFF-C file".into()));
}
let total = src.size();
let is_aifc = tag(&head, 8).is_some_and(|t| &t == b"AIFC");
let mut comm: Option<(i16, u32, i16, f64, [u8; 4])> = None;
let mut ssnd: Option<(u64, u64)> = None;
let mut pos: u64 = 12;
let mut chunks = 0usize;
while let (Some(id), Some(size32)) = (src.tag(pos), src.be_u32(pos + 4)) {
chunks += 1;
if chunks > 100_000 {
break;
}
let body = pos + 8;
let remaining = total.saturating_sub(body);
let declared = u64::from(size32);
let len = declared.min(remaining);
match &id {
b"COMM" => {
let c = src.bytes(body, usize::try_from(len.min(64)).unwrap_or(0)).unwrap_or_default();
let short = || AudioError::Malformed("COMM chunk too short".into());
let channels = be_u16(&c, 0).ok_or_else(short)? as i16;
let frames = be_u32(&c, 2).ok_or_else(short)?;
let bits = be_u16(&c, 6).ok_or_else(short)? as i16;
let rate = extended_to_f64(c.get(8..18).ok_or_else(short)?).ok_or_else(|| AudioError::Malformed("invalid sample rate".into()))?;
let compression = if is_aifc { tag(&c, 18).ok_or_else(short)? } else { *b"NONE" };
comm = Some((channels, frames, bits, rate, compression));
}
b"SSND" if ssnd.is_none() => {
let offset = if len >= 4 { u64::from(src.be_u32(body).unwrap_or(0)) } else { 0 };
let start = body.saturating_add(8).saturating_add(offset).min(total);
let data_len = len.saturating_sub(8).saturating_sub(offset);
ssnd = Some((start, data_len));
}
_ => {}
}
if declared > remaining {
break;
}
let Some(next) = body.checked_add(declared).and_then(|p| p.checked_add(declared & 1)) else { break };
pos = next;
}
let (channels, frames, bits, rate, compression) = comm.ok_or_else(|| AudioError::Malformed("AIFF has no COMM chunk".into()))?;
if channels <= 0 {
return Err(AudioError::Malformed("AIFF declares zero or negative channels".into()));
}
if !(1.0..=MAX_SAMPLE_RATE).contains(&rate) {
return Err(AudioError::Malformed(format!("AIFF sample rate {rate} out of range")));
}
let sample_rate = rate.round() as u32;
let int_kind = |big_endian: bool| -> Result<(PcmKind, SampleFormat)> {
if !(1..=32).contains(&bits) {
return Err(AudioError::Unsupported(format!("AIFF sample size {bits}")));
}
let bytes = (bits as usize).div_ceil(8);
let sf = match bits {
1..=8 => SampleFormat::Int8,
9..=16 => SampleFormat::Int16,
17..=24 => SampleFormat::Int24,
_ => SampleFormat::Int32,
};
Ok((PcmKind::Int { bytes, big_endian, unsigned: false }, sf))
};
let (kind, sample_format) = match &compression {
b"NONE" | b"twos" => int_kind(true)?,
b"sowt" => int_kind(false)?,
b"fl32" | b"FL32" => (PcmKind::Float { bytes: 4, big_endian: true }, SampleFormat::Float32),
b"fl64" | b"FL64" => (PcmKind::Float { bytes: 8, big_endian: true }, SampleFormat::Float64),
_ => return Ok(AiffParse::Foreign),
};
// SSND is required when COMM declares frames (AIFF 1.3); a file that ends before it is
// truncated, not empty
let (data_start, data_len) = match ssnd {
Some(ssnd) => ssnd,
None if frames > 0 => return Err(AudioError::Malformed(format!("AIFF declares {frames} frames but has no SSND chunk"))),
None => (total, 0),
};
let block = kind.bytes().saturating_mul(channels as usize).max(1);
let frames = u64::from(frames).min(data_len / block as u64);
Ok(AiffParse::Native(AiffLayout {
info: AudioInfo::new(Container::Aiff, CodecId::Pcm, sample_format, sample_rate, layout(usize::from(channels as u16)), frames),
kind,
data_start,
data_len,
}))
}
/// The AIFF channel order (AIFF 1.3, "Sample Points"): mono, stereo, L R C and quad map to
/// speakers; the six-channel order (L Lc C R Rc S) and larger counts have no WAV order.
fn layout(channels: usize) -> ChannelLayout {
match channels {
1 => ChannelLayout::MONO,
2 => ChannelLayout::STEREO,
3 => ChannelLayout::SURROUND_3_0,
4 => ChannelLayout::QUAD,
n => ChannelLayout::unordered(crate::layout::count(n)),
}
}