mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 22:42:46 +00:00
* 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>
150 lines
6.1 KiB
Rust
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)),
|
|
}
|
|
}
|