From 56d088bf04b0322f4f970be7b631368ec77bff39 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 11:48:37 -0500 Subject: [PATCH] craft-audio: flac::Encoder, the FLAC packet encoder, is public (#809) * craft-audio: FilmCraft's FLAC encoder and MP1/MP2 decoder speed-ups FLAC: per-order predictor kernels, merged Rice partition sums, the LPC window kept per block length, and batches consumed by offset (filmcraft 5eaff794, d9dad4a5). The output is unchanged: 64 golden cases hash the same before and after. MP1/MP2: the faster synthesis filterbank (filmcraft 51c755e5), with its bit-exact test. * craft-audio: FilmCraft's AAC and Opus decoder speed-ups and refactors AAC: table-driven Huffman decoding, per-stage FFT twiddles and fused stages, reused per-channel work buffers, and the encoder's memoised per-band work (filmcraft f9627280, d587ceed, 6121635c, dc124ab7, 7a532eca). Opus: radix-2/3/4 FFT kernels in the CELT IMDCT, the PVQ table built once, one de-emphasis loop for both channels, the SILK resampler without per-sample divisions and with eight partial sums, concealment and FEC capped at 120 ms (filmcraft bf7ac420, 12e32026, c4dc64d1, eb352ec8, 87b8e22d, 014b5e56, 78d77b72). The per-frame mid-only flag this crate fixed in the SILK decoder is kept in the refactored decode. * craft-media: Ogg CRC-32 by slice-by-16 FilmCraft's 6763f9bf: sixteen independent lookups per 16 input bytes, so opening a file is several times faster. Checked against a bit-at-a-time reference on every short length and alignment, and across every split. * craft-audio: SoundCraft's CAF cookie-size and FLAC vendor-length checks A CAF magic cookie size above the signed range is refused instead of passed to symphonia (soundcraft 3bd3965), and a Vorbis comment block whose vendor string claims more than the block holds is refused (soundcraft ddfe991). Each with its regression test. * craft-audio: AC-3 reads bits through the shared BitReader FilmCraft's 1c9c2e82: the AC-3 decoder's private bit-at-a-time reader is replaced by craft-media's BitReader, with its two tests: the decoded output of 2,000 synthetic and 12 real syncframes (and their damaged variants) hashes the same as before, and the reader agrees with the old one on random reads, skips and overruns. The three AC-3 features now turn on craft-media's bitstream module. * craft-audio: the quantiser rounds without a libm call SoundCraft's d293336, the audio-io part: round-half-away by integer arithmetic in the bounce's inner loop, with its test against f64::round over the quantiser's range. * craft-audio: flac::Encoder, the FLAC packet encoder, is public FilmCraft muxes FLAC into its own outputs from integer samples, so it needs the encoder below FileEncoder: planar samples in, frames out, and the STREAMINFO to patch in at the end. The module is behind flac-encode, its errors convert to AudioError::Encode, and pending_samples says how long the final frame will be. --- crates/audio/README.md | 2 +- crates/audio/src/lib.rs | 33 +++++++++++++++++++++++++- crates/audio/src/write/flac/encoder.rs | 24 ++++++++++++++++++- crates/audio/src/write/flac/mod.rs | 4 ++-- crates/audio/src/write/mod.rs | 2 +- 5 files changed, 59 insertions(+), 6 deletions(-) diff --git a/crates/audio/README.md b/crates/audio/README.md index e05ae3e7c..3f321821d 100644 --- a/crates/audio/README.md +++ b/crates/audio/README.md @@ -31,7 +31,7 @@ No default features. The shared types (`AudioBuffer`, `AudioInfo`, `ChannelLayou | `encode` | every encoder: the WAV, AIFF and FLAC file writers, and the AAC, ALAC and AC-3/E-AC-3 packet encoders | `lossless-encode`, `aac-encode`, `alac-encode`, `ac3-encode`, `eac3-encode` | | `lossless-encode` | WAV, AIFF and FLAC writing | `pcm-encode`, `flac-encode` | | `pcm-encode` | `encode`, `FileEncoder` for WAV and AIFF; `wav_float_header` | none | -| `flac-encode` | `encode`, `FileEncoder` for FLAC | craft-media `bitstream` | +| `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 | | `alac-encode` | `alac::Encoder` | none | diff --git a/crates/audio/src/lib.rs b/crates/audio/src/lib.rs index 4285ca105..cac8f0291 100644 --- a/crates/audio/src/lib.rs +++ b/crates/audio/src/lib.rs @@ -22,7 +22,7 @@ //! - [`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`] -//! (`ac3-decode`), [`mpa`] (`mpa-decode`). +//! (`ac3-decode`), [`mpa`] (`mpa-decode`), [`flac`] (`flac-encode`: the packet encoder). //! //! The shared types below build with no features. @@ -51,6 +51,30 @@ mod wav; #[cfg(any(feature = "pcm-encode", feature = "flac-encode"))] mod write; +/// The FLAC packet encoder (RFC 9639), for a caller that muxes FLAC itself (into MP4, Matroska or +/// its own `.flac` writer): planar integer samples in, frames out, and the STREAMINFO to patch in +/// at the end. [`FileEncoder`] and [`encode`] write whole `.flac` files from `f32` instead. +/// +/// ``` +/// use craft_audio::flac::{Encoder, EncoderConfig, STREAMINFO_OFFSET}; +/// +/// let left: Vec = (0..48_000).map(|i| ((i as f64 * 0.05).sin() * 20_000.0) as i32).collect(); +/// let right = left.clone(); +/// let mut enc = Encoder::new(EncoderConfig::new(48_000, 2, 16))?; +/// let mut file = enc.header(&[]); +/// file.extend(enc.encode(&[&left, &right])?); +/// file.extend(enc.finish()); +/// let at = STREAMINFO_OFFSET as usize; +/// file[at..at + 34].copy_from_slice(&enc.streaminfo()); +/// assert_eq!(&file[..4], b"fLaC"); +/// assert!(file.len() < 48_000 * 2 * 2 / 2); // well under half of 16-bit PCM +/// # Ok::<(), craft_audio::flac::Error>(()) +/// ``` +#[cfg(feature = "flac-encode")] +pub mod flac { + pub use crate::write::flac::encoder::{Encoder, EncoderConfig, Error, STREAMINFO_OFFSET}; +} + pub use buffer::AudioBuffer; /// The craft-media this crate is built with, for its containers: using it rather than a separate /// dependency keeps the types the two crates share identical. @@ -135,6 +159,13 @@ impl From for AudioError { } } +#[cfg(feature = "flac-encode")] +impl From for AudioError { + fn from(e: flac::Error) -> Self { + AudioError::Encode(format!("FLAC: {e}")) + } +} + #[cfg(feature = "mpa-decode")] impl From for AudioError { fn from(e: mpa::Error) -> Self { diff --git a/crates/audio/src/write/flac/encoder.rs b/crates/audio/src/write/flac/encoder.rs index 02a22a0c0..a7a453d20 100644 --- a/crates/audio/src/write/flac/encoder.rs +++ b/crates/audio/src/write/flac/encoder.rs @@ -13,18 +13,30 @@ use super::predict::{Effort, Subframe}; /// Byte offset of the 34-byte STREAMINFO body in [`Encoder::header`]. pub const STREAMINFO_OFFSET: u64 = 8; +/// Why the encoder refused a configuration or a block of samples. +#[non_exhaustive] #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum Error { + /// The configuration is outside what FLAC can code; the text names the field. #[error("invalid configuration: {0}")] InvalidConfig(&'static str), + /// A block had a different number of channels than the configuration. #[error("expected {expected} channels, got {got}")] - Channels { expected: usize, got: usize }, + Channels { + /// The configured channel count. + expected: usize, + /// The channels in the block. + got: usize, + }, + /// The channels of a block differ in length. #[error("channels of unequal length")] UnequalChannels, + /// A sample is outside the configured sample size. #[error("sample {0} does not fit the configured sample size")] SampleRange(i32), } +/// What to encode and how hard to try. #[derive(Clone, Debug)] pub struct EncoderConfig { /// 1 to 1 048 575 Hz. @@ -41,11 +53,14 @@ pub struct EncoderConfig { } impl EncoderConfig { + /// 4096-sample blocks at level 5. 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 } } } +/// The packet encoder: [`Encoder::header`], then frames from [`Encoder::encode`] and +/// [`Encoder::finish`], then [`Encoder::streaminfo`] over [`STREAMINFO_OFFSET`]. pub struct Encoder { cfg: EncoderConfig, effort: Effort, @@ -61,6 +76,7 @@ pub struct Encoder { } impl Encoder { + /// An encoder for `cfg`, or [`Error::InvalidConfig`] when FLAC cannot code it. pub fn new(cfg: EncoderConfig) -> Result { if !(1..=8).contains(&cfg.channels) { return Err(Error::InvalidConfig("1 to 8 channels")); @@ -174,6 +190,12 @@ impl Encoder { Ok(out) } + /// Samples per channel waiting for a full block: the length of the frame [`Encoder::finish`] + /// would write. + pub fn pending_samples(&self) -> usize { + self.pending.first().map_or(0, Vec::len) + } + /// 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) { diff --git a/crates/audio/src/write/flac/mod.rs b/crates/audio/src/write/flac/mod.rs index f2674d427..6f562e6e2 100644 --- a/crates/audio/src/write/flac/mod.rs +++ b/crates/audio/src/write/flac/mod.rs @@ -2,7 +2,7 @@ //! (linear prediction, stereo decorrelation, STREAMINFO MD5) into any `Write + Seek`. mod crc; -mod encoder; +pub(crate) mod encoder; mod frame; mod md5; mod predict; @@ -49,7 +49,7 @@ fn mask_comment(channels: usize, mask: Option) -> Option { } fn encoder_error(e: encoder::Error) -> AudioError { - AudioError::Encode(format!("FLAC: {e}")) + e.into() } /// A FLAC stream written block by block; [`Writer::finish`] fills in the STREAMINFO sizes, sample diff --git a/crates/audio/src/write/mod.rs b/crates/audio/src/write/mod.rs index eaad8e961..25c9b2528 100644 --- a/crates/audio/src/write/mod.rs +++ b/crates/audio/src/write/mod.rs @@ -4,7 +4,7 @@ #[cfg(feature = "pcm-encode")] mod aiff; #[cfg(feature = "flac-encode")] -mod flac; +pub(crate) mod flac; mod pcm; #[cfg(feature = "pcm-encode")] mod wav;