mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-12 08:52:32 +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>
73 lines
2.5 KiB
Rust
73 lines
2.5 KiB
Rust
//! Planar `f32` audio buffer.
|
|
|
|
/// Planar f32 audio.
|
|
///
|
|
/// Every channel is expected to hold the same number of frames; [`AudioBuffer::frames`] reports the
|
|
/// shortest channel so a ragged buffer never causes out-of-bounds access.
|
|
#[derive(Debug, Clone, Default, PartialEq)]
|
|
pub struct AudioBuffer {
|
|
/// Sample rate in Hz.
|
|
pub sample_rate: u32,
|
|
/// One `Vec` of samples per channel.
|
|
pub channels: Vec<Vec<f32>>,
|
|
}
|
|
|
|
impl AudioBuffer {
|
|
/// A silent buffer with `channels` channels of `frames` frames each.
|
|
pub fn new(sample_rate: u32, channels: u16, frames: usize) -> Self {
|
|
Self { sample_rate, channels: vec![vec![0.0; frames]; usize::from(channels)] }
|
|
}
|
|
|
|
/// Number of frames (the length of the shortest channel; 0 when there are no channels).
|
|
pub fn frames(&self) -> usize {
|
|
self.channels.iter().map(Vec::len).min().unwrap_or(0)
|
|
}
|
|
|
|
/// Number of channels.
|
|
pub fn num_channels(&self) -> u16 {
|
|
u16::try_from(self.channels.len()).unwrap_or(u16::MAX)
|
|
}
|
|
|
|
/// Duration in seconds (0 when the sample rate is 0).
|
|
pub fn duration_secs(&self) -> f64 {
|
|
if self.sample_rate == 0 {
|
|
return 0.0;
|
|
}
|
|
self.frames() as f64 / f64::from(self.sample_rate)
|
|
}
|
|
|
|
/// Interleaved copy of the samples (frame-major: `L0 R0 L1 R1 ...`).
|
|
pub fn interleaved(&self) -> Vec<f32> {
|
|
let frames = self.frames();
|
|
let mut out = Vec::with_capacity(frames.saturating_mul(self.channels.len()));
|
|
for f in 0..frames {
|
|
for ch in &self.channels {
|
|
out.push(ch.get(f).copied().unwrap_or(0.0));
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Build a planar buffer from interleaved samples. A trailing partial frame is dropped;
|
|
/// `channels == 0` yields an empty buffer.
|
|
pub fn from_interleaved(sr: u32, channels: u16, data: &[f32]) -> Self {
|
|
let channels = usize::from(channels);
|
|
if channels == 0 {
|
|
return Self { sample_rate: sr, channels: Vec::new() };
|
|
}
|
|
let frames = data.len() / channels;
|
|
let mut out: Vec<Vec<f32>> = (0..channels).map(|_| Vec::with_capacity(frames)).collect();
|
|
for frame in data.chunks_exact(channels) {
|
|
for (ch, &s) in out.iter_mut().zip(frame) {
|
|
ch.push(s);
|
|
}
|
|
}
|
|
Self { sample_rate: sr, channels: out }
|
|
}
|
|
|
|
/// Absolute peak over all channels (NaN samples are ignored).
|
|
pub fn peak(&self) -> f32 {
|
|
self.channels.iter().flat_map(|c| c.iter()).fold(0.0f32, |acc, &s| acc.max(s.abs()))
|
|
}
|
|
}
|