diff --git a/crates/audio/README.md b/crates/audio/README.md index a1eb059d2..0c9f63a97 100644 --- a/crates/audio/README.md +++ b/crates/audio/README.md @@ -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 | diff --git a/crates/audio/src/lib.rs b/crates/audio/src/lib.rs index 5916b91e8..4964166a7 100644 --- a/crates/audio/src/lib.rs +++ b/crates/audio/src/lib.rs @@ -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] diff --git a/crates/audio/src/read/mod.rs b/crates/audio/src/read/mod.rs index 533e996a6..35a220767 100644 --- a/crates/audio/src/read/mod.rs +++ b/crates/audio/src/read/mod.rs @@ -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; diff --git a/crates/audio/src/read/raw.rs b/crates/audio/src/read/raw.rs new file mode 100644 index 000000000..e63f9d2cb --- /dev/null +++ b/crates/audio/src/read/raw.rs @@ -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, + /// 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 { + 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 { + 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()? }) +} diff --git a/crates/audio/src/write/mod.rs b/crates/audio/src/write/mod.rs index 25c9b2528..2355a7b05 100644 --- a/crates/audio/src/write/mod.rs +++ b/crates/audio/src/write/mod.rs @@ -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 diff --git a/crates/audio/src/write/raw.rs b/crates/audio/src/write/raw.rs new file mode 100644 index 000000000..dc9a31649 --- /dev/null +++ b/crates/audio/src/write/raw.rs @@ -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 { + 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 { + 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 { + 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 { + 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()[..])); + } +} diff --git a/crates/audio/tests/audio/main.rs b/crates/audio/tests/audio/main.rs index f247167b5..8c37256d6 100644 --- a/crates/audio/tests/audio/main.rs +++ b/crates/audio/tests/audio/main.rs @@ -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; diff --git a/crates/audio/tests/audio/raw_pcm.rs b/crates/audio/tests/audio/raw_pcm.rs new file mode 100644 index 000000000..1b5fed669 --- /dev/null +++ b/crates/audio/tests/audio/raw_pcm.rs @@ -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 { + Some(RawPcm { data: data.to_vec(), channels, sample_rate, bits }) +} + +#[test] +fn wav_and_aiff_round_trip_bit_for_bit() { + let pcm: Vec = (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 = (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 = (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); +}