Squashed from work; the fine-grained history is under tag archive/work-2026-08-26: - audio: a FLAC decoder from the specification, beside the MP3 and Vorbis ones
274 lines
9.4 KiB
Rust
274 lines
9.4 KiB
Rust
//! FLAC: a no-panic sweep over mangled bytes, and — when `afconvert` is on
|
|
//! PATH — a sample-exact comparison against a FLAC it produced from a
|
|
//! synthetic WAV.
|
|
//!
|
|
//! ```text
|
|
//! afconvert -f flac -d flac in.wav out.flac
|
|
//! afconvert -f WAVE -d LEI16 in.flac out.wav
|
|
//! ```
|
|
|
|
use makepad_audio_decode::{decode_any, flac, AudioError, AudioFormat, Limits};
|
|
use std::path::PathBuf;
|
|
use std::process::Command;
|
|
|
|
// -- afconvert oracle ------------------------------------------------------
|
|
|
|
fn afconvert_ok() -> bool {
|
|
Command::new("afconvert").arg("-help").output().is_ok()
|
|
}
|
|
|
|
fn write_wav_i16(path: &std::path::Path, rate: u32, channels: u16, pcm: &[i16]) {
|
|
let data_bytes = pcm.len() * 2;
|
|
let mut w = Vec::with_capacity(44 + data_bytes);
|
|
w.extend_from_slice(b"RIFF");
|
|
w.extend_from_slice(&(36u32 + data_bytes as u32).to_le_bytes());
|
|
w.extend_from_slice(b"WAVE");
|
|
w.extend_from_slice(b"fmt ");
|
|
w.extend_from_slice(&16u32.to_le_bytes());
|
|
w.extend_from_slice(&1u16.to_le_bytes());
|
|
w.extend_from_slice(&channels.to_le_bytes());
|
|
w.extend_from_slice(&rate.to_le_bytes());
|
|
let byte_rate = rate * channels as u32 * 2;
|
|
w.extend_from_slice(&byte_rate.to_le_bytes());
|
|
w.extend_from_slice(&(channels * 2).to_le_bytes());
|
|
w.extend_from_slice(&16u16.to_le_bytes());
|
|
w.extend_from_slice(b"data");
|
|
w.extend_from_slice(&(data_bytes as u32).to_le_bytes());
|
|
for s in pcm {
|
|
w.extend_from_slice(&s.to_le_bytes());
|
|
}
|
|
std::fs::write(path, w).expect("write wav");
|
|
}
|
|
|
|
/// Two seconds of stereo 44.1 kHz: inharmonic sines plus a short burst, so a
|
|
/// real encoder will emit FIXED/LPC/Rice rather than a CONSTANT stream.
|
|
fn test_signal() -> Vec<i16> {
|
|
let rate = 44_100.0;
|
|
let n = 44_100 * 2;
|
|
let mut pcm = Vec::with_capacity(n * 2);
|
|
for i in 0..n {
|
|
let t = i as f64 / rate;
|
|
let left = (2.0 * std::f64::consts::PI * 440.0 * t).sin() * 0.45
|
|
+ (2.0 * std::f64::consts::PI * 880.0 * t).sin() * 0.12;
|
|
let right = (2.0 * std::f64::consts::PI * 659.25 * t).sin() * 0.40
|
|
+ (2.0 * std::f64::consts::PI * 220.0 * t).sin() * 0.18;
|
|
let burst = if (i as i32 - 20_000).unsigned_abs() < 64 { 0.2 } else { 0.0 };
|
|
pcm.push((left * 30_000.0) as i16);
|
|
pcm.push(((right + burst) * 30_000.0) as i16);
|
|
}
|
|
pcm
|
|
}
|
|
|
|
fn oracle_dir() -> PathBuf {
|
|
let base = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("target");
|
|
let dir = base.join("flac-oracle");
|
|
let _ = std::fs::create_dir_all(&dir);
|
|
dir
|
|
}
|
|
|
|
#[test]
|
|
fn oracle_matches_afconvert_sample_exact() {
|
|
if !afconvert_ok() {
|
|
eprintln!("afconvert not available; skipping the FLAC oracle comparison");
|
|
return;
|
|
}
|
|
let dir = oracle_dir();
|
|
let wav_path = dir.join("signal.wav");
|
|
let flac_path = dir.join("signal.flac");
|
|
let pcm = test_signal();
|
|
write_wav_i16(&wav_path, 44_100, 2, &pcm);
|
|
// afconvert refuses to replace an existing destination.
|
|
let _ = std::fs::remove_file(&flac_path);
|
|
|
|
let status = Command::new("afconvert")
|
|
.args(["-f", "flac", "-d", "flac"])
|
|
.arg(&wav_path)
|
|
.arg(&flac_path)
|
|
.status()
|
|
.expect("spawn afconvert");
|
|
assert!(status.success(), "afconvert -f flac failed: {status}");
|
|
let bytes = std::fs::read(&flac_path).expect("read flac");
|
|
assert_eq!(makepad_audio_decode::sniff(&bytes), Some(AudioFormat::Flac));
|
|
|
|
let audio = flac::decode_all(&bytes).expect("decode flac");
|
|
assert_eq!(audio.rate, 44_100);
|
|
assert_eq!(audio.channels, 2);
|
|
assert_eq!(audio.frames(), pcm.len() / 2, "frame count");
|
|
|
|
let mut mismatches = 0usize;
|
|
let mut first = None;
|
|
for (i, (&want, &got_f)) in pcm.iter().zip(audio.pcm_interleaved_f32.iter()).enumerate() {
|
|
let got = (got_f * 32768.0).round() as i32;
|
|
if got != want as i32 {
|
|
mismatches += 1;
|
|
if first.is_none() {
|
|
first = Some((i, want, got, got_f));
|
|
}
|
|
}
|
|
}
|
|
if mismatches > 0 {
|
|
panic!(
|
|
"{mismatches} sample mismatches; first at {:?} of {}",
|
|
first,
|
|
pcm.len()
|
|
);
|
|
}
|
|
|
|
let probed = flac::probe_duration(&bytes).unwrap();
|
|
assert!((probed - audio.duration_secs()).abs() < 1e-9, "{probed} vs {}", audio.duration_secs());
|
|
assert_eq!(decode_any(&bytes).unwrap(), audio);
|
|
|
|
let mut stream = flac::FlacDecoder::new(&bytes).unwrap();
|
|
let mut pieces = Vec::new();
|
|
let mut block_sizes = std::collections::BTreeSet::new();
|
|
while let Some(frame) = stream.next_frame().unwrap() {
|
|
block_sizes.insert(frame.pcm.len() / frame.channels as usize);
|
|
pieces.extend_from_slice(frame.pcm);
|
|
}
|
|
assert_eq!(pieces, audio.pcm_interleaved_f32);
|
|
assert!(block_sizes.len() > 1, "oracle stream should include a short final block");
|
|
|
|
let err = flac::decode_all_limited(&bytes, Limits::with_max_frames(100));
|
|
assert!(matches!(err, Err(AudioError::TooLarge(_))), "{err:?}");
|
|
}
|
|
|
|
// -- totality: truncation and bit flips ------------------------------------
|
|
|
|
struct Lcg(u64);
|
|
|
|
impl Lcg {
|
|
fn next(&mut self) -> u64 {
|
|
self.0 = self.0.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
|
|
self.0 >> 16
|
|
}
|
|
}
|
|
|
|
fn exercise(bytes: &[u8]) {
|
|
if let Ok(audio) = flac::decode_all(bytes) {
|
|
assert!(audio.pcm_interleaved_f32.iter().all(|v| v.is_finite()));
|
|
assert!(audio.channels > 0 && audio.rate > 0);
|
|
}
|
|
let _ = flac::probe_duration(bytes);
|
|
let _ = flac::read_tags(bytes);
|
|
let _ = decode_any(bytes);
|
|
if let Ok(mut d) = flac::FlacDecoder::new(bytes) {
|
|
let mut guard = 0;
|
|
while let Ok(Some(frame)) = d.next_frame() {
|
|
assert!(frame.pcm.iter().all(|v| v.is_finite()));
|
|
guard += 1;
|
|
assert!(guard < 100_000, "frame loop did not terminate");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Tiny valid FLAC (CONSTANT, 8 samples mono 16-bit) so the hostile sweep has
|
|
/// a real bitstream to mangle, even when afconvert is absent.
|
|
fn tiny_flac() -> Vec<u8> {
|
|
// Built by the in-crate encoder shape: fLaC + STREAMINFO + one CONSTANT
|
|
// frame of silence. CRC-8/16 are computed the same way as the decoder.
|
|
fn crc8(data: &[u8]) -> u8 {
|
|
let mut crc = 0u8;
|
|
for &b in data {
|
|
crc ^= b;
|
|
for _ in 0..8 {
|
|
crc = if crc & 0x80 != 0 { (crc << 1) ^ 0x07 } else { crc << 1 };
|
|
}
|
|
}
|
|
crc
|
|
}
|
|
fn crc16(data: &[u8]) -> u16 {
|
|
let mut crc = 0u16;
|
|
for &b in data {
|
|
crc ^= (b as u16) << 8;
|
|
for _ in 0..8 {
|
|
crc = if crc & 0x8000 != 0 { (crc << 1) ^ 0x8005 } else { crc << 1 };
|
|
}
|
|
}
|
|
crc
|
|
}
|
|
let mut file = b"fLaC".to_vec();
|
|
let mut info = [0u8; 34];
|
|
info[0..2].copy_from_slice(&8u16.to_be_bytes());
|
|
info[2..4].copy_from_slice(&8u16.to_be_bytes());
|
|
let mut w = 0u64;
|
|
w |= 8_000u64 << 44;
|
|
w |= 0u64 << 41; // 1 channel
|
|
w |= 15u64 << 36; // 16 bps
|
|
w |= 8; // 8 samples
|
|
info[10..18].copy_from_slice(&w.to_be_bytes());
|
|
// MD5 left at zero: "not calculated". The hostile sweep only needs a
|
|
// structurally valid file; STREAMINFO MD5 is covered by the crate tests.
|
|
file.push(0x80); // last, STREAMINFO
|
|
file.push(0);
|
|
file.push(0);
|
|
file.push(34);
|
|
file.extend_from_slice(&info);
|
|
|
|
let mut frame = vec![0xFF, 0xF8];
|
|
// blocksize 8 = 256*2^(n-8) is 256 for n=8; use uncommon 8-bit: 0110, (8-1).
|
|
frame.push(0x60);
|
|
frame.push(0x00); // mono, bps from STREAMINFO
|
|
frame.push(0x00); // frame number 0
|
|
frame.push(7); // blocksize-1
|
|
let c = crc8(&frame);
|
|
frame.push(c);
|
|
// CONSTANT 0, 16-bit, 8 samples: padding+type+wasted + 16-bit sample, then pad.
|
|
// 1+6+1+16 = 24 bits → 3 bytes: 0x00, 0x00, 0x00
|
|
frame.extend_from_slice(&[0x00, 0x00, 0x00]);
|
|
let fc = crc16(&frame);
|
|
frame.extend_from_slice(&fc.to_be_bytes());
|
|
file.extend_from_slice(&frame);
|
|
file
|
|
}
|
|
|
|
#[test]
|
|
fn tiny_fixture_decodes() {
|
|
let bytes = tiny_flac();
|
|
let audio = flac::decode_all(&bytes).expect("tiny flac");
|
|
assert_eq!(audio.rate, 8_000);
|
|
assert_eq!(audio.channels, 1);
|
|
assert_eq!(audio.frames(), 8);
|
|
assert!(audio.pcm_interleaved_f32.iter().all(|&v| v == 0.0));
|
|
assert_eq!(makepad_audio_decode::sniff(&bytes), Some(AudioFormat::Flac));
|
|
}
|
|
|
|
#[test]
|
|
fn truncation_never_panics() {
|
|
let fixture = tiny_flac();
|
|
for cut in 0..fixture.len() {
|
|
assert!(flac::decode_all(&fixture[..cut]).is_err(), "prefix {cut} decoded successfully");
|
|
exercise(&fixture[..cut]);
|
|
}
|
|
for cut in [0usize, 1, 2, 3, 4, 5, 8, 10, 20, 38, 42, 50] {
|
|
exercise(&fixture[..cut.min(fixture.len())]);
|
|
}
|
|
exercise(&[]);
|
|
exercise(&[0xFF; 64]);
|
|
exercise(b"fLaC\xff\xff\xff\xff");
|
|
exercise(b"not a flac at all, really quite long garbage padding");
|
|
}
|
|
|
|
#[test]
|
|
fn flipped_bytes_never_panic() {
|
|
let fixture = tiny_flac();
|
|
let mut rng = Lcg(0x5EED_1234_ABCD_0001);
|
|
for _ in 0..200 {
|
|
let mut bytes = fixture.clone();
|
|
let flips = 1 + (rng.next() % 4) as usize;
|
|
for _ in 0..flips {
|
|
let at = (rng.next() as usize) % bytes.len();
|
|
bytes[at] ^= (rng.next() % 255 + 1) as u8;
|
|
}
|
|
exercise(&bytes);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn random_garbage_never_panics() {
|
|
let mut rng = Lcg(0xC0FF_EE00_1234_5678);
|
|
for _ in 0..50 {
|
|
let n = 1 + (rng.next() % 4096) as usize;
|
|
let bytes: Vec<u8> = (0..n).map(|_| rng.next() as u8).collect();
|
|
exercise(&bytes);
|
|
}
|
|
}
|