diff --git a/.github/workflows/audio.yml b/.github/workflows/audio.yml index 1dc76d17..f2d7d49e 100644 --- a/.github/workflows/audio.yml +++ b/.github/workflows/audio.yml @@ -35,7 +35,7 @@ jobs: # Each feature on its own, so code that needs another feature fails here. - name: Clippy, each feature alone run: | - for f in "" decode decode-native symphonia encode lossless-encode pcm-encode peaks serde; do + for f in "" decode decode-native symphonia encode lossless-encode pcm-encode flac-encode peaks serde; do echo "::group::features: ${f:-none}" cargo clippy -p craft-audio --all-targets --locked --no-default-features ${f:+--features "$f"} -- -D warnings echo "::endgroup::" @@ -82,6 +82,7 @@ jobs: key: craft-audio-features - run: cargo test -p craft-audio --no-default-features --features lossless-encode --locked - run: cargo test -p craft-audio --no-default-features --features pcm-encode --locked --lib + - run: cargo test -p craft-audio --no-default-features --features flac-encode --locked --lib - run: cargo test -p craft-audio --no-default-features --features decode-native --locked test: timeout-minutes: 20 diff --git a/README.md b/README.md index 6c004096..e45d6e0f 100644 --- a/README.md +++ b/README.md @@ -78,8 +78,8 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = " = encode(&buf, &opts)?; +let opts = EncodeOptions::new(EncodeFormat::Flac).with_bit_depth(BitDepth::Int24).with_dither(true); +let flac: Vec = encode(&buf, &opts)?; // 5.1 with side surrounds: a layout other than the default for 6 channels, recorded in the WAV. let wav = encode(&surround, &EncodeOptions::default().with_layout(ChannelLayout::SURROUND_5_1_SIDE))?; @@ -240,7 +242,7 @@ match craft_audio::decode(&bytes, None) { |---|---|---| | WAV, BWF, RF64/BW64 | native | native | | AIFF, AIFF-C | native | native | -| FLAC | symphonia | — | +| FLAC | symphonia | native | | AAC-LC, HE-AAC | symphonia | — | | MP1, MP2, MP3 (MPEG audio Layer I/II/III) | symphonia | — | | ALAC, Vorbis, ADPCM, A-law, µ-law | symphonia | — | @@ -269,6 +271,7 @@ match craft_audio::decode(&bytes, None) { | Codec | Spec | Scope | Verified against | |---|---|---|---| | WAV, AIFF | Microsoft RIFF, Apple AIFF/AIFF-C | read: integer 8–32-bit, float 32/64-bit, `WAVE_FORMAT_EXTENSIBLE` masks, RF64/BW64, BWF `bext`, truncated files; write: 16/24/32-bit integer and 32-bit float, RF64 above 4 GiB, any layout, TPDF dither | round trips; hostile-input tests | +| FLAC encoder | RFC 9639 | 16, 24, 32 bits; 1–8 channels; up to 655,350 Hz; LPC, stereo decorrelation, STREAMINFO MD5 | bit-exact through ffmpeg and the reference `flac` decoder | ## symphonia @@ -320,12 +323,14 @@ the full tests with ffmpeg required. | Part | Source | |---|---| | WAV, AIFF, `decode`/`probe`, peaks | SoundCraft `soundcraft-audio-io` [7dc56a4](https://github.com/storytold/soundcraft/commit/7dc56a4dbeebf57eacece09536726c57844fc59d) | +| FLAC encoder | FilmCraft [8fda2f2](https://github.com/storytold/filmcraft/commit/8fda2f232198c4c79ff9cfe5b6a347bb1375fa9e) | Where apps had overlapping implementations, the one that measured better was kept, with the tests of both: | Format | Kept | Reason | |---|---|---| +| FLAC encoding | FilmCraft | 0.1–37% smaller output than SoundCraft's on 12 test signals; STREAMINFO MD5 | | WAV/AIFF writing | SoundCraft | only one with BWF, dither, arbitrary speaker masks, streaming without known length | ## Migration notes for apps @@ -350,11 +355,12 @@ What an app adjusts: `#[non_exhaustive]`. `Speaker::ALL` is a slice. - `TrackInfo::sample_rate` and `channels` are `Option`s. -- Features are per format. SoundCraft's `encode` is now `lossless-encode`, which turns on - `pcm-encode` (WAV, AIFF). `encode` turns on every encoder. Writing a format +- Features are per format. SoundCraft's `encode` (WAV, AIFF, FLAC) is now `lossless-encode`, made + of `pcm-encode` (WAV, AIFF) and `flac-encode`. `encode` turns on every encoder. Writing a format whose feature is off returns `AudioError::Unsupported`. - `FileEncoder::write` takes any per-channel slices, but refuses a block with missing channels or short slices instead of filling them with silence. - `DecodeOptions` is not `Copy`. +- The FLAC encoder's vendor string is `craft-audio `. - `decode` tries the native path first (WAV, AIFF, and PCM in MP4 and Matroska), which decodes MP4 and Matroska files to presentation length. A failed source read is `AudioError::Io`. diff --git a/crates/audio/examples/encode.rs b/crates/audio/examples/encode.rs index a1c7b89d..6d49d55d 100644 --- a/crates/audio/examples/encode.rs +++ b/crates/audio/examples/encode.rs @@ -1,4 +1,4 @@ -//! Write a tone as WAV and AIFF, whole and streamed. +//! Write a tone as WAV and FLAC, whole and streamed. //! //! `cargo run -p craft-audio --example encode --features lossless-encode -- out` @@ -21,8 +21,8 @@ fn main() -> Result<(), Box> { std::fs::write(dir.join("tone-5.1.wav"), wav)?; // Streamed: the same bytes, block by block into a file. - let opts = EncodeOptions::new(EncodeFormat::Aiff); - let mut enc = FileEncoder::new(std::fs::File::create(dir.join("tone.aiff"))?, &opts, rate as u32, 2)?; + let opts = EncodeOptions::new(EncodeFormat::Flac); + let mut enc = FileEncoder::new(std::fs::File::create(dir.join("tone.flac"))?, &opts, rate as u32, 2)?; for chunk in tone.chunks(4096) { enc.write(&[chunk, chunk], chunk.len())?; } diff --git a/crates/audio/src/lib.rs b/crates/audio/src/lib.rs index 942a141b..cbdc3c07 100644 --- a/crates/audio/src/lib.rs +++ b/crates/audio/src/lib.rs @@ -15,8 +15,9 @@ //! - [`TrackDecoder`] (feature `decode-native`): one track's packets from any demuxer to planar //! `f32` in WAV channel order. //! - [`encode`], [`FileEncoder`]: WAV/BWF/RF64 and AIFF/AIFF-C (feature `pcm-encode`, which also -//! gives [`wav_float_header`]), whole-buffer or incremental into any `Write + Seek`. Features -//! `lossless-encode` and `encode` turn it on. +//! 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` is +//! the same as `lossless-encode`. //! - [`peaks`] (feature `peaks`): min/max/RMS pyramid for waveform drawing. //! //! The shared types below build with no features. @@ -33,7 +34,7 @@ mod read; mod track; #[cfg(any(feature = "decode-native", feature = "pcm-encode"))] mod wav; -#[cfg(feature = "pcm-encode")] +#[cfg(any(feature = "pcm-encode", feature = "flac-encode"))] mod write; pub use buffer::AudioBuffer; @@ -49,7 +50,9 @@ pub use read::{DEFAULT_MAX_SAMPLES, DecodeOptions, Decoder, TrackInfo, decode, d #[cfg(feature = "decode-native")] pub use track::{LOOKAHEAD_PACKETS, TrackDecoder}; #[cfg(feature = "pcm-encode")] -pub use write::{ENCODE_EXTENSIONS, FileEncoder, encode, encode_format_for, wav_float_header}; +pub use write::wav_float_header; +#[cfg(any(feature = "pcm-encode", feature = "flac-encode"))] +pub use write::{ENCODE_EXTENSIONS, FileEncoder, encode, encode_format_for}; /// Errors from probing, decoding and encoding. #[non_exhaustive] @@ -76,7 +79,8 @@ pub enum AudioError { /// Result alias for this module. pub type Result = std::result::Result; -/// The file formats [`encode`] writes (feature `pcm-encode`). +/// The file formats [`encode`] writes. Each needs its feature: WAV and AIFF `pcm-encode`, FLAC +/// `flac-encode`; asking for one that is not built returns [`AudioError::Unsupported`]. #[non_exhaustive] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] @@ -85,6 +89,8 @@ pub enum EncodeFormat { Wav, /// AIFF (integer depths) or AIFF-C `fl32` (float). Aiff, + /// FLAC (integer depths). + Flac, } impl EncodeFormat { @@ -93,6 +99,7 @@ impl EncodeFormat { match self { EncodeFormat::Wav => Container::Wav, EncodeFormat::Aiff => Container::Aiff, + EncodeFormat::Flac => Container::Flac, } } } @@ -244,8 +251,8 @@ pub enum BitDepth { /// ``` /// use craft_audio::{BitDepth, EncodeFormat, EncodeOptions}; /// -/// let opts = EncodeOptions::new(EncodeFormat::Aiff).with_bit_depth(BitDepth::Int16).with_dither(true); -/// assert_eq!(opts.format, EncodeFormat::Aiff); +/// let opts = EncodeOptions::new(EncodeFormat::Flac).with_bit_depth(BitDepth::Int16).with_dither(true); +/// assert_eq!(opts.format, EncodeFormat::Flac); /// ``` #[non_exhaustive] #[derive(Debug, Clone, PartialEq)] diff --git a/crates/audio/src/read/mod.rs b/crates/audio/src/read/mod.rs index 99798120..2a62ef1e 100644 --- a/crates/audio/src/read/mod.rs +++ b/crates/audio/src/read/mod.rs @@ -364,12 +364,12 @@ pub fn probe_with(bytes: &[u8], ext_hint: Option<&str>, opts: &DecodeOptions) -> /// file instead, and [`decode_with`] for another track or size limit. /// /// ``` -/// # #[cfg(feature = "pcm-encode")] { +/// # #[cfg(feature = "flac-encode")] { /// use craft_audio::{AudioBuffer, EncodeFormat, EncodeOptions, decode, encode}; /// /// let tone: Vec = (0..4800).map(|i| (i as f32 * 0.05).sin() * 0.5).collect(); -/// let aiff = encode(&AudioBuffer { sample_rate: 48_000, channels: vec![tone] }, &EncodeOptions::new(EncodeFormat::Aiff)).unwrap(); -/// let (info, buf) = decode(&aiff, None).unwrap(); +/// let flac = encode(&AudioBuffer { sample_rate: 48_000, channels: vec![tone] }, &EncodeOptions::new(EncodeFormat::Flac)).unwrap(); +/// let (info, buf) = decode(&flac, None).unwrap(); /// assert_eq!((info.channels, buf.frames()), (1, 4800)); /// # } /// ``` diff --git a/crates/audio/src/write/flac/crc.rs b/crates/audio/src/write/flac/crc.rs new file mode 100644 index 00000000..0cc4869b --- /dev/null +++ b/crates/audio/src/write/flac/crc.rs @@ -0,0 +1,57 @@ +//! The two frame checksums of RFC 9639 §9.1.8 and §9.3: CRC-8 (polynomial x⁸+x²+x+1) over the +//! frame header and CRC-16 (x¹⁶+x¹⁵+x²+1) over the whole frame, both MSB-first with a zero start. + +const fn table8() -> [u8; 256] { + let mut t = [0u8; 256]; + let mut i = 0; + while i < 256 { + let mut c = i as u8; + let mut b = 0; + while b < 8 { + c = if c & 0x80 != 0 { (c << 1) ^ 0x07 } else { c << 1 }; + b += 1; + } + t[i] = c; + i += 1; + } + t +} + +const fn table16() -> [u16; 256] { + let mut t = [0u16; 256]; + let mut i = 0; + while i < 256 { + let mut c = (i as u16) << 8; + let mut b = 0; + while b < 8 { + c = if c & 0x8000 != 0 { (c << 1) ^ 0x8005 } else { c << 1 }; + b += 1; + } + t[i] = c; + i += 1; + } + t +} + +static T8: [u8; 256] = table8(); +static T16: [u16; 256] = table16(); + +pub fn crc8(data: &[u8]) -> u8 { + data.iter().fold(0u8, |c, &b| T8[usize::from(c ^ b)]) +} + +pub fn crc16(data: &[u8]) -> u16 { + data.iter().fold(0u16, |c, &b| (c << 8) ^ T16[usize::from((c >> 8) as u8 ^ b)]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn check_values() { + // the standard check value of each polynomial over "123456789" + assert_eq!(crc8(b"123456789"), 0xF4); + assert_eq!(crc16(b"123456789"), 0xFEE8); + } +} diff --git a/crates/audio/src/write/flac/encoder.rs b/crates/audio/src/write/flac/encoder.rs new file mode 100644 index 00000000..508bbf59 --- /dev/null +++ b/crates/audio/src/write/flac/encoder.rs @@ -0,0 +1,232 @@ +//! The FLAC encoder (RFC 9639): planar integer samples (8–32 bit, 1–8 channels) → `fLaC`, STREAMINFO, +//! a VORBIS_COMMENT naming the encoder, then fixed-size frames. Each channel of each frame is coded +//! as constant, verbatim, a fixed polynomial predictor or linear prediction, whichever is smallest, +//! with partitioned Rice codes; stereo frames also try left/side, side/right and mid/side. The +//! STREAMINFO sizes and MD5 are only known at the end: [`Encoder::streaminfo`] is written over +//! [`STREAMINFO_OFFSET`] once [`Encoder::finish`] has run. +//! +//! From FilmCraft's `filmcraft-flac` (storytold/filmcraft@8fda2f2). + +use super::frame::{FrameSpec, Stereo}; +use super::predict::{Effort, Subframe}; + +/// Byte offset of the 34-byte STREAMINFO body in [`Encoder::header`]. +pub const STREAMINFO_OFFSET: u64 = 8; + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum Error { + #[error("invalid configuration: {0}")] + InvalidConfig(&'static str), + #[error("expected {expected} channels, got {got}")] + Channels { expected: usize, got: usize }, + #[error("channels of unequal length")] + UnequalChannels, + #[error("sample {0} does not fit the configured sample size")] + SampleRange(i32), +} + +#[derive(Clone, Debug)] +pub struct EncoderConfig { + /// 1 to 1 048 575 Hz. + pub sample_rate: u32, + /// 1–8 (FLAC channel order: L R C LFE Ls Rs … for 5.1). + pub channels: u8, + /// 8–32. At 32 bits stereo channels are coded independently: the side channel of + /// left/side, side/right and mid/side coding would need 33 bits. + pub bits_per_sample: u8, + /// Samples per channel per frame, 16–65535 (4096 by default). + pub block_size: u16, + /// 0 (fastest) to 8 (smallest); 5 by default. + pub level: u8, +} + +impl EncoderConfig { + pub fn new(sample_rate: u32, channels: u8, bits_per_sample: u8) -> Self { + EncoderConfig { sample_rate, channels, bits_per_sample, block_size: 4096, level: 5 } + } +} + +pub struct Encoder { + cfg: EncoderConfig, + effort: Effort, + /// Samples waiting for a full block, per channel. + pending: Vec>, + frame_number: u64, + total_samples: u64, + min_frame: u32, + max_frame: u32, + md5: super::md5::Md5, +} + +impl Encoder { + pub fn new(cfg: EncoderConfig) -> Result { + if !(1..=8).contains(&cfg.channels) { + return Err(Error::InvalidConfig("1 to 8 channels")); + } + if !(8..=32).contains(&cfg.bits_per_sample) { + return Err(Error::InvalidConfig("8 to 32 bits per sample")); + } + if !(1..=1_048_575).contains(&cfg.sample_rate) { + return Err(Error::InvalidConfig("sample rate 1 to 1048575 Hz")); + } + if cfg.block_size < 16 { + return Err(Error::InvalidConfig("block size 16 to 65535")); + } + let effort = Effort::level(cfg.level); + Ok(Encoder { + pending: vec![Vec::new(); usize::from(cfg.channels)], + cfg, + effort, + frame_number: 0, + total_samples: 0, + min_frame: 0, + max_frame: 0, + // RFC 9639 requires an MD5 signature of the unencoded audio in STREAMINFO; this is an + // integrity checksum, not a password hash or a security boundary. + md5: super::md5::Md5::default(), // NOSONAR + }) + } + + /// `fLaC`, the STREAMINFO block (sizes and MD5 unknown yet: see [`Encoder::streaminfo`]) and a + /// VORBIS_COMMENT block with the vendor string. + pub fn header(&self) -> Vec { + let mut v = b"fLaC".to_vec(); + v.extend_from_slice(&[0, 0, 0, 34]); + v.extend_from_slice(&self.streaminfo_with(0, 0, 0, [0; 16])); + let vendor = concat!("craft-audio ", env!("CARGO_PKG_VERSION")); + let len = 4 + vendor.len() + 4; + // last metadata block, type 4 (VORBIS_COMMENT), little-endian lengths inside + v.push(0x84); + v.extend_from_slice(&(len as u32).to_be_bytes()[1..]); + v.extend_from_slice(&(vendor.len() as u32).to_le_bytes()); + v.extend_from_slice(vendor.as_bytes()); + v.extend_from_slice(&0u32.to_le_bytes()); + v + } + + /// The final STREAMINFO body: frame sizes, total samples and MD5 of what has been encoded. + pub fn streaminfo(&self) -> [u8; 34] { + self.streaminfo_with(self.min_frame, self.max_frame, self.total_samples, self.md5.digest()) + } + + fn streaminfo_with(&self, min_frame: u32, max_frame: u32, total: u64, md5: [u8; 16]) -> [u8; 34] { + let mut w = craft_media::bitstream::BitWriter::new(); + let bs = u32::from(self.cfg.block_size); + w.write_bits(bs, 16); + w.write_bits(bs, 16); + w.write_bits(min_frame.min(0xFF_FFFF), 24); + w.write_bits(max_frame.min(0xFF_FFFF), 24); + w.write_bits(self.cfg.sample_rate, 20); + w.write_bits(u32::from(self.cfg.channels) - 1, 3); + w.write_bits(u32::from(self.cfg.bits_per_sample) - 1, 5); + // 36 bits; 0 means unknown, and a count that does not fit is left unknown + let total = if total >> 36 == 0 { total } else { 0 }; + w.write_bits((total >> 32) as u32, 4); + w.write_bits(total as u32, 32); + w.write_bytes(&md5); + let mut out = [0u8; 34]; + out.copy_from_slice(w.bytes().get(..34).unwrap_or(&[0; 34])); + out + } + + /// Add samples (one slice per channel, equal lengths); returns the frames completed. + pub fn encode(&mut self, planar: &[&[i32]]) -> Result, Error> { + Ok(self.encode_frames(planar)?.concat()) + } + + /// [`Encoder::encode`] with each completed frame on its own (one MP4 / QuickTime sample each). + pub fn encode_frames(&mut self, planar: &[&[i32]]) -> Result>, Error> { + if planar.len() != self.pending.len() { + return Err(Error::Channels { expected: self.pending.len(), got: planar.len() }); + } + let n = planar.first().map_or(0, |c| c.len()); + if planar.iter().any(|c| c.len() != n) { + return Err(Error::UnequalChannels); + } + // i64: at 32 bits the range is all of i32 (1 << 31 overflows i32) + let max = (1i64 << (self.cfg.bits_per_sample - 1)) - 1; + if let Some(&bad) = planar.iter().flat_map(|c| c.iter()).find(|&&s| i64::from(s) > max || i64::from(s) < -max - 1) { + return Err(Error::SampleRange(bad)); + } + for (p, c) in self.pending.iter_mut().zip(planar) { + p.extend_from_slice(c); + } + let bs = usize::from(self.cfg.block_size); + let mut out = Vec::new(); + while self.pending.first().is_some_and(|p| p.len() >= bs) { + let block: Vec> = self.pending.iter_mut().map(|p| p.drain(..bs).collect()).collect(); + out.push(self.frame(&block)); + } + Ok(out) + } + + /// Encode what is left as a final, shorter frame. + pub fn finish(&mut self) -> Vec { + if self.pending.first().is_none_or(Vec::is_empty) { + return Vec::new(); + } + let block: Vec> = self.pending.iter_mut().map(std::mem::take).collect(); + self.frame(&block) + } + + fn frame(&mut self, block: &[Vec]) -> Vec { + let n = block.first().map_or(0, Vec::len); + let bps = self.cfg.bits_per_sample; + // the MD5 of the unencoded audio: interleaved, little-endian, whole bytes per sample + let bytes = usize::from(bps.div_ceil(8)); + let mut raw = Vec::with_capacity(n * block.len() * bytes); + for i in 0..n { + for c in block { + raw.extend_from_slice(c.get(i).copied().unwrap_or(0).to_le_bytes().get(..bytes).unwrap_or_default()); + } + } + self.md5.update(&raw); + + let effort = self.effort; + let code = |x: &[i32], b: u8| super::predict::choose(x, b, effort); + let data = match block { + // the side channel takes one bit more than the samples: at most 32 (i32) + [l, r] if effort.stereo && bps < 32 => { + let side: Vec = l.iter().zip(r).map(|(&a, &b)| a - b).collect(); + let mid: Vec = l.iter().zip(r).map(|(&a, &b)| (a + b) >> 1).collect(); + let (cl, cr, cs, cm) = (code(l, bps), code(r, bps), code(&side, bps + 1), code(&mid, bps)); + let options = [ + (Stereo::Independent, cl.1 + cr.1), + (Stereo::LeftSide, cl.1 + cs.1), + (Stereo::SideRight, cs.1 + cr.1), + (Stereo::MidSide, cm.1 + cs.1), + ]; + let best = options.iter().min_by_key(|o| o.1).map_or(Stereo::Independent, |o| o.0); + let channels: Vec<(&[i32], Subframe, u8)> = match best { + Stereo::Independent => vec![(l, cl.0, bps), (r, cr.0, bps)], + Stereo::LeftSide => vec![(l, cl.0, bps), (&side, cs.0, bps + 1)], + Stereo::SideRight => vec![(&side, cs.0, bps + 1), (r, cr.0, bps)], + Stereo::MidSide => vec![(&mid, cm.0, bps), (&side, cs.0, bps + 1)], + }; + super::frame::write(&FrameSpec { + number: self.frame_number, + sample_rate: self.cfg.sample_rate, + bps, + stereo: Some(best), + channels: &channels, + }) + } + _ => { + let channels: Vec<(&[i32], Subframe, u8)> = block.iter().map(|c| (c.as_slice(), code(c, bps).0, bps)).collect(); + super::frame::write(&FrameSpec { + number: self.frame_number, + sample_rate: self.cfg.sample_rate, + bps, + stereo: None, + channels: &channels, + }) + } + }; + self.frame_number += 1; + self.total_samples += n as u64; + let size = u32::try_from(data.len()).unwrap_or(u32::MAX); + self.min_frame = if self.min_frame == 0 { size } else { self.min_frame.min(size) }; + self.max_frame = self.max_frame.max(size); + data + } +} diff --git a/crates/audio/src/write/flac/frame.rs b/crates/audio/src/write/flac/frame.rs new file mode 100644 index 00000000..6b3d28e8 --- /dev/null +++ b/crates/audio/src/write/flac/frame.rs @@ -0,0 +1,205 @@ +//! Writing one frame (RFC 9639 §9): header with CRC-8, one subframe per channel, zero padding to a +//! byte, CRC-16. + +use craft_media::bitstream::BitWriter; + +use super::crc::{crc8, crc16}; +use super::predict::{Residual, Subframe}; + +/// Inter-channel decorrelation of a stereo frame (§9.1.3). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Stereo { + Independent, + /// Left, then side (left − right). + LeftSide, + /// Side, then right. + SideRight, + /// Mid ((left + right) >> 1), then side. + MidSide, +} + +/// Frame header block size bits: 4096 has its own code, others are stored after the header. +fn block_size_code(n: u16) -> (u8, Option<(u32, u32)>) { + match n { + 4096 => (0b1100, None), + 1..=256 => (0b0110, Some((u32::from(n - 1), 8))), + _ => (0b0111, Some((u32::from(n.saturating_sub(1)), 16))), + } +} + +/// Frame header sample rate code; 0 refers to STREAMINFO. +fn sample_rate_code(rate: u32) -> u8 { + match rate { + 88_200 => 1, + 176_400 => 2, + 192_000 => 3, + 8_000 => 4, + 16_000 => 5, + 22_050 => 6, + 24_000 => 7, + 32_000 => 8, + 44_100 => 9, + 48_000 => 10, + 96_000 => 11, + _ => 0, + } +} + +/// Frame header sample size code; 0 refers to STREAMINFO. +fn sample_size_code(bps: u8) -> u8 { + match bps { + 8 => 1, + 12 => 2, + 16 => 4, + 20 => 5, + 24 => 6, + // 32 bits has code 7 since RFC 9639, which earlier decoders (symphonia 0.5, FilmCraft's own + // FLAC import) reject as reserved; 0 ("as STREAMINFO says") decodes everywhere + _ => 0, + } +} + +/// The frame number in the UTF-8-like coding of §9.1.5 (up to 36 bits). +fn coded_number(v: u64, out: &mut Vec) { + if v < 0x80 { + out.push(v as u8); + return; + } + let tail = match v { + 0..0x800 => 1, + 0x800..0x1_0000 => 2, + 0x1_0000..0x20_0000 => 3, + 0x20_0000..0x400_0000 => 4, + 0x400_0000..0x8000_0000 => 5, + _ => 6, + }; + let lead_mask: u8 = !(0xFFu8 >> (tail + 1)); + out.push(lead_mask | (v >> (6 * tail)) as u8 & (0x7F >> (tail + 1))); + for i in (0..tail).rev() { + out.push(0x80 | ((v >> (6 * i)) & 0x3F) as u8); + } +} + +pub struct FrameSpec<'a> { + pub number: u64, + pub sample_rate: u32, + pub bps: u8, + pub stereo: Option, + /// The signals as coded (mid/side already formed), with their subframes. + pub channels: &'a [(&'a [i32], Subframe, u8)], +} + +fn write_residual(w: &mut BitWriter, residual: &Residual, r: &[i64], block: usize, order: usize) { + let wide = residual.wide(); + w.write_bits(u32::from(wide), 2); + w.write_bits(u32::from(residual.partition_order), 4); + let parts = 1usize << residual.partition_order; + let len = block / parts; + let mut start = 0usize; + for (p, &k) in residual.params.iter().enumerate() { + w.write_bits(u32::from(k), if wide { 5 } else { 4 }); + let n = if p == 0 { len.saturating_sub(order) } else { len }; + for &v in r.get(start..start + n).unwrap_or_default() { + let u = if v >= 0 { (v as u64) << 1 } else { ((-v - 1) as u64) << 1 | 1 }; + let mut q = u >> k; + while q >= 32 { + w.write_bits(0, 32); + q -= 32; + } + w.write_bits(1, q as u32 + 1); + w.write_bits((u & ((1u64 << k) - 1)) as u32, u32::from(k)); + } + start += n; + } +} + +fn write_subframe(w: &mut BitWriter, x: &[i32], sub: &Subframe, bps: u8) { + let b = u32::from(bps); + let sample = |w: &mut BitWriter, v: i32| w.write_bits(v as u32, b); + w.write_bit(false); + match sub { + Subframe::Constant(v) => { + w.write_bits(0, 6); + w.write_bit(false); + sample(w, *v); + } + Subframe::Verbatim => { + w.write_bits(1, 6); + w.write_bit(false); + x.iter().for_each(|&v| sample(w, v)); + } + Subframe::Fixed { order, residual } => { + w.write_bits(0b001000 | u32::from(*order), 6); + w.write_bit(false); + let o = usize::from(*order); + x.iter().take(o).for_each(|&v| sample(w, v)); + let r = super::predict::fixed_residual(x, o).unwrap_or_default(); + write_residual(w, residual, &r, x.len(), o); + } + Subframe::Lpc { coefs, precision, shift, residual } => { + let o = coefs.len(); + w.write_bits(0b100000 | (o as u32 - 1), 6); + w.write_bit(false); + x.iter().take(o).for_each(|&v| sample(w, v)); + w.write_bits(u32::from(*precision) - 1, 4); + w.write_bits(u32::from(*shift), 5); + coefs.iter().for_each(|&c| w.write_bits(c as u32, u32::from(*precision))); + let r = super::predict::lpc_residual(x, coefs, *shift).unwrap_or_default(); + write_residual(w, residual, &r, x.len(), o); + } + } +} + +/// One complete frame. +pub fn write(spec: &FrameSpec) -> Vec { + let block = spec.channels.first().map_or(0, |c| c.0.len()); + let (bs_code, bs_extra) = block_size_code(u16::try_from(block).unwrap_or(u16::MAX)); + let channel_code = match spec.stereo { + Some(Stereo::LeftSide) => 8, + Some(Stereo::SideRight) => 9, + Some(Stereo::MidSide) => 10, + _ => spec.channels.len().saturating_sub(1) as u8, + }; + let mut h = vec![0xFF, 0xF8, bs_code << 4 | sample_rate_code(spec.sample_rate), channel_code << 4 | sample_size_code(spec.bps) << 1]; + coded_number(spec.number, &mut h); + if let Some((v, n)) = bs_extra { + if n == 8 { + h.push(v as u8); + } else { + h.extend_from_slice(&(v as u16).to_be_bytes()); + } + } + h.push(crc8(&h)); + let mut w = BitWriter::new(); + w.write_bytes(&h); + for (x, sub, bps) in spec.channels { + write_subframe(&mut w, x, sub, *bps); + } + let mut out = w.finish(); + let crc = crc16(&out); + out.extend_from_slice(&crc.to_be_bytes()); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn coded_numbers() { + let code = |v| { + let mut o = Vec::new(); + coded_number(v, &mut o); + o + }; + assert_eq!(code(0), [0]); + assert_eq!(code(0x7F), [0x7F]); + assert_eq!(code(0x80), [0xC2, 0x80]); + assert_eq!(code(0x7FF), [0xDF, 0xBF]); + assert_eq!(code(0x800), [0xE0, 0xA0, 0x80]); + assert_eq!(code(0xFFFF), [0xEF, 0xBF, 0xBF]); + assert_eq!(code(0x1_0000), [0xF0, 0x90, 0x80, 0x80]); + assert_eq!(code(0x7FFF_FFFF), [0xFD, 0xBF, 0xBF, 0xBF, 0xBF, 0xBF]); + assert_eq!(code(0xF_FFFF_FFFF), [0xFE, 0xBF, 0xBF, 0xBF, 0xBF, 0xBF, 0xBF]); + } +} diff --git a/crates/audio/src/write/flac/md5.rs b/crates/audio/src/write/flac/md5.rs new file mode 100644 index 00000000..8e71d86f --- /dev/null +++ b/crates/audio/src/write/flac/md5.rs @@ -0,0 +1,119 @@ +//! MD5 (RFC 1321), for the STREAMINFO signature of the unencoded audio (RFC 9639 §8.2): every +//! sample, interleaved, little-endian, in the fewest whole bytes that hold the sample size. + +const S: [u32; 64] = [ + 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16, 23, + 4, 11, 16, 23, 4, 11, 16, 23, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, +]; + +/// `floor(abs(sin(i + 1)) × 2³²)`, RFC 1321 §3.4. +const K: [u32; 64] = [ + 0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee, 0xf57c0faf, 0x4787c62a, 0xa8304613, 0xfd469501, 0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be, + 0x6b901122, 0xfd987193, 0xa679438e, 0x49b40821, 0xf61e2562, 0xc040b340, 0x265e5a51, 0xe9b6c7aa, 0xd62f105d, 0x02441453, 0xd8a1e681, 0xe7d3fbc8, + 0x21e1cde6, 0xc33707d6, 0xf4d50d87, 0x455a14ed, 0xa9e3e905, 0xfcefa3f8, 0x676f02d9, 0x8d2a4c8a, 0xfffa3942, 0x8771f681, 0x6d9d6122, 0xfde5380c, + 0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70, 0x289b7ec6, 0xeaa127fa, 0xd4ef3085, 0x04881d05, 0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665, + 0xf4292244, 0x432aff97, 0xab9423a7, 0xfc93a039, 0x655b59c3, 0x8f0ccc92, 0xffeff47d, 0x85845dd1, 0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1, + 0xf7537e82, 0xbd3af235, 0x2ad7d2bb, 0xeb86d391, +]; + +#[derive(Clone)] +pub struct Md5 { + state: [u32; 4], + block: [u8; 64], + filled: usize, + len: u64, +} + +impl Default for Md5 { + fn default() -> Self { + Md5 { state: [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476], block: [0; 64], filled: 0, len: 0 } + } +} + +impl Md5 { + pub fn update(&mut self, mut data: &[u8]) { + self.len = self.len.wrapping_add(data.len() as u64); + while !data.is_empty() { + let take = (64 - self.filled).min(data.len()); + let (head, rest) = data.split_at(take); + if let Some(dst) = self.block.get_mut(self.filled..self.filled + take) { + dst.copy_from_slice(head); + } + self.filled += take; + data = rest; + if self.filled == 64 { + let block = self.block; + self.compress(&block); + self.filled = 0; + } + } + } + + fn compress(&mut self, block: &[u8; 64]) { + let mut m = [0u32; 16]; + for (w, c) in m.iter_mut().zip(block.as_chunks::<4>().0) { + *w = u32::from_le_bytes(*c); + } + let [mut a, mut b, mut c, mut d] = self.state; + for i in 0..64 { + let (f, g) = match i / 16 { + 0 => ((b & c) | (!b & d), i), + 1 => ((d & b) | (!d & c), (5 * i + 1) % 16), + 2 => (b ^ c ^ d, (3 * i + 5) % 16), + _ => (c ^ (b | !d), (7 * i) % 16), + }; + let f = f.wrapping_add(a).wrapping_add(K[i]).wrapping_add(m[g]); + a = d; + d = c; + c = b; + b = b.wrapping_add(f.rotate_left(S[i])); + } + for (s, v) in self.state.iter_mut().zip([a, b, c, d]) { + *s = s.wrapping_add(v); + } + } + + /// The digest so far (the state is not consumed: more data may follow). + pub fn digest(&self) -> [u8; 16] { + let mut h = self.clone(); + let bits = h.len.wrapping_mul(8); + h.update(&[0x80]); + while h.filled != 56 { + h.update(&[0]); + } + h.update(&bits.to_le_bytes()); + let mut out = [0u8; 16]; + for (o, s) in out.as_chunks_mut::<4>().0.iter_mut().zip(h.state) { + o.copy_from_slice(&s.to_le_bytes()); + } + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn hex(d: [u8; 16]) -> String { + d.iter().map(|b| format!("{b:02x}")).collect() + } + + #[test] + fn rfc_1321_test_suite() { + let md5 = |s: &[u8]| { + let mut m = Md5::default(); + m.update(s); + hex(m.digest()) + }; + assert_eq!(md5(b""), "d41d8cd98f00b204e9800998ecf8427e"); + assert_eq!(md5(b"abc"), "900150983cd24fb0d6963f7d28e17f72"); + assert_eq!(md5(b"message digest"), "f96b697d7cb7938d525a2f31aaf161d0"); + assert_eq!(md5(b"12345678901234567890123456789012345678901234567890123456789012345678901234567890"), "57edf4a22be3c955ac49da2e2107b67a"); + // fed in pieces across block boundaries + let mut m = Md5::default(); + for c in b"12345678901234567890123456789012345678901234567890123456789012345678901234567890".chunks(7) { + m.update(c); + } + assert_eq!(hex(m.digest()), "57edf4a22be3c955ac49da2e2107b67a"); + } +} diff --git a/crates/audio/src/write/flac/mod.rs b/crates/audio/src/write/flac/mod.rs new file mode 100644 index 00000000..b4069474 --- /dev/null +++ b/crates/audio/src/write/flac/mod.rs @@ -0,0 +1,130 @@ +//! FLAC writing (RFC 9639): [`Writer`] quantizes planar `f32` and streams it through [`encoder`] +//! (linear prediction, stereo decorrelation, STREAMINFO MD5) into any `Write + Seek`. + +mod crc; +mod encoder; +mod frame; +mod md5; +mod predict; + +#[cfg(test)] +use craft_media_testkit as common; +#[cfg(test)] +mod oracle_tests; + +use super::pcm::{Quantizer, validate_buffer, validate_layout}; +use crate::{AudioBuffer, AudioError, BitDepth, Result}; +use encoder::{Encoder, EncoderConfig, STREAMINFO_OFFSET}; +use std::io::{Cursor, Seek, SeekFrom, Write}; + +/// Checks that FLAC can hold the layout; returns the bits per sample. +fn check(channels: usize, sample_rate: u32, depth: BitDepth) -> Result { + validate_layout(channels, sample_rate)?; + if channels > 8 { + return Err(AudioError::Unsupported(format!("FLAC supports at most 8 channels, got {channels}"))); + } + // STREAMINFO could say up to 1048575 Hz, but a frame header names at most 655350 Hz, and + // symphonia (our decoder) refuses streams above that. + if sample_rate > 655_350 { + return Err(AudioError::Unsupported(format!("FLAC sample rate {sample_rate} exceeds 655350 Hz"))); + } + match depth { + BitDepth::Int16 => Ok(16), + BitDepth::Int24 => Ok(24), + BitDepth::Int32 => Ok(32), + BitDepth::Float32 => Err(AudioError::Unsupported("FLAC cannot store 32-bit float samples".into())), + } +} + +fn encoder_error(e: encoder::Error) -> AudioError { + AudioError::Encode(format!("FLAC: {e}")) +} + +/// A FLAC stream written block by block; [`Writer::finish`] fills in the STREAMINFO sizes, sample +/// count and MD5. +pub(crate) struct Writer { + out: W, + start: u64, + encoder: Encoder, + quantizers: Vec, + planar: Vec>, + frames: u64, + /// Set when a write has failed: the encoder may hold samples the sink never received, so the + /// writer refuses every later call. + failed: bool, +} + +impl Writer { + pub(crate) fn new(mut out: W, channels: usize, sample_rate: u32, depth: BitDepth, dither: bool) -> Result { + let bps = check(channels, sample_rate, depth)?; + // `check` bounds channels to 1..=8. + let encoder = Encoder::new(EncoderConfig::new(sample_rate, channels as u8, bps)).map_err(encoder_error)?; + let start = out.stream_position()?; + out.write_all(&encoder.header())?; + Ok(Writer { + out, + start, + encoder, + quantizers: Quantizer::per_channel(u32::from(bps), dither, channels), + planar: vec![Vec::new(); channels], + frames: 0, + failed: false, + }) + } + + /// The frame count after `frames` more, or an error past what FLAC can count (2^36 frames). + pub(crate) fn check_room(&self, frames: u64) -> Result { + let total = self.frames.saturating_add(frames); + if total >= 1u64 << 36 { + return Err(AudioError::TooLarge("too many frames for FLAC".into())); + } + Ok(total) + } + + /// Appends `frames` frames of planar audio (missing samples are silence), unless they don't + /// fit (see [`Writer::check_room`]). + pub(crate) fn write>(&mut self, block: &[C], frames: usize) -> Result<()> { + if self.failed { + return Err(super::failed_write_error()); + } + let total = self.check_room(frames as u64)?; + for (c, (p, q)) in self.planar.iter_mut().zip(self.quantizers.iter_mut()).enumerate() { + let src = block.get(c).map(AsRef::as_ref).unwrap_or_default(); + p.clear(); + p.extend((0..frames).map(|i| q.quantize(src.get(i).copied().unwrap_or(0.0)))); + } + let refs: Vec<&[i32]> = self.planar.iter().map(Vec::as_slice).collect(); + let written = self.encoder.encode(&refs).map_err(encoder_error).and_then(|bytes| Ok(self.out.write_all(&bytes)?)); + if let Err(e) = written { + self.failed = true; + return Err(e); + } + self.frames = total; + Ok(()) + } + + pub(crate) fn finish(mut self) -> Result { + if self.failed { + return Err(super::failed_write_error()); + } + let last = self.encoder.finish(); + self.out.write_all(&last)?; + self.out.seek(SeekFrom::Start(self.start.saturating_add(STREAMINFO_OFFSET)))?; + self.out.write_all(&self.encoder.streaminfo())?; + self.out.seek(SeekFrom::End(0))?; + self.out.flush()?; + Ok(self.out) + } +} + +pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result> { + validate_buffer(buf)?; + let bps = check(buf.channels.len(), buf.sample_rate, depth)?; + let frames = buf.frames(); + let mut out = Vec::new(); + out.try_reserve(frames.saturating_mul(buf.channels.len()).saturating_mul(usize::from(bps) / 8) / 2) + .map_err(|_| AudioError::TooLarge("cannot allocate FLAC output".into()))?; + let mut w = Writer::new(Cursor::new(out), buf.channels.len(), buf.sample_rate, depth, dither)?; + w.write(&buf.channels, frames)?; + Ok(w.finish()?.into_inner()) +} diff --git a/crates/audio/src/write/flac/oracle_tests.rs b/crates/audio/src/write/flac/oracle_tests.rs new file mode 100644 index 00000000..500365df --- /dev/null +++ b/crates/audio/src/write/flac/oracle_tests.rs @@ -0,0 +1,206 @@ +//! ffmpeg as an external decoder oracle (skipped when absent, see `tests/common`): every stream must +//! decode without an error to exactly the samples that went in, with the STREAMINFO MD5 matching +//! ffmpeg's hash of the decoded audio. From FilmCraft's `filmcraft-flac` tests. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use super::common; +use super::encoder::{Encoder, EncoderConfig, Error, STREAMINFO_OFFSET}; + +fn ffmpeg() -> Option { + common::ffmpeg() +} + +struct Rng(u64); +impl Rng { + fn next(&mut self) -> f64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + (self.0 >> 11) as f64 / (1u64 << 53) as f64 * 2.0 - 1.0 + } +} + +/// Test signals at `bps`, full scale: music-like (two sines and noise), a sweep, white noise, +/// a full-scale square, silence and a lone click. +fn signal(kind: &str, n: usize, bps: u8, seed: u64) -> Vec { + let max = ((1i64 << (bps - 1)) - 1) as f64; + let mut r = Rng(seed | 1); + (0..n) + .map(|i| { + let t = i as f64 / 48_000.0; + let v = match kind { + "music" => { + 0.5 * (2.0 * std::f64::consts::PI * 220.0 * t).sin() + 0.3 * (2.0 * std::f64::consts::PI * 1375.0 * t).sin() + 0.05 * r.next() + } + "sweep" => (2.0 * std::f64::consts::PI * (40.0 + 4000.0 * t) * t).sin() * 0.9, + "noise" => r.next(), + "square" => { + if (i / 37) % 2 == 0 { + 1.0 + } else { + -1.0 + } + } + "click" => f64::from(u8::from(i == n / 2)), + _ => 0.0, + }; + ((v * max).round() as i64).clamp(-(max as i64) - 1, max as i64) as i32 + }) + .collect() +} + +fn encode(planar: &[Vec], cfg: EncoderConfig, chunk: usize) -> Result, Error> { + let mut enc = Encoder::new(cfg)?; + let mut file = enc.header(); + let n = planar.first().map_or(0, Vec::len); + let mut at = 0; + while at < n { + let end = (at + chunk).min(n); + let parts: Vec<&[i32]> = planar.iter().map(|c| &c[at..end]).collect(); + file.extend(enc.encode(&parts)?); + at = end; + } + file.extend(enc.finish()); + let o = STREAMINFO_OFFSET as usize; + file[o..o + 34].copy_from_slice(&enc.streaminfo()); + Ok(file) +} + +fn run(ffmpeg: &Path, args: &[&str], input: &Path) -> (Vec, String) { + let out = Command::new(ffmpeg).args(["-v", "error", "-i"]).arg(input).args(args).output().unwrap(); + assert!(out.status.success(), "ffmpeg failed: {}", String::from_utf8_lossy(&out.stderr)); + (out.stdout, String::from_utf8_lossy(&out.stderr).into_owned()) +} + +/// Encode, decode with ffmpeg, compare every sample and the MD5; returns the file size. +fn round_trip(name: &str, planar: &[Vec], cfg: EncoderConfig, chunk: usize) -> Option { + let ffmpeg = ffmpeg()?; + let bps = cfg.bits_per_sample; + let rate = cfg.sample_rate; + let file = encode(planar, cfg, chunk).unwrap(); + let path = common::fixture_subdir("flac").join(format!("{name}.flac")); + std::fs::write(&path, &file).unwrap(); + + // samples: s32 holds each one shifted to the top bits + let (raw, err) = run(&ffmpeg, &["-f", "s32le", "-c:a", "pcm_s32le", "-"], &path); + assert!(err.is_empty(), "{name}: decoder errors: {err}"); + let decoded: Vec = raw.as_chunks::<4>().0.iter().map(|b| i32::from_le_bytes(*b) >> (32 - bps)).collect(); + let n = planar[0].len(); + assert_eq!(decoded.len(), n * planar.len(), "{name}: sample count"); + for (i, frame) in decoded.chunks_exact(planar.len()).enumerate() { + for (c, &v) in frame.iter().enumerate() { + assert_eq!(v, planar[c][i], "{name}: channel {c} sample {i}"); + } + } + + // STREAMINFO: total samples, rate, and the MD5 against ffmpeg's hash of the decoded audio. FLAC + // hashes samples unscaled; ffmpeg rescales other sizes (20-bit becomes 24-bit), so only 8, 16, + // 24 and 32 bit compare + let si = &file[STREAMINFO_OFFSET as usize..STREAMINFO_OFFSET as usize + 34]; + let pcm = match bps { + 8 => Some("s8"), + 16 => Some("s16le"), + 24 => Some("s24le"), + 32 => Some("s32le"), + _ => None, + }; + if let Some(pcm) = pcm { + let (hash, _) = run(&ffmpeg, &["-c:a", &format!("pcm_{pcm}"), "-f", "hash", "-hash", "md5", "-"], &path); + let hash = String::from_utf8_lossy(&hash); + let ours: String = si[18..34].iter().map(|b| format!("{b:02x}")).collect(); + assert_eq!(hash.trim().strip_prefix("MD5=").unwrap_or(""), ours, "{name}: MD5"); + } + let total = (u64::from(si[13] & 0x0F) << 32) | u64::from(u32::from_be_bytes([si[14], si[15], si[16], si[17]])); + assert_eq!(total, n as u64, "{name}: total samples"); + let si_rate = (u32::from(si[10]) << 12) | (u32::from(si[11]) << 4) | (u32::from(si[12]) >> 4); + assert_eq!(si_rate, rate); + Some(file.len()) +} + +#[test] +fn every_signal_round_trips_exactly() { + for (kind, seed) in [("music", 1), ("sweep", 2), ("noise", 3), ("square", 4), ("silence", 5), ("click", 6)] { + for bps in [16u8, 24, 32] { + let planar = vec![signal(kind, 20_000, bps, seed), signal(kind, 20_000, bps, seed + 100)]; + if round_trip(&format!("{kind}-{bps}"), &planar, EncoderConfig::new(48_000, 2, bps), 4800).is_none() { + return; + } + } + } +} + +#[test] +fn channel_layouts_rates_and_sizes() { + let cases: [(&str, u8, u8, u32, usize); 8] = [ + ("mono-8bit", 1, 8, 8_000, 3_001), + ("mono-24-44k1", 1, 24, 44_100, 9_999), + ("stereo-20bit", 2, 20, 96_000, 10_000), + ("5.1-24", 6, 24, 48_000, 12_345), + ("mono-32", 1, 32, 48_000, 7_777), + ("5.1-32", 6, 32, 96_000, 12_345), + ("odd-rate", 2, 16, 37_800, 5_000), + ("tiny", 2, 16, 48_000, 1), + ]; + for (name, ch, bps, rate, n) in cases { + let planar: Vec> = (0..ch).map(|c| signal(if c == 3 { "silence" } else { "music" }, n, bps, u64::from(c) + 7)).collect(); + if round_trip(name, &planar, EncoderConfig::new(rate, ch, bps), 1000).is_none() { + return; + } + } +} + +#[test] +fn levels_and_block_sizes_trade_time_for_size() { + let planar = vec![signal("music", 48_000, 16, 11), signal("sweep", 48_000, 16, 12)]; + let mut sizes = Vec::new(); + for level in 0..=8 { + let cfg = EncoderConfig { level, ..EncoderConfig::new(48_000, 2, 16) }; + let Some(size) = round_trip(&format!("level{level}"), &planar, cfg, 48_000) else { return }; + sizes.push(size); + } + // the effortful levels are never larger than the fastest, and all beat PCM + assert!(sizes[8] <= sizes[0] && sizes[5] <= sizes[0], "{sizes:?}"); + assert!(sizes.iter().all(|&s| s < 48_000 * 4), "{sizes:?}"); + for bs in [16u16, 192, 1152, 4608, 65_535] { + let cfg = EncoderConfig { block_size: bs, ..EncoderConfig::new(48_000, 2, 16) }; + round_trip(&format!("block{bs}"), &planar, cfg, 777); + } +} + +#[test] +fn bad_input_is_an_error_not_a_panic() { + assert!(Encoder::new(EncoderConfig::new(48_000, 0, 16)).is_err()); + assert!(Encoder::new(EncoderConfig::new(48_000, 9, 16)).is_err()); + assert!(Encoder::new(EncoderConfig::new(48_000, 2, 33)).is_err()); + assert!(Encoder::new(EncoderConfig::new(48_000, 2, 7)).is_err()); + assert!(Encoder::new(EncoderConfig::new(0, 2, 16)).is_err()); + assert!(Encoder::new(EncoderConfig { block_size: 8, ..EncoderConfig::new(48_000, 2, 16) }).is_err()); + let mut enc = Encoder::new(EncoderConfig::new(48_000, 2, 16)).unwrap(); + assert_eq!(enc.encode(&[&[0, 1]]), Err(Error::Channels { expected: 2, got: 1 })); + assert_eq!(enc.encode(&[&[0, 1], &[0]]), Err(Error::UnequalChannels)); + assert_eq!(enc.encode(&[&[40_000], &[0]]), Err(Error::SampleRange(40_000))); + assert_eq!(enc.encode(&[&[-32_769], &[0]]), Err(Error::SampleRange(-32_769))); + // nothing encoded: no frames, and a header that still says "unknown" + assert!(enc.finish().is_empty()); + assert_eq!(&enc.header()[..4], b"fLaC"); +} + +/// 32-bit stereo at the extremes of i32: left minus right would need 33 bits, so the encoder must +/// not code a side channel (it overflowed `i32` and wrote garbage); every level decodes exactly. +#[test] +fn full_scale_32_bit_stereo_round_trips() { + let n = 9_000; + let left: Vec = (0..n).map(|i| if i % 3 == 0 { i32::MAX } else { i32::MIN }).collect(); + let right: Vec = left.iter().map(|&v| if v == i32::MAX { i32::MIN } else { i32::MAX }).collect(); + let planar = vec![left, right]; + for level in [0u8, 5, 8] { + let cfg = EncoderConfig { level, ..EncoderConfig::new(48_000, 2, 32) }; + if round_trip(&format!("extremes-32-level{level}"), &planar, cfg, 4096).is_none() { + return; + } + } + let mut enc = Encoder::new(EncoderConfig::new(48_000, 2, 32)).unwrap(); + assert!(enc.encode(&[&[i32::MIN, i32::MAX], &[0, -1]]).is_ok()); +} diff --git a/crates/audio/src/write/flac/predict.rs b/crates/audio/src/write/flac/predict.rs new file mode 100644 index 00000000..81646cce --- /dev/null +++ b/crates/audio/src/write/flac/predict.rs @@ -0,0 +1,327 @@ +//! Subframe prediction (RFC 9639 §9.2.5–9.2.6) and residual coding (§9.2.7): the fixed polynomial +//! predictors, linear prediction from the windowed autocorrelation (Levinson–Durbin), and +//! partitioned Rice codes. Everything here only measures and chooses; [`super::frame`] writes. + +/// Residuals larger than this are not Rice coded (a 5-bit parameter tops out at 30). +const MAX_RESIDUAL: i64 = (1 << 30) - 1; + +/// A residual coded with partitioned Rice codes. +#[derive(Clone, Debug, PartialEq)] +pub struct Residual { + /// Rice parameter per partition; `1 << order` partitions. + pub params: Vec, + pub partition_order: u8, + /// Total bits of the residual section, coding method included. + pub bits: u64, +} + +impl Residual { + /// The 5-bit parameter method is needed when any parameter passes 14 (15 is the escape code). + pub fn wide(&self) -> bool { + self.params.iter().any(|&k| k > 14) + } +} + +/// How one channel of one block is coded. +#[derive(Clone, Debug, PartialEq)] +pub enum Subframe { + Constant(i32), + Verbatim, + Fixed { order: u8, residual: Residual }, + Lpc { coefs: Vec, precision: u8, shift: u8, residual: Residual }, +} + +/// Folded residual: 0, -1, 1, -2, 2 … → 0, 1, 2, 3, 4 …. +fn fold(r: i64) -> u64 { + if r >= 0 { (r as u64) << 1 } else { ((-r - 1) as u64) << 1 | 1 } +} + +/// Bits of `n` folded values summing to `sum` with Rice parameter `k`: unary quotient, stop bit, +/// `k` low bits. The quotient total is approximated by `sum >> k`, exact up to the per-value +/// carries; [`rice_bits_exact`] gives the true cost for the chosen parameter. +fn rice_estimate(sum: u64, n: u64, k: u32) -> u64 { + (sum >> k) + n * (u64::from(k) + 1) +} + +fn rice_bits_exact(u: &[u64], k: u32) -> u64 { + u.iter().map(|&v| (v >> k) + u64::from(k) + 1).sum() +} + +/// The best parameter for a partition of folded values. +fn best_param(u: &[u64]) -> (u8, u64) { + let n = u.len() as u64; + let sum: u64 = u.iter().sum(); + if n == 0 { + return (0, 0); + } + let mean = sum / n; + let guess = if mean == 0 { 0 } else { 63 - mean.leading_zeros() }; + let mut best = (0u32, u64::MAX); + for k in guess.saturating_sub(1)..=(guess + 1).min(30) { + let b = rice_estimate(sum, n, k); + if b < best.1 { + best = (k, b); + } + } + (best.0 as u8, rice_bits_exact(u, best.0)) +} + +/// Choose the partition order and parameters for the residual of a block of `block` samples whose +/// first `order` samples are warm-up (`u` holds the folded residual after them). +fn code_residual(u: &[u64], block: usize, order: usize, max_partition_order: u8) -> Residual { + let mut best: Option = None; + for po in 0..=max_partition_order { + let parts = 1usize << po; + if !block.is_multiple_of(parts) { + break; + } + let len = block / parts; + if len <= order { + break; + } + let mut params = Vec::with_capacity(parts); + let mut bits = 0u64; + let mut start = 0usize; + for p in 0..parts { + let n = if p == 0 { len - order } else { len }; + let Some(chunk) = u.get(start..start + n) else { break }; + let (k, b) = best_param(chunk); + params.push(k); + bits += b; + start += n; + } + let wide = params.iter().any(|&k| k > 14); + bits += 2 + 4 + parts as u64 * if wide { 5 } else { 4 }; + if best.as_ref().is_none_or(|b| bits < b.bits) { + best = Some(Residual { params, partition_order: po, bits }); + } + } + best.unwrap_or(Residual { params: vec![0], partition_order: 0, bits: u64::MAX }) +} + +/// The residual of fixed predictor `order` (0–4), or None when it does not fit. +pub fn fixed_residual(x: &[i32], order: usize) -> Option> { + let x: Vec = x.iter().map(|&v| i64::from(v)).collect(); + let mut out = Vec::with_capacity(x.len().saturating_sub(order)); + for i in order..x.len() { + let s = |j: usize| x.get(i - j).copied().unwrap_or(0); + let r = match order { + 0 => s(0), + 1 => s(0) - s(1), + 2 => s(0) - 2 * s(1) + s(2), + 3 => s(0) - 3 * s(1) + 3 * s(2) - s(3), + _ => s(0) - 4 * s(1) + 6 * s(2) - 4 * s(3) + s(4), + }; + if r.abs() > MAX_RESIDUAL { + return None; + } + out.push(r); + } + Some(out) +} + +/// The residual of quantised linear prediction, or None when it does not fit. +pub fn lpc_residual(x: &[i32], coefs: &[i32], shift: u8) -> Option> { + let order = coefs.len(); + let mut out = Vec::with_capacity(x.len().saturating_sub(order)); + for i in order..x.len() { + let hist = x.get(i - order..i)?; + // coefs[0] weighs the previous sample + let pred: i64 = coefs.iter().zip(hist.iter().rev()).map(|(&c, &s)| i64::from(c) * i64::from(s)).sum(); + let r = i64::from(*x.get(i)?) - (pred >> shift); + if r.abs() > MAX_RESIDUAL { + return None; + } + out.push(r); + } + Some(out) +} + +/// Tukey(0.5) window: flat in the middle half, raised-cosine tapers. +fn window(n: usize) -> Vec { + let taper = (n / 4).max(1); + (0..n) + .map(|i| { + let edge = i.min(n - 1 - i); + if edge >= taper { 1.0 } else { 0.5 * (1.0 - (std::f64::consts::PI * edge as f64 / taper as f64).cos()) } + }) + .collect() +} + +/// Linear prediction coefficients of every order 1..=`max` (Levinson–Durbin on the windowed +/// autocorrelation); empty for a silent or degenerate block. +fn lpc_orders(x: &[i32], max: usize) -> Vec> { + let n = x.len(); + if n <= max { + return Vec::new(); + } + let w = window(n); + let s: Vec = x.iter().zip(&w).map(|(&v, &w)| f64::from(v) * w).collect(); + let r: Vec = (0..=max).map(|lag| s.iter().zip(s.iter().skip(lag)).map(|(a, b)| a * b).sum()).collect(); + let Some(&r0) = r.first() else { return Vec::new() }; + if r0 <= 0.0 { + return Vec::new(); + } + let mut err = r0 * (1.0 + 1e-9); + let mut a: Vec = Vec::new(); + let mut all = Vec::with_capacity(max); + for i in 0..max { + let ri = r.get(i + 1).copied().unwrap_or(0.0); + let acc: f64 = a.iter().enumerate().map(|(j, &aj)| aj * r.get(i - j).copied().unwrap_or(0.0)).sum(); + let k = (ri - acc) / err; + if !k.is_finite() { + break; + } + let prev = a.clone(); + a.push(k); + for j in 0..i { + if let (Some(aj), Some(&p)) = (a.get_mut(j), prev.get(i - 1 - j)) { + *aj -= k * p; + } + } + err *= 1.0 - k * k; + all.push(a.clone()); + if err <= 0.0 { + break; + } + } + all +} + +/// Quantise coefficients to `precision`-bit integers with a right shift (0–15), carrying the +/// rounding error forward so the quantised filter stays close to the real one. +fn quantise(coefs: &[f64], precision: u8) -> Option<(Vec, u8)> { + let cmax = coefs.iter().fold(0.0f64, |m, c| m.max(c.abs())); + if !(cmax.is_finite() && cmax > 0.0) { + return None; + } + let lim = (1i64 << (precision - 1)) - 1; + // the largest shift that keeps the biggest coefficient inside the precision + let log = cmax.log2().floor() as i32 + 1; + let shift = (i32::from(precision) - 1 - log).clamp(0, 15); + let scale = f64::from(1u32 << shift); + let mut carry = 0.0; + let mut q = Vec::with_capacity(coefs.len()); + for &c in coefs { + carry += c * scale; + let v = (carry.round() as i64).clamp(-lim - 1, lim); + carry -= v as f64; + q.push(v as i32); + } + Some((q, shift as u8)) +} + +/// Encoding effort, from the compression level. +#[derive(Clone, Copy, Debug)] +pub struct Effort { + pub max_lpc_order: usize, + pub max_partition_order: u8, + pub stereo: bool, +} + +impl Effort { + pub fn level(level: u8) -> Effort { + let (max_lpc_order, max_partition_order, stereo) = match level { + 0 => (0, 3, false), + 1 | 2 => (0, 3, true), + 3 => (6, 4, false), + 4 => (8, 4, true), + 5 => (8, 5, true), + 6 => (8, 6, true), + _ => (12, 6, true), + }; + Effort { max_lpc_order, max_partition_order, stereo } + } +} + +/// The cheapest coding of one channel of `bps`-bit samples, and its size in bits. +pub fn choose(x: &[i32], bps: u8, effort: Effort) -> (Subframe, u64) { + let n = x.len(); + let b = u64::from(bps); + let head = 8; // zero bit, type, wasted-bits flag + let first = x.first().copied().unwrap_or(0); + if x.iter().all(|&v| v == first) { + return (Subframe::Constant(first), head + b); + } + let mut best = (Subframe::Verbatim, head + n as u64 * b); + let mut consider = |sub: Subframe, bits: u64| { + if bits < best.1 { + best = (sub, bits); + } + }; + let folded = |r: &[i64]| r.iter().map(|&v| fold(v)).collect::>(); + for order in 0..=4usize.min(n.saturating_sub(1)) { + let Some(r) = fixed_residual(x, order) else { continue }; + let residual = code_residual(&folded(&r), n, order, effort.max_partition_order); + let bits = head + order as u64 * b + residual.bits; + consider(Subframe::Fixed { order: order as u8, residual }, bits); + } + // 15-bit coefficients, fewer for short blocks (their side information weighs more) + let precision: u8 = if n <= 1152 { 12 } else { 15 }; + for coefs in lpc_orders(x, effort.max_lpc_order) { + let Some((q, shift)) = quantise(&coefs, precision) else { continue }; + let order = q.len(); + let Some(r) = lpc_residual(x, &q, shift) else { continue }; + let residual = code_residual(&folded(&r), n, order, effort.max_partition_order); + let bits = head + order as u64 * (b + u64::from(precision)) + 4 + 5 + residual.bits; + consider(Subframe::Lpc { coefs: q, precision, shift, residual }, bits); + } + best +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn folding() { + assert_eq!([0, -1, 1, -2, 2].map(fold), [0, 1, 2, 3, 4]); + } + + #[test] + fn predictable_signals_beat_verbatim() { + // a ramp is exactly predicted by the order-2 fixed predictor + let ramp: Vec = (0..4096).map(|i| i * 3 - 6000).collect(); + let (sub, bits) = choose(&ramp, 16, Effort::level(5)); + assert!(matches!(sub, Subframe::Fixed { order: 2, .. } | Subframe::Lpc { .. }), "{sub:?}"); + assert!(bits < 4096 * 2, "{bits}"); + // a sine is well predicted by LPC + let sine: Vec = (0..4096).map(|i| ((i as f64 * 0.05).sin() * 20000.0) as i32).collect(); + let (_, bits) = choose(&sine, 16, Effort::level(5)); + assert!(bits < 4096 * 6, "{bits}"); + assert_eq!(choose(&[7; 100], 16, Effort::level(5)), (Subframe::Constant(7), 24)); + } + + #[test] + fn lpc_residual_inverts() { + let x: Vec = (0..500).map(|i| ((i as f64 * 0.1).sin() * 1000.0) as i32).collect(); + let coefs = lpc_orders(&x, 4).pop().unwrap(); + let (q, shift) = quantise(&coefs, 15).unwrap(); + let r = lpc_residual(&x, &q, shift).unwrap(); + // rebuild the signal from the warm-up and the residual, as a decoder would + let mut y: Vec = x[..q.len()].to_vec(); + for (i, &res) in r.iter().enumerate() { + let p: i64 = q.iter().zip(y[i..i + q.len()].iter().rev()).map(|(&c, &s)| i64::from(c) * i64::from(s)).sum(); + y.push((res + (p >> shift)) as i32); + } + assert_eq!(y, x); + } + + #[test] + fn noise_and_extremes_do_not_panic() { + let mut seed = 1u32; + let noise: Vec = (0..4096) + .map(|_| { + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + (seed >> 8) as i32 - (1 << 23) + }) + .collect(); + let (_, bits) = choose(&noise, 24, Effort::level(8)); + assert!(bits <= 8 + 4096 * 24); + let square: Vec = (0..4096).map(|i| if i % 2 == 0 { (1 << 23) - 1 } else { -(1 << 23) }).collect(); + choose(&square, 24, Effort::level(8)); + choose(&[], 16, Effort::level(5)); + choose(&[1], 16, Effort::level(5)); + choose(&[1, 2], 16, Effort::level(8)); + } +} diff --git a/crates/audio/src/write/mod.rs b/crates/audio/src/write/mod.rs index 8e845aa1..cb75f0d3 100644 --- a/crates/audio/src/write/mod.rs +++ b/crates/audio/src/write/mod.rs @@ -1,49 +1,83 @@ -//! Encoding: WAV/BWF/RF64 and AIFF/AIFF-C writers (`pcm-encode`), whole-buffer and streaming. +//! Encoding: WAV/BWF/RF64 and AIFF/AIFF-C writers (`pcm-encode`) and the FLAC encoder +//! (`flac-encode`), whole-buffer and streaming. +#[cfg(feature = "pcm-encode")] mod aiff; +#[cfg(feature = "flac-encode")] +mod flac; mod pcm; +#[cfg(feature = "pcm-encode")] mod wav; use crate::{AudioBuffer, AudioError, ChannelLayout, EncodeFormat, EncodeOptions, Result}; use std::io::{Seek, Write}; -/// Encode `buf` as WAV/BWF or AIFF/AIFF-C. Integer depths clamp samples to [-1, 1]; float +/// Encode `buf` as WAV/BWF, AIFF/AIFF-C or FLAC. Integer depths clamp samples to [-1, 1]; float /// keeps values above full scale. `opts.layout`, when set, must have `buf`'s channel count. /// /// ``` +/// # #[cfg(feature = "pcm-encode")] { /// use craft_audio::{AudioBuffer, BitDepth, EncodeFormat, EncodeOptions, encode}; /// /// let buf = AudioBuffer::new(48_000, 2, 48_000); // one second of stereo silence /// let opts = EncodeOptions::new(EncodeFormat::Wav).with_bit_depth(BitDepth::Int24).with_dither(true); /// let wav: Vec = encode(&buf, &opts).unwrap(); /// assert_eq!(&wav[..4], b"RIFF"); +/// # } /// ``` pub fn encode(buf: &AudioBuffer, opts: &EncodeOptions) -> Result> { // Checked for every format; only a WAV records it. + #[cfg_attr(not(feature = "pcm-encode"), allow(unused_variables))] let mask = opts.layout.map(|l| wav_mask(&l, buf.channels.len())).transpose()?; match opts.format { + #[cfg(feature = "pcm-encode")] EncodeFormat::Wav => wav::encode(buf, opts.bit_depth, opts.dither, opts.bwf.as_ref(), opts.force_rf64, mask), + #[cfg(feature = "pcm-encode")] EncodeFormat::Aiff => aiff::encode(buf, opts.bit_depth, opts.dither), + #[cfg(feature = "flac-encode")] + EncodeFormat::Flac => flac::encode(buf, opts.bit_depth, opts.dither), + #[allow(unreachable_patterns)] + f => Err(not_built(f)), } } -/// The file extensions of the formats [`encode`] can write. -pub const ENCODE_EXTENSIONS: &[&str] = &["wav", "wave", "bwf", "aif", "aiff", "aifc"]; +/// The error for a format whose writer is not built. +#[allow(dead_code)] +fn not_built(format: EncodeFormat) -> AudioError { + let feature = if format == EncodeFormat::Flac { "flac-encode" } else { "pcm-encode" }; + AudioError::Unsupported(format!("writing {format:?} needs the `{feature}` feature")) +} -/// The format [`encode`] writes for a file extension or format name (`wav`, `aiff`, case-insensitive, -/// with or without the dot), or `None` when it cannot write it. `aifc` is AIFF: float AIFF is -/// written as AIFF-C. +/// The file extensions of the formats [`encode`] can write (those whose features are on). +#[cfg(all(feature = "pcm-encode", feature = "flac-encode"))] +pub const ENCODE_EXTENSIONS: &[&str] = &["wav", "wave", "bwf", "aif", "aiff", "aifc", "flac"]; +/// The file extensions of the formats [`encode`] can write (those whose features are on). +#[cfg(all(feature = "pcm-encode", not(feature = "flac-encode")))] +pub const ENCODE_EXTENSIONS: &[&str] = &["wav", "wave", "bwf", "aif", "aiff", "aifc"]; +/// The file extensions of the formats [`encode`] can write (those whose features are on). +#[cfg(all(feature = "flac-encode", not(feature = "pcm-encode")))] +pub const ENCODE_EXTENSIONS: &[&str] = &["flac"]; + +/// The format [`encode`] writes for a file extension or format name (`wav`, `aiff`, `flac`, +/// case-insensitive, with or without the dot), or `None` when it cannot write it. `aifc` is +/// AIFF: float AIFF is written as AIFF-C. /// /// ``` +/// # #[cfg(feature = "pcm-encode")] { /// use craft_audio::{EncodeFormat, encode_format_for}; /// /// assert_eq!(encode_format_for(".AIFF"), Some(EncodeFormat::Aiff)); /// assert_eq!(encode_format_for("mp3"), None); +/// # } /// ``` pub fn encode_format_for(name: &str) -> Option { match name.trim_start_matches('.').to_ascii_lowercase().as_str() { + #[cfg(feature = "pcm-encode")] "wav" | "wave" | "bwf" => Some(EncodeFormat::Wav), + #[cfg(feature = "pcm-encode")] "aif" | "aiff" | "aifc" => Some(EncodeFormat::Aiff), + #[cfg(feature = "flac-encode")] + "flac" => Some(EncodeFormat::Flac), _ => None, } } @@ -64,24 +98,26 @@ fn failed_write_error() -> AudioError { AudioError::Encode("the stream encoder already failed on an earlier write; its output is incomplete".into()) } -/// Writes WAV/BWF (RF64 above 4 GiB) or AIFF / AIFF-C `fl32` block by block, producing +/// Writes WAV/BWF (RF64 above 4 GiB), AIFF / AIFF-C `fl32` or FLAC block by block, producing /// the same bytes [`encode`] would for the whole buffer: a file of any length never /// has to fit in memory. The file starts at the sink's position, and nothing may follow that /// position (a new file, or one truncated there): the encoder cannot truncate a `Write + Seek`, /// so the bytes of a longer file already there would stay after the new one. /// /// ``` +/// # #[cfg(feature = "flac-encode")] { /// use craft_audio::{EncodeFormat, EncodeOptions, FileEncoder}; /// use std::io::Cursor; /// -/// let opts = EncodeOptions::new(EncodeFormat::Aiff); +/// let opts = EncodeOptions::new(EncodeFormat::Flac); /// let mut enc = FileEncoder::new(Cursor::new(Vec::new()), &opts, 48_000, 2).unwrap(); /// let block = [vec![0.25f32; 4096], vec![-0.25f32; 4096]]; // one Vec (or slice) per channel /// for _ in 0..10 { /// enc.write(&block, 4096).unwrap(); /// } -/// let aiff = enc.finish().unwrap().into_inner(); -/// assert_eq!(&aiff[..4], b"FORM"); +/// let flac = enc.finish().unwrap().into_inner(); +/// assert_eq!(&flac[..4], b"fLaC"); +/// # } /// ``` pub struct FileEncoder { writer: Writer, @@ -91,16 +127,24 @@ pub struct FileEncoder { impl std::fmt::Debug for FileEncoder { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let format = match &self.writer { + #[cfg(feature = "pcm-encode")] Writer::Wav(_) => EncodeFormat::Wav, + #[cfg(feature = "pcm-encode")] Writer::Aiff(_) => EncodeFormat::Aiff, + #[cfg(feature = "flac-encode")] + Writer::Flac(_) => EncodeFormat::Flac, }; f.debug_struct("FileEncoder").field("format", &format).finish_non_exhaustive() } } enum Writer { + #[cfg(feature = "pcm-encode")] Wav(wav::Writer), + #[cfg(feature = "pcm-encode")] Aiff(aiff::Writer), + #[cfg(feature = "flac-encode")] + Flac(flac::Writer), } impl FileEncoder { @@ -108,8 +152,10 @@ impl FileEncoder { /// `opts.layout`, when set, must have `channels` channels. pub fn new(out: W, opts: &EncodeOptions, sample_rate: u32, channels: u16) -> Result { let (depth, dither, channels) = (opts.bit_depth, opts.dither, usize::from(channels)); + #[cfg_attr(not(feature = "pcm-encode"), allow(unused_variables))] let mask = opts.layout.map(|l| wav_mask(&l, channels)).transpose()?; let writer = match opts.format { + #[cfg(feature = "pcm-encode")] EncodeFormat::Wav => Writer::Wav(wav::Writer::new( out, channels, @@ -118,17 +164,26 @@ impl FileEncoder { dither, wav::HeaderExtras { bwf: opts.bwf.as_ref(), mask, force_rf64: opts.force_rf64 }, )?), + #[cfg(feature = "pcm-encode")] EncodeFormat::Aiff => Writer::Aiff(aiff::Writer::new(out, channels, sample_rate, depth, dither)?), + #[cfg(feature = "flac-encode")] + EncodeFormat::Flac => Writer::Flac(flac::Writer::new(out, channels, sample_rate, depth, dither)?), + #[allow(unreachable_patterns)] + f => return Err(not_built(f)), }; Ok(FileEncoder { writer, channels }) } /// Fails, without writing anything, when `frames` more frames would not fit the format (an - /// AIFF holds 4 GiB). + /// AIFF holds 4 GiB, a FLAC 2^36 frames). pub fn check_room(&self, frames: u64) -> Result<()> { match &self.writer { + #[cfg(feature = "pcm-encode")] Writer::Wav(w) => w.check_room(frames), + #[cfg(feature = "pcm-encode")] Writer::Aiff(w) => w.check_room(frames), + #[cfg(feature = "flac-encode")] + Writer::Flac(w) => w.check_room(frames), } .map(drop) } @@ -147,16 +202,24 @@ impl FileEncoder { return Err(AudioError::Encode(format!("{frames} frames asked for, but a channel holds {short}"))); } match &mut self.writer { + #[cfg(feature = "pcm-encode")] Writer::Wav(w) => w.write(block, frames), + #[cfg(feature = "pcm-encode")] Writer::Aiff(w) => w.write(block, frames), + #[cfg(feature = "flac-encode")] + Writer::Flac(w) => w.write(block, frames), } } /// Completes the headers and hands back `out`, positioned at the end of the file. pub fn finish(self) -> Result { match self.writer { + #[cfg(feature = "pcm-encode")] Writer::Wav(w) => w.finish(), + #[cfg(feature = "pcm-encode")] Writer::Aiff(w) => w.finish(), + #[cfg(feature = "flac-encode")] + Writer::Flac(w) => w.finish(), } } } @@ -175,9 +238,28 @@ impl FileEncoder { /// let header = wav_float_header(48_000, 2, 2).unwrap(); /// file[..header.len()].copy_from_slice(&header); /// ``` +#[cfg(feature = "pcm-encode")] pub fn wav_float_header(sample_rate: u32, channels: u16, frames: u64) -> Result> { if channels == 0 || sample_rate == 0 { return Err(AudioError::Encode("a WAV needs channels and a sample rate".into())); } wav::header(usize::from(channels), sample_rate, (32, true), None, None, frames, false) } + +// With only one of `pcm-encode` and `flac-encode`, the other's formats are refused, not silently +// written as something else. +#[cfg(all(test, not(all(feature = "pcm-encode", feature = "flac-encode"))))] +mod tests { + use super::*; + + #[test] + fn unbuilt_format_is_unsupported() { + let (format, name) = if cfg!(feature = "pcm-encode") { (EncodeFormat::Flac, "flac") } else { (EncodeFormat::Wav, "wav") }; + let opts = EncodeOptions::new(format); + let buf = AudioBuffer::new(48_000, 1, 16); + assert!(matches!(encode(&buf, &opts), Err(AudioError::Unsupported(_)))); + let enc = FileEncoder::new(std::io::Cursor::new(Vec::new()), &opts, 48_000, 1); + assert!(matches!(enc, Err(AudioError::Unsupported(_)))); + assert_eq!(encode_format_for(name), None); + } +} diff --git a/crates/audio/src/write/pcm.rs b/crates/audio/src/write/pcm.rs index 834cd687..8374f3a0 100644 --- a/crates/audio/src/write/pcm.rs +++ b/crates/audio/src/write/pcm.rs @@ -1,4 +1,4 @@ -//! PCM helpers shared by the WAV and AIFF writers: quantization (with optional TPDF +//! PCM helpers shared by the WAV, AIFF and FLAC writers: quantization (with optional TPDF //! dither), interleaving and layout checks. use crate::{AudioBuffer, AudioError, Result}; diff --git a/crates/audio/tests/audio/encode_format.rs b/crates/audio/tests/audio/encode_format.rs index 67c5da07..fb47652c 100644 --- a/crates/audio/tests/audio/encode_format.rs +++ b/crates/audio/tests/audio/encode_format.rs @@ -9,10 +9,11 @@ fn only_formats_the_encoder_writes_are_named() { assert_eq!(encode_format_for("wave"), Some(EncodeFormat::Wav)); assert_eq!(encode_format_for("BWF"), Some(EncodeFormat::Wav)); assert_eq!(encode_format_for(".aifc"), Some(EncodeFormat::Aiff)); + assert_eq!(encode_format_for("flac"), Some(EncodeFormat::Flac)); for name in craft_audio::ENCODE_EXTENSIONS { assert!(encode_format_for(name).is_some(), "{name}"); } - for other in ["flac", "mp3", "ogg", "m4a", "xyz", ""] { + for other in ["mp3", "ogg", "m4a", "xyz", ""] { assert_eq!(encode_format_for(other), None, "{other}"); } } diff --git a/crates/audio/tests/audio/float_headroom.rs b/crates/audio/tests/audio/float_headroom.rs index a16098b2..e0c62204 100644 --- a/crates/audio/tests/audio/float_headroom.rs +++ b/crates/audio/tests/audio/float_headroom.rs @@ -89,7 +89,7 @@ fn float32_keeps_non_finite_sample_handling() { fn integer_pcm_still_clips_at_full_scale() { let audio = AudioBuffer { sample_rate: 48_000, channels: vec![vec![-2.0, -1.25, -1.0, -0.5, 0.0, 0.5, 1.0, 1.25, 2.0]] }; for (depth, max) in [(BitDepth::Int16, 32767.0 / 32768.0), (BitDepth::Int24, 8_388_607.0 / 8_388_608.0), (BitDepth::Int32, 1.0)] { - for format in [EncodeFormat::Wav, EncodeFormat::Aiff] { + for format in [EncodeFormat::Wav, EncodeFormat::Aiff, EncodeFormat::Flac] { for streaming in [false, true] { let options = EncodeOptions::new(format).with_bit_depth(depth).with_dither(false); let bytes = encoded(&audio, &options, streaming); diff --git a/crates/audio/tests/audio/malformed.rs b/crates/audio/tests/audio/malformed.rs index 2486c232..c5deb532 100644 --- a/crates/audio/tests/audio/malformed.rs +++ b/crates/audio/tests/audio/malformed.rs @@ -62,6 +62,8 @@ fn every_truncation_is_handled() { small(EncodeFormat::Wav, BitDepth::Float32, 6), small(EncodeFormat::Aiff, BitDepth::Int24, 1), small(EncodeFormat::Aiff, BitDepth::Float32, 2), + small(EncodeFormat::Flac, BitDepth::Int16, 2), + small(EncodeFormat::Flac, BitDepth::Int24, 1), ]; for file in &files { for n in 0..file.len() { @@ -85,7 +87,9 @@ fn truncated_data_chunk_yields_partial_audio() { #[test] fn every_single_byte_corruption_is_handled() { - for file in [small(EncodeFormat::Wav, BitDepth::Int24, 2), small(EncodeFormat::Aiff, BitDepth::Int16, 2)] { + for file in + [small(EncodeFormat::Wav, BitDepth::Int24, 2), small(EncodeFormat::Aiff, BitDepth::Int16, 2), small(EncodeFormat::Flac, BitDepth::Int16, 1)] + { for i in 0..file.len().min(200) { for v in [0x00, 0xFF, 0x7F, 0x80] { let mut f = file.clone(); @@ -305,6 +309,29 @@ fn packets_longer_than_any_codec_uses_are_refused() { assert!(matches!(decode(&mp4(480, &alac(1 << 21), |_, a| a), None), Err(AudioError::Malformed(m)) if m.contains("declares packets"))); } +#[test] +fn flac_metadata_fields_beyond_their_block_are_refused() { + let with_block = |kind: u8, body: &[u8]| { + let f = small(EncodeFormat::Flac, BitDepth::Int16, 1); + let head = ((0x80 | u32::from(kind)) << 24 | body.len() as u32).to_be_bytes(); + [&f[..4], &[f[4] & 0x7f], &f[5..42], &head, body, &f[42..]].concat() + }; + let picture = |data: u32| [&[0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0][..], &[0; 16], &data.to_be_bytes(), &[1, 2, 3]].concat(); + assert_eq!(decode(&with_block(6, &picture(3)), None).unwrap().1.frames(), 300); + let comment = [&0u32.to_le_bytes()[..], &1u32.to_le_bytes(), &u32::MAX.to_le_bytes()].concat(); + for f in [with_block(6, &picture(0x7fff_fff0)), with_block(4, &comment)] { + assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than its block"))); + } +} + +#[test] +fn an_id3_tag_before_the_audio_is_skipped_unread() { + let frame = [&b"TIT2"[..], &0x7fff_ffffu32.to_be_bytes(), &[0, 0, 3, b'h', b'i', 0]].concat(); + let tag = [&b"ID3\x03\0\0\0\0\0"[..], &[frame.len() as u8], &frame].concat(); + let flac = small(EncodeFormat::Flac, BitDepth::Int16, 1); + assert_eq!(decode(&[tag, flac].concat(), None).unwrap().1.frames(), 300); +} + /// Container magic inside an MP3 frame payload must not change detection or make probing reject the stream. #[test] fn a_valid_mp3_with_incidental_container_magic_still_probes() { @@ -366,6 +393,40 @@ fn an_exhausted_audio_score_budget_does_not_reject_the_mp3_behind_it() { assert_eq!(decoded.frames, buf.frames() as u64); } +/// Past the score budget the preflight does not run, so if the probe then selects a container +/// whose reader allocates from sizes in the file, the post-probe refusal still applies. +#[test] +fn a_container_selected_without_its_size_check_is_refused() { + let flac = small(EncodeFormat::Flac, BitDepth::Int16, 1); + // Exactly the cap of false candidates, then one more that the reader rejects (invalid + // bit-rate), so the preflight stops unchecked before reaching the FLAC marker behind it. + let mut false_syncs: Vec = (0..256).flat_map(|_| [0xFFu8, 0xE2]).collect(); + false_syncs.extend_from_slice(&[0xFF, 0xFB, 0xF0, 0x00]); + let f = [false_syncs, flac].concat(); + assert_eq!(detect_container(&f, None), Some(Container::MpegAudio)); + assert!(matches!(probe(&f, None), Err(AudioError::Malformed(m)) if m.contains("was not size-checked"))); + assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("was not size-checked"))); +} + +/// A zero-frame FLAC after arbitrary bytes or ID3 tags is still detected and reported as an empty stream, not a +/// decode failure. +#[test] +fn zero_frame_flac_after_a_prefix_is_still_empty() { + let flac = + encode(&AudioBuffer::new(48_000, 1, 0), &EncodeOptions::new(EncodeFormat::Flac).with_bit_depth(BitDepth::Int16).with_dither(false)).unwrap(); + let tag = id3_tag(b"tag"); + for (f, detected) in [ + ([vec![0x7A; 64], flac.clone()].concat(), None), + ([tag.clone(), vec![0x7A; 16], flac.clone()].concat(), Some(Container::MpegAudio)), + ([tag.clone(), tag, flac.clone()].concat(), Some(Container::MpegAudio)), + ] { + assert_eq!(detect_container(&f, None), detected); + let info = probe(&f, None).expect("empty FLAC after a prefix"); + assert_eq!((info.container, info.sample_rate, info.channels, info.frames), (Container::Flac, 48_000, 1, 0)); + assert_eq!(decode(&f, None).unwrap().1.frames(), 0); + } +} + /// Build one MPEG-4 AAC-LC ADTS frame at 48 kHz stereo around `payload`, with the 13-bit length covering the 7-byte /// header. fn adts_frame(payload: &[u8]) -> Vec { @@ -378,6 +439,19 @@ fn adts_frame(payload: &[u8]) -> Vec { [&h[..], payload].concat() } +/// False MPEG sync prefixes must not stop a following zero-frame FLAC from probing as empty. +#[test] +fn zero_frame_flac_after_a_false_mpeg_sync_is_still_empty() { + let flac = + encode(&AudioBuffer::new(48_000, 1, 0), &EncodeOptions::new(EncodeFormat::Flac).with_bit_depth(BitDepth::Int16).with_dither(false)).unwrap(); + for junk in [vec![0xFF, 0xE0, 0x00], vec![0xFF, 0xF0, 0x00], vec![0xFF, 0xFB, 0xF0, 0x00]] { + let f = [junk, flac.clone()].concat(); + let info = probe(&f, None).expect("empty FLAC after a false sync"); + assert_eq!((info.container, info.sample_rate, info.channels, info.frames), (Container::Flac, 48_000, 1, 0)); + assert_eq!(decode(&f, None).unwrap().1.frames(), 0); + } +} + /// A false MPEG sync prefix must not let malformed CAF or MP4 size fields bypass the container preflight. #[test] fn a_false_sync_does_not_bypass_container_checks() { @@ -550,6 +624,7 @@ fn aiff_sowt_little_endian() { fn detect_container_by_magic_and_hint() { assert_eq!(detect_container(&small(EncodeFormat::Wav, BitDepth::Int16, 1), Some("mp3")), Some(Container::Wav)); assert_eq!(detect_container(&small(EncodeFormat::Aiff, BitDepth::Int16, 1), None), Some(Container::Aiff)); + assert_eq!(detect_container(&small(EncodeFormat::Flac, BitDepth::Int16, 1), None), Some(Container::Flac)); assert_eq!(detect_container(b"OggS\0\x02rest", None), Some(Container::Ogg)); assert_eq!(detect_container(b"caff\0\x01\0\0", None), Some(Container::Caf)); assert_eq!(detect_container(b"ID3\x04\0\0\0\0\0\x00", None), Some(Container::MpegAudio)); @@ -591,6 +666,20 @@ fn garbage_in_compressed_containers_errors() { assert!(decode(&[], None).is_err()); } +/// A marker that passes its container check must not let the probe select a different container +/// without its own checks: the reader is refused when the probed format disagrees. +#[test] +fn a_probed_container_that_disagrees_with_the_checked_marker_is_refused() { + // The junk byte hides both magics, so the hint picks the marker: the FLAC is checked, but the + // probe reads the WAVE before it. + let bytes = [vec![0], small(EncodeFormat::Wav, BitDepth::Int24, 2), small(EncodeFormat::Flac, BitDepth::Int24, 2)].concat(); + match decode(&bytes, Some("flac")) { + Err(AudioError::Malformed(message)) => assert!(message.contains("probed container"), "unexpected message: {message}"), + Err(other) => panic!("unexpected error kind: {other:?}"), + Ok(_) => panic!("the mismatched container must be refused"), + } +} + proptest! { #![proptest_config(ProptestConfig { cases: 256, ..ProptestConfig::default() })] @@ -614,6 +703,16 @@ proptest! { let _ = probe(&f, None); let _ = decode(&f, None); } + + #[test] + fn random_audio_roundtrips_through_flac(samples in proptest::collection::vec(-1.2f32..1.2, 1..3000), channels in 1usize..4) { + let buf = AudioBuffer::from_interleaved(48_000, channels as u16, &samples); + prop_assume!(buf.frames() > 0); + let opts = EncodeOptions::new(EncodeFormat::Flac).with_bit_depth(BitDepth::Int24).with_dither(false); + let (_, flac) = decode(&encode(&buf, &opts).unwrap(), None).unwrap(); + let (_, wav) = decode(&encode(&buf, &opts.clone().with_format(EncodeFormat::Wav)).unwrap(), None).unwrap(); + prop_assert_eq!(flac, wav); + } } /// An extensible `fmt ` chunk: cbSize, valid bits, mask, then the 16-byte subtype GUID. diff --git a/crates/audio/tests/audio/roundtrip.rs b/crates/audio/tests/audio/roundtrip.rs index 51198048..67db9880 100644 --- a/crates/audio/tests/audio/roundtrip.rs +++ b/crates/audio/tests/audio/roundtrip.rs @@ -70,6 +70,66 @@ fn wav_and_aiff_roundtrip_all_depths_and_layouts() { } } +#[test] +fn flac_roundtrip_is_lossless_vs_wav() { + for depth in [BitDepth::Int16, BitDepth::Int24, BitDepth::Int32] { + for channels in [1, 2, 6] { + // Spans several blocks plus a short final block. + let src = sine(44_100, channels, 4096 * 2 + 17); + let wav = encode(&src, &EncodeOptions::new(EncodeFormat::Wav).with_bit_depth(depth).with_dither(false)).unwrap(); + let flac = encode(&src, &EncodeOptions::new(EncodeFormat::Flac).with_bit_depth(depth).with_dither(false)).unwrap(); + assert_eq!(detect_container(&flac, None), Some(craft_audio::Container::Flac)); + let info = probe(&flac, None).unwrap(); + assert_eq!((info.container, info.codec), (craft_audio::Container::Flac, craft_audio::CodecId::Flac)); + assert_eq!((info.sample_rate, info.channels as usize, info.frames), (44_100, channels, src.frames() as u64)); + assert_eq!(info.sample_format, expected_sf(depth)); + let (_, from_wav) = decode(&wav, None).unwrap(); + let (finfo, from_flac) = decode(&flac, None).unwrap(); + assert_eq!(finfo.frames, src.frames() as u64); + assert_eq!(from_flac, from_wav, "{depth:?} {channels}ch"); + if depth != BitDepth::Int32 { + assert!(flac.len() < wav.len(), "{depth:?} {channels}ch: flac {} >= wav {}", flac.len(), wav.len()); + } + } + } +} + +#[test] +fn flac_handles_silence_noise_full_scale_and_odd_rates() { + let frames = 10_000; + let mut b = AudioBuffer::new(37_123, 3, frames); + let mut seed = 1u32; + for i in 0..frames { + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + b.channels[1][i] = (seed as f32 / u32::MAX as f32) * 2.0 - 1.0; // white noise + b.channels[2][i] = if i % 2 == 0 { 1.5 } else { -1.5 }; // clipped full-scale square + } + for depth in [BitDepth::Int16, BitDepth::Int24, BitDepth::Int32] { + for dither in [false, true] { + let opts = EncodeOptions::new(EncodeFormat::Flac).with_bit_depth(depth).with_dither(dither); + let flac = encode(&b, &opts).unwrap(); + let (info, out) = decode(&flac, None).unwrap(); + assert_eq!(info.sample_rate, 37_123); + let wav = encode(&b, &opts.clone().with_format(EncodeFormat::Wav)).unwrap(); + let (_, expect) = decode(&wav, None).unwrap(); + assert_eq!(out, expect, "{depth:?} dither={dither}"); + } + } +} + +#[test] +fn flac_rejects_unsupported_layouts() { + let opts = EncodeOptions::new(EncodeFormat::Flac).with_bit_depth(BitDepth::Float32).with_dither(false); + assert!(encode(&sine(48_000, 2, 10), &opts).is_err()); + let opts = opts.clone().with_bit_depth(BitDepth::Int16); + assert!(encode(&sine(48_000, 9, 10), &opts).is_err()); + assert!(encode(&sine(700_000, 1, 10), &opts).is_err()); + // Tiny buffers still encode and decode. + let one = sine(48_000, 2, 1); + let (_, out) = decode(&encode(&one, &opts).unwrap(), None).unwrap(); + assert_eq!(out.frames(), 1); +} + #[test] fn bwf_roundtrip() { let bwf = BwfInfo::default() @@ -80,8 +140,8 @@ fn bwf_roundtrip() { .with_time_reference(0x1_2345_6789); // > 32 bits let src = sine(96_000, 2, 1000); // RF64 is a WAV layout: other formats ignore it. - let aiff = encode(&src, &EncodeOptions::new(EncodeFormat::Aiff).with_force_rf64(true)).unwrap(); - assert_eq!(&aiff[..4], b"FORM"); + let flac = encode(&src, &EncodeOptions::new(EncodeFormat::Flac).with_force_rf64(true)).unwrap(); + assert_eq!(&flac[..4], b"fLaC"); for depth in DEPTHS { let opts = EncodeOptions::new(EncodeFormat::Wav).with_bit_depth(depth).with_dither(false).with_bwf(bwf.clone()); let bytes = encode(&src, &opts).unwrap(); @@ -149,8 +209,11 @@ fn encode_rejects_bad_buffers_and_formats() { // Non-finite samples are sanitized; only integer output clips finite samples. let mut b = AudioBuffer::new(48_000, 1, 4); b.channels[0] = vec![f32::NAN, f32::INFINITY, -5.0, 0.5]; - for format in [EncodeFormat::Wav, EncodeFormat::Aiff] { + for format in [EncodeFormat::Wav, EncodeFormat::Aiff, EncodeFormat::Flac] { for depth in DEPTHS { + if format == EncodeFormat::Flac && depth == BitDepth::Float32 { + continue; + } let bytes = encode(&b, &EncodeOptions::new(format).with_bit_depth(depth)).unwrap(); let (_, out) = decode(&bytes, None).unwrap(); assert_eq!(out.channels[0][0], 0.0); @@ -214,6 +277,10 @@ fn multichannel_wav_speaker_masks() { let fmt = bytes.windows(4).position(|w| w == b"fmt ").unwrap(); assert_eq!(bytes.get(fmt + 8..fmt + 10), Some(&[1u8, 0][..])); assert_eq!(probe(&bytes, None).unwrap().layout, ChannelLayout::STEREO); + // FLAC (decoded by symphonia) reports its channel assignment: six channels are 5.1. + let flac = o16.clone().with_format(EncodeFormat::Flac); + let info = probe(&craft_audio::encode(&buf(6), &flac).unwrap(), None).unwrap(); + assert_eq!((info.container, info.codec, info.layout), (craft_audio::Container::Flac, craft_audio::CodecId::Flac, ChannelLayout::SURROUND_5_1)); // A layout for another channel count is refused. assert!(craft_audio::encode(&buf(2), &o16.clone().with_layout(ChannelLayout::SURROUND_5_1)).is_err()); } @@ -234,6 +301,31 @@ fn float_wav_header_is_rewritten_in_place_as_the_file_grows() { assert!(wav_float_header(48_000, 0, 0).is_err()); } +#[test] +fn zero_frame_flac_round_trips_like_wav_and_aiff() { + for format in [EncodeFormat::Wav, EncodeFormat::Aiff, EncodeFormat::Flac] { + for depth in [BitDepth::Int16, BitDepth::Int24] { + for channels in [1, 2] { + let opts = EncodeOptions::new(format).with_bit_depth(depth); + let bytes = encode(&AudioBuffer::new(48_000, channels, 0), &opts).unwrap(); + let info = probe(&bytes, None).unwrap(); + assert_eq!((info.container, info.sample_format, info.sample_rate, info.frames), (format.container(), expected_sf(depth), 48_000, 0)); + assert_eq!(info.channels, channels); + let (info, buf) = decode(&bytes, None).unwrap(); + assert_eq!((info.frames, buf.frames(), buf.num_channels()), (0, 0, channels)); + } + } + } +} + +#[test] +fn flac_claiming_frames_but_holding_none_still_errors() { + let opts = EncodeOptions::new(EncodeFormat::Flac).with_bit_depth(BitDepth::Int16); + let mut bytes = encode(&AudioBuffer::new(48_000, 2, 0), &opts).unwrap(); + bytes[25] = 1; // STREAMINFO total samples (low byte of the 36-bit field) = 1 + assert!(decode(&bytes, None).is_err()); +} + #[test] fn float_wav_header_rejects_a_frame_count_whose_total_size_overflows() { // 4 bytes per frame: the data length fits in u64, but adding the header to it does not. diff --git a/crates/audio/tests/audio/stream.rs b/crates/audio/tests/audio/stream.rs index a948ccf7..b3271b40 100644 --- a/crates/audio/tests/audio/stream.rs +++ b/crates/audio/tests/audio/stream.rs @@ -33,6 +33,8 @@ fn streamed_files_match_whole_buffer_encodes() { (EncodeFormat::Wav, BitDepth::Int16, true, Some(bwf), Some(ChannelLayout::SURROUND_5_1_SIDE)), (EncodeFormat::Aiff, BitDepth::Int24, true, None, None), (EncodeFormat::Aiff, BitDepth::Float32, false, None, None), + (EncodeFormat::Flac, BitDepth::Int16, true, None, None), + (EncodeFormat::Flac, BitDepth::Int24, false, None, None), ]; for (format, bit_depth, dither, bwf, layout) in cases { let base = EncodeOptions::new(format).with_bit_depth(bit_depth).with_dither(dither); @@ -84,14 +86,14 @@ impl Seek for FailAfter { #[test] fn a_failed_write_poisons_the_encoder() { let buf = tone(2, 20_001); - for format in [EncodeFormat::Wav, EncodeFormat::Aiff] { + for format in [EncodeFormat::Wav, EncodeFormat::Aiff, EncodeFormat::Flac] { let opts = EncodeOptions::new(format).with_bit_depth(BitDepth::Int16).with_dither(false); let sink = FailAfter { inner: Cursor::new(Vec::new()), budget: 256 }; let mut enc = FileEncoder::new(sink, &opts, buf.sample_rate, 2).unwrap(); // The sink accepts a prefix of the first block, then fails. assert!(enc.write(&buf.channels, buf.frames()).is_err(), "{format:?}"); // Later calls must not claim success: the bytes on the sink no longer match the - // encoder's counters. + // encoder's counters (and for FLAC the failed frame's samples are already gone). assert!(matches!(enc.write(&buf.channels, 16), Err(AudioError::Encode(_))), "{format:?}"); assert!(matches!(enc.finish(), Err(AudioError::Encode(_))), "{format:?}"); } @@ -100,9 +102,7 @@ fn a_failed_write_poisons_the_encoder() { #[test] fn blocks_past_a_format_limit_are_refused_before_anything_is_written() { let buf = tone(2, 100); - // AIFF sizes are 32-bit: 4 GiB of 16-bit stereo. - { - let (format, limit) = (EncodeFormat::Aiff, 1u64 << 30); + for (format, limit) in [(EncodeFormat::Aiff, 1u64 << 30), (EncodeFormat::Flac, 1u64 << 36)] { let opts = EncodeOptions::new(format).with_bit_depth(BitDepth::Int16).with_dither(false); let mut enc = FileEncoder::new(Cursor::new(Vec::new()), &opts, buf.sample_rate, 2).unwrap(); enc.write(&buf.channels, 100).unwrap(); @@ -165,7 +165,7 @@ fn decoder_reads_any_read_seek_source_and_reports_source_failures() { fn seeking_to_or_past_the_end_ends_the_stream() { use craft_audio::{DecodeOptions, Decoder}; let buf = tone(2, 4800); - for format in [EncodeFormat::Wav, EncodeFormat::Aiff] { + for format in [EncodeFormat::Wav, EncodeFormat::Aiff, EncodeFormat::Flac] { let file = encode(&buf, &EncodeOptions::new(format).with_bit_depth(BitDepth::Int16)).unwrap(); for target in [4800, 4801, 1_000_000] { let mut dec = Decoder::from_bytes(&file, None, &DecodeOptions::default()).unwrap();