craft-audio: integer PCM of WAV and AIFF files bit for bit, and minimal files around it (#917)

wav_pcm and aiff_pcm (feature decode-native) return a file's integer PCM as stored
(RawPcm: little-endian interleaved bytes, 8-bit unsigned, with channels, rate and
bits), built on the existing WAV and AIFF chunk parsers. pcm_wav_file,
pcm_wav_header, pcm_aiff_file and pcm_aiff_header (feature pcm-encode) write the
44-byte canonical WAVE header and the plain AIFF header around such bytes.

For essence embedded in AAF and OMF files, which is moved bit for bit and whose
descriptors hold a bare header as a summary. The writers are FilmCraft's
(filmcraft-interchange wav.rs); the readers replace its second WAV/AIFF parser
with this crate's.
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-11 18:52:13 -05:00
1 parent 5ebc719ca2
commit 119f6e252e
8 files changed
+267 -2

No files matched your search

+2 -2
View File
@@ -26,11 +26,11 @@ No default features. The shared types (`AudioBuffer`, `AudioInfo`, `ChannelLayou
| Feature | Provides | Pulls in |
|---|---|---|
| `decode` | `decode`, `probe`, `Decoder`, `tracks`, `TrackDecoder`; every format in [Format support](#format-support) | `decode-native`, `symphonia` |
| `decode-native` | the same, native formats only (others are `Unsupported`) | the five decoders below, craft-media `isobmff`/`matroska`/`ogg`, `log` |
| `decode-native` | the same, native formats only (others are `Unsupported`); `wav_pcm`, `aiff_pcm` (a file's integer PCM bit for bit, as `RawPcm`) | the five decoders below, 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, ALAC, AC-3/E-AC-3 and MP3 encoders | `lossless-encode`, `aac-encode`, `alac-encode`, `ac3-encode`, `eac3-encode`, `mp3-encode` |
| `lossless-encode` | WAV, AIFF and FLAC writing | `pcm-encode`, `flac-encode` |
| `pcm-encode` | `encode`, `FileEncoder` for WAV and AIFF; `wav_float_header` | none |
| `pcm-encode` | `encode`, `FileEncoder` for WAV and AIFF; `wav_float_header`; `pcm_wav_file`, `pcm_wav_header`, `pcm_aiff_file`, `pcm_aiff_header` (minimal files around integer PCM bytes, for essence embedded in AAF / OMF) | none |
| `flac-encode` | `encode`, `FileEncoder` for FLAC; `flac::Encoder`, the packet encoder for callers that mux FLAC themselves | craft-media `bitstream` |
| `aac-decode`, `aac-encode` | `aac::Decoder`, `aac::Encoder` | craft-media `bitstream` |
| `alac-decode` | `alac::Decoder` | none |
+7
View File
@@ -19,6 +19,9 @@
//! gives [`wav_float_header`]) and FLAC (feature `flac-encode`), whole-buffer or incremental into
//! any `Write + Seek`. Feature `lossless-encode` turns on both; feature `encode` turns on
//! `lossless-encode` and every codec's encoder.
//! - [`wav_pcm`], [`aiff_pcm`] (feature `decode-native`) and [`pcm_wav_file`], [`pcm_wav_header`],
//! [`pcm_aiff_file`], [`pcm_aiff_header`] (feature `pcm-encode`): the integer PCM of a WAV or AIFF
//! file bit for bit, and minimal files around such PCM, for essence embedded in other containers.
//! - [`peaks`] (feature `peaks`): min/max/RMS pyramid for waveform drawing.
//! - Codec modules for packet-level use, each with its own features: [`aac`] (`aac-decode`,
//! `aac-encode`), [`alac`] (`alac-decode`, `alac-encode`), [`opus`] (`opus-decode`), [`ac3`]
@@ -91,12 +94,16 @@ pub use read::{
DECODE_EXTENSIONS, DEFAULT_MAX_SAMPLES, DecodeOptions, Decoder, TrackInfo, decode, decode_with, detect_container, probe, probe_with, tracks,
tracks_of,
};
#[cfg(feature = "decode-native")]
pub use read::{RawPcm, aiff_pcm, wav_pcm};
#[cfg(any(feature = "aac-decode", feature = "alac-decode", feature = "opus-decode", feature = "ac3-decode", feature = "mpa-decode"))]
pub use track::{LOOKAHEAD_PACKETS, TrackDecoder};
#[cfg(feature = "pcm-encode")]
pub use write::wav_float_header;
#[cfg(any(feature = "pcm-encode", feature = "flac-encode"))]
pub use write::{ENCODE_EXTENSIONS, FileEncoder, encode, encode_format_for};
#[cfg(feature = "pcm-encode")]
pub use write::{pcm_aiff_file, pcm_aiff_header, pcm_wav_file, pcm_wav_header};
/// Errors from probing, decoding and encoding.
#[non_exhaustive]
+2
View File
@@ -14,6 +14,7 @@ mod flac;
#[cfg(feature = "symphonia")]
mod mp4;
mod native;
mod raw;
mod source;
#[cfg(feature = "symphonia")]
mod symph;
@@ -21,6 +22,7 @@ mod wav;
use crate::{AudioBuffer, AudioError, AudioInfo, ChannelLayout, CodecId, Container, Result};
use native::Native;
pub use raw::{RawPcm, aiff_pcm, wav_pcm};
pub use source::SharedSource;
use source::Src;
use std::sync::Arc;
+58
View File
@@ -0,0 +1,58 @@
//! The integer PCM of a WAV or AIFF file as stored, for a caller that moves it bit for bit
//! (essence embedded in an AAF or OMF file) instead of decoding it to `f32`.
use super::source::Src;
use super::{aiff, wav};
use crate::pcm::PcmKind;
/// Integer PCM taken out of a WAV or AIFF file: interleaved little-endian samples of
/// `bits.div_ceil(8)` bytes each, signed except for 8-bit, which is unsigned (the WAVE convention).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RawPcm {
/// The samples; empty for a header with no data after it (a descriptor's summary).
pub data: Vec<u8>,
/// Interleaved channels (at least 1).
pub channels: u16,
/// Sample rate in Hz (at least 1).
pub sample_rate: u32,
/// Bits a sample occupies in `data`: 8, 16, 24 or 32.
pub bits: u16,
}
/// The samples of a layout: `len` bytes at `start`, as far as the file has them.
fn stored(b: &[u8], start: u64, len: u64) -> &[u8] {
let start = usize::try_from(start).unwrap_or(usize::MAX).min(b.len());
let end = start.saturating_add(usize::try_from(len).unwrap_or(usize::MAX)).min(b.len());
b.get(start..end).unwrap_or_default()
}
/// The integer PCM of a WAV file (plain or `WAVE_FORMAT_EXTENSIBLE` PCM), bit for bit. `None` for
/// anything else: not a WAV, float or compressed samples, no `fmt ` or `data` chunk. A file cut
/// short gives the samples that are there.
pub fn wav_pcm(bytes: &[u8]) -> Option<RawPcm> {
let wav::WavParse::Native(layout) = wav::parse(&Src::Slice(bytes)).ok()? else { return None };
let PcmKind::Int { bytes: width, big_endian: false, .. } = layout.kind else { return None };
Some(RawPcm {
data: stored(bytes, layout.data_start, layout.data_len).to_vec(),
channels: layout.info.channels,
sample_rate: layout.info.sample_rate,
bits: u16::try_from(width.checked_mul(8)?).ok()?,
})
}
/// The integer PCM of an AIFF or AIFF-C (`NONE`, `twos`, `sowt`) file, converted to the layout of
/// [`RawPcm`]: little-endian, and 8-bit samples made unsigned. `None` for anything else.
pub fn aiff_pcm(bytes: &[u8]) -> Option<RawPcm> {
let aiff::AiffParse::Native(layout) = aiff::parse(&Src::Slice(bytes)).ok()? else { return None };
let PcmKind::Int { bytes: width, big_endian, .. } = layout.kind else { return None };
let stored = stored(bytes, layout.data_start, layout.data_len);
let mut data = Vec::with_capacity(stored.len());
for s in stored.chunks_exact(width.max(1)) {
match (width, big_endian) {
(1, _) => data.extend(s.iter().map(|b| b ^ 0x80)),
(_, true) => data.extend(s.iter().rev()),
(_, false) => data.extend_from_slice(s),
}
}
Some(RawPcm { data, channels: layout.info.channels, sample_rate: layout.info.sample_rate, bits: u16::try_from(width.checked_mul(8)?).ok()? })
}
+4
View File
@@ -7,9 +7,13 @@ mod aiff;
pub(crate) mod flac;
mod pcm;
#[cfg(feature = "pcm-encode")]
mod raw;
#[cfg(feature = "pcm-encode")]
mod wav;
use crate::{AudioBuffer, AudioError, ChannelLayout, EncodeFormat, EncodeOptions, Result};
#[cfg(feature = "pcm-encode")]
pub use raw::{pcm_aiff_file, pcm_aiff_header, pcm_wav_file, pcm_wav_header};
use std::io::{Seek, Write};
/// Encode `buf` as WAV/BWF, AIFF/AIFF-C or FLAC. Integer depths clamp samples to [-1, 1]; float
+120
View File
@@ -0,0 +1,120 @@
//! Minimal WAVE and AIFF files around integer PCM the caller already has as bytes (essence
//! embedded in an AAF or OMF file): the 44-byte canonical WAVE header and the plain AIFF header,
//! with nothing a reader of such a summary could trip over (no `JUNK`, no extensible `fmt `).
//! [`crate::encode`] writes full WAV/BWF/RF64 and AIFF files from `f32`.
//!
//! PCM here is interleaved little-endian samples of `bits.div_ceil(8)` bytes, 8-bit unsigned
//! (the layout of a WAVE `data` chunk). Sizes saturate at the 32-bit fields instead of wrapping.
use super::aiff::u32_to_extended;
/// A complete WAVE file around little-endian PCM.
pub fn pcm_wav_file(pcm: &[u8], channels: u16, sample_rate: u32, bits: u16) -> Vec<u8> {
let mut v = pcm_wav_header(u32::try_from(pcm.len()).unwrap_or(u32::MAX), channels, sample_rate, bits);
v.extend_from_slice(pcm);
if pcm.len() % 2 == 1 {
v.push(0);
}
v
}
/// The 44 bytes of a canonical WAVE file up to and including the `data` chunk header, for
/// `data_len` bytes of PCM.
pub fn pcm_wav_header(data_len: u32, channels: u16, sample_rate: u32, bits: u16) -> Vec<u8> {
let block = channels.max(1).saturating_mul(bits.div_ceil(8));
let mut v = Vec::with_capacity(44);
v.extend_from_slice(b"RIFF");
v.extend_from_slice(&36u32.saturating_add(data_len).saturating_add(data_len & 1).to_le_bytes());
v.extend_from_slice(b"WAVEfmt ");
v.extend_from_slice(&16u32.to_le_bytes());
v.extend_from_slice(&1u16.to_le_bytes());
v.extend_from_slice(&channels.max(1).to_le_bytes());
v.extend_from_slice(&sample_rate.to_le_bytes());
v.extend_from_slice(&sample_rate.saturating_mul(u32::from(block)).to_le_bytes());
v.extend_from_slice(&block.to_le_bytes());
v.extend_from_slice(&bits.to_le_bytes());
v.extend_from_slice(b"data");
v.extend_from_slice(&data_len.to_le_bytes());
v
}
/// A complete AIFF file (big-endian, 8-bit signed) from little-endian PCM.
pub fn pcm_aiff_file(pcm: &[u8], channels: u16, sample_rate: u32, bits: u16) -> Vec<u8> {
let width = usize::from(bits.div_ceil(8)).max(1);
let frames = pcm.len() / (width * usize::from(channels.max(1)));
let mut v = pcm_aiff_header(u32::try_from(frames).unwrap_or(u32::MAX), channels, sample_rate, bits);
let head = v.len();
for s in pcm.chunks_exact(width) {
if width == 1 {
v.extend(s.iter().map(|b| b ^ 0x80)); // WAVE 8-bit is unsigned, AIFF signed
} else {
v.extend(s.iter().rev());
}
}
if (v.len() - head) % 2 == 1 {
v.push(0);
}
v
}
/// The 54 bytes of an AIFF file up to the sound data (`FORM`, `COMM`, `SSND` with its offset and
/// block size), for `frames` sample frames.
pub fn pcm_aiff_header(frames: u32, channels: u16, sample_rate: u32, bits: u16) -> Vec<u8> {
let data_len = u64::from(frames).saturating_mul(u64::from(channels.max(1))).saturating_mul(u64::from(bits.div_ceil(8)));
let chunk = |extra: u64| u32::try_from(extra.saturating_add(data_len)).unwrap_or(u32::MAX);
let mut v = Vec::with_capacity(54);
v.extend_from_slice(b"FORM");
v.extend_from_slice(&chunk(4 + 26 + 16 + (data_len & 1)).to_be_bytes());
v.extend_from_slice(b"AIFFCOMM");
v.extend_from_slice(&18u32.to_be_bytes());
v.extend_from_slice(&channels.max(1).to_be_bytes());
v.extend_from_slice(&frames.to_be_bytes());
v.extend_from_slice(&bits.to_be_bytes());
v.extend_from_slice(&u32_to_extended(sample_rate));
v.extend_from_slice(b"SSND");
v.extend_from_slice(&chunk(8).to_be_bytes());
v.extend_from_slice(&[0; 8]);
v
}
#[cfg(test)]
mod tests {
use super::*;
/// A descriptor for more than 4 GiB of PCM: the RIFF and FORM size fields saturate.
#[test]
fn huge_headers_saturate() {
let h = pcm_wav_header(u32::MAX, 2, u32::MAX, 24);
assert_eq!(h.get(4..8), Some(&u32::MAX.to_le_bytes()[..]));
assert_eq!(h.get(28..32), Some(&u32::MAX.to_le_bytes()[..]), "byte rate");
let a = pcm_aiff_header(u32::MAX, u16::MAX, 48_000, 32);
assert_eq!(a.get(4..8), Some(&u32::MAX.to_be_bytes()[..]));
assert_eq!(a.len(), 54);
}
#[test]
fn headers_have_the_canonical_layout() {
let h = pcm_wav_header(96, 2, 48_000, 24);
assert_eq!(h.len(), 44);
let mut expect = b"RIFF".to_vec();
expect.extend_from_slice(&132u32.to_le_bytes());
expect.extend_from_slice(b"WAVEfmt ");
for x in [16u32, 1 | 2 << 16, 48_000, 288_000, 6 | 24 << 16] {
expect.extend_from_slice(&x.to_le_bytes());
}
expect.extend_from_slice(b"data");
expect.extend_from_slice(&96u32.to_le_bytes());
assert_eq!(h, expect);
// zero channels are written as one, and an odd data length counts its pad byte
let odd = pcm_wav_file(&[1, 2, 3], 0, 8_000, 8);
assert_eq!(odd.len(), 48);
assert_eq!(odd.get(4..8), Some(&40u32.to_le_bytes()[..]));
assert_eq!(odd.get(22..24), Some(&1u16.to_le_bytes()[..]));
let a = pcm_aiff_header(8, 2, 44_100, 24);
assert_eq!(a.get(..4), Some(&b"FORM"[..]));
assert_eq!(a.get(4..8), Some(&(46u32 + 48).to_be_bytes()[..]));
// 44 100 Hz as an 80-bit extended float
assert_eq!(a.get(28..38), Some(&[0x40, 0x0E, 0xAC, 0x44, 0, 0, 0, 0, 0, 0][..]));
assert_eq!(a.get(42..46), Some(&(8u32 + 48).to_be_bytes()[..]));
}
}
+1
View File
@@ -6,6 +6,7 @@ mod flac_damage;
mod float_headroom;
mod malformed;
mod mp4_length;
mod raw_pcm;
mod roundtrip;
mod stream;
mod wav_empty_data;
+73
View File
@@ -0,0 +1,73 @@
//! Integer PCM in and out of WAV and AIFF files bit for bit (`wav_pcm`, `aiff_pcm`, `pcm_*`).
use craft_audio::{
AudioBuffer, BitDepth, EncodeFormat, EncodeOptions, RawPcm, aiff_pcm, encode, pcm_aiff_file, pcm_aiff_header, pcm_wav_file, pcm_wav_header,
wav_pcm,
};
fn raw(data: &[u8], channels: u16, sample_rate: u32, bits: u16) -> Option<RawPcm> {
Some(RawPcm { data: data.to_vec(), channels, sample_rate, bits })
}
#[test]
fn wav_and_aiff_round_trip_bit_for_bit() {
let pcm: Vec<u8> = (0..48u8).collect(); // 8 stereo 24-bit frames
let w = pcm_wav_file(&pcm, 2, 48_000, 24);
assert_eq!(wav_pcm(&w), raw(&pcm, 2, 48_000, 24));
let a = pcm_aiff_file(&pcm, 2, 44_100, 24);
assert_eq!(aiff_pcm(&a), raw(&pcm, 2, 44_100, 24));
let p16: Vec<u8> = (0..32u8).collect();
assert_eq!(aiff_pcm(&pcm_aiff_file(&p16, 1, 96_000, 16)), raw(&p16, 1, 96_000, 16));
// 8-bit: unsigned in WAVE and in `RawPcm`, signed in AIFF
let p8 = [0u8, 0x7F, 0x80, 0xFF, 3];
assert_eq!(wav_pcm(&pcm_wav_file(&p8, 1, 8_000, 8)), raw(&p8, 1, 8_000, 8));
let a8 = pcm_aiff_file(&p8, 1, 8_000, 8);
assert_eq!(a8.get(54..59), Some(&[0x80, 0xFF, 0x00, 0x7F, 0x83][..]));
assert_eq!(a8.len(), 60, "odd sound data is padded");
assert_eq!(aiff_pcm(&a8), raw(&p8, 1, 8_000, 8));
// neither reads the other's container
assert_eq!(wav_pcm(&a), None);
assert_eq!(aiff_pcm(&w), None);
}
/// A descriptor's summary is the header alone: the format is read, with no samples.
#[test]
fn headers_without_data_give_the_format() {
assert_eq!(wav_pcm(&pcm_wav_header(4800, 2, 48_000, 16)), raw(&[], 2, 48_000, 16));
assert_eq!(aiff_pcm(&pcm_aiff_header(1200, 2, 48_000, 16)), raw(&[], 2, 48_000, 16));
}
#[test]
fn files_cut_short_never_panic_and_give_what_is_there() {
let pcm: Vec<u8> = (0..48u8).collect();
let w = pcm_wav_file(&pcm, 2, 48_000, 24);
let a = pcm_aiff_file(&pcm, 2, 44_100, 24);
for cut in 0..w.len().max(a.len()) {
if let Some(p) = w.get(..cut).and_then(wav_pcm) {
assert_eq!(Some(&p.data[..]), pcm.get(..cut.saturating_sub(44)), "wav cut at {cut}");
}
if let Some(p) = a.get(..cut).and_then(aiff_pcm) {
assert!(pcm.starts_with(&p.data) && p.data.len() <= cut.saturating_sub(54), "aiff cut at {cut}");
}
}
assert_eq!(wav_pcm(b"RIFF"), None);
assert_eq!(aiff_pcm(a.get(..30).unwrap()), None);
}
#[test]
fn only_integer_pcm_is_taken() {
let buf = AudioBuffer::new(48_000, 2, 16);
let float = encode(&buf, &EncodeOptions::new(EncodeFormat::Wav).with_bit_depth(BitDepth::Float32)).unwrap();
assert_eq!(wav_pcm(&float), None);
let float = encode(&buf, &EncodeOptions::new(EncodeFormat::Aiff).with_bit_depth(BitDepth::Float32)).unwrap();
assert_eq!(aiff_pcm(&float), None);
// the full writer's 24-bit WAV (extensible `fmt `, a JUNK chunk before it) is integer PCM too
let int = encode(&buf, &EncodeOptions::new(EncodeFormat::Wav).with_bit_depth(BitDepth::Int24)).unwrap();
assert_eq!(wav_pcm(&int), raw(&[0; 96], 2, 48_000, 24));
let int = encode(&buf, &EncodeOptions::new(EncodeFormat::Aiff).with_bit_depth(BitDepth::Int16)).unwrap();
assert_eq!(aiff_pcm(&int), raw(&[0; 64], 2, 48_000, 16));
// zero channels or a zero rate is not a format
let mut bad = pcm_wav_file(&[0; 8], 1, 48_000, 16);
bad[24..28].copy_from_slice(&0u32.to_le_bytes());
assert_eq!(wav_pcm(&bad), None);
}