makepad/libs/audio_decode/tests/vorbis_oracle.rs
Admin 016a171a35 libs/audio_*: MP3, Vorbis and Ogg of our own, plus lyric alignment and audio imaging
Another dependency the app should not be asking the platform for:

  - audio_decode — MP3 (layer 3, LSF tables, synthesis) and Ogg Vorbis
    (codebooks, floor, residue, MDCT) decoders, with tag reading. Both are
    checked against oracle fixtures rather than against our own expectations.
  - audio_encode — an Ogg Vorbis encoder: MDCT, psychoacoustics, floor and
    Huffman coding, setup tables, plus `oggenc` and `audiobench` binaries.
  - audio_picture — waveform and spectrogram rendering, and compositing.
  - audio_lyrics — word-level lyric alignment (DTW plus a DP snap) and the
    baked schema behind karaoke timing.
  - audio_sidechannels — the side-channel plumbing between them.

libs/voice grows a CUDA backend and an alignment path beside its CPU decoder,
with a `whisper_parity` binary to keep the two honest.
2026-08-23 01:34:35 +02:00

488 lines
18 KiB
Rust

//! Vorbis: a tiny committed fixture, a no-panic sweep over mangled copies of
//! it, and — behind an environment variable — a full comparison against
//! CoreAudio's decoder.
//!
//! The fixture tests are self-contained and fast: they run anywhere, with no
//! `local/` checkout and no macOS. The oracle test needs reference WAVs that
//! `afconvert` produced from the same Ogg files, so it stays opt-in:
//!
//! ```text
//! afconvert -f WAVE -d LEF32 in.ogg $DIR/in.wav
//! MAKEPAD_VORBIS_ORACLE=$DIR cargo test -p makepad-audio-decode --release \
//! --test vorbis_oracle -- --nocapture oracle
//! ```
use makepad_audio_decode::{decode_any, vorbis, AudioError, AudioFormat};
const FIXTURE: &[u8] = include_bytes!("../testdata/button-press.ogg");
// -- the committed fixture -------------------------------------------------
#[test]
fn fixture_decodes_to_the_expected_audio() {
let audio = vorbis::decode_all(FIXTURE).unwrap();
assert_eq!(audio.rate, 48_000);
assert_eq!(audio.channels, 1);
// Exactly the granule position of the last page, which is also the frame
// count CoreAudio reports for this file ("13347 valid frames").
assert_eq!(audio.frames(), 13_347);
// Level and shape, measured against the CoreAudio decode of the same file.
// afconvert writes its WAV 128 frames in (CoreAudio trims that much as
// priming), and dropping those 128 frames reproduces its RMS to six digits
// and its peak exactly.
let pcm = &audio.pcm_interleaved_f32;
let rms = |s: &[f32]| (s.iter().map(|&v| (v * v) as f64).sum::<f64>() / s.len() as f64).sqrt();
assert!((rms(pcm) - 0.007_074).abs() < 1e-5, "rms {}", rms(pcm));
assert!((rms(&pcm[128..]) - 0.007_107).abs() < 1e-5, "trimmed rms {}", rms(&pcm[128..]));
let peak = pcm.iter().fold(0.0f32, |a, &b| a.max(b.abs()));
assert!((peak - 0.118_989).abs() < 1e-4, "peak {peak}");
// A button click: quiet, then a burst a third of the way in, then decay.
let eighth = pcm.len() / 8;
let loudest = (0..8).max_by(|&a, &b| {
rms(&pcm[a * eighth..(a + 1) * eighth])
.partial_cmp(&rms(&pcm[b * eighth..(b + 1) * eighth]))
.unwrap()
});
assert_eq!(loudest, Some(3));
assert!(rms(&pcm[..eighth]) < 0.002);
assert!(rms(&pcm[7 * eighth..]) < 0.002);
}
#[test]
fn fixture_probe_matches_the_decode() {
let secs = vorbis::probe_duration(FIXTURE).unwrap();
let audio = vorbis::decode_all(FIXTURE).unwrap();
assert!((secs - audio.duration_secs()).abs() < 1e-9, "{secs} vs {}", audio.duration_secs());
// 13347 frames at 48 kHz, and afinfo agrees to six digits.
assert!((secs - 0.278_062_5).abs() < 1e-6, "{secs}");
}
#[test]
fn probe_does_not_decode_a_hundred_megabytes() {
// The fixture's pages repeated to 100 MB: a duration probe must read the
// first page and the last one, not the 100 MB in between.
let mut big: Vec<u8> = Vec::with_capacity(100 << 20);
while big.len() < 100 << 20 {
big.extend_from_slice(FIXTURE);
}
let started = std::time::Instant::now();
let secs = vorbis::probe_duration(&big).unwrap();
let elapsed = started.elapsed();
assert!((secs - 0.278_062_5).abs() < 1e-6, "{secs}");
assert!(elapsed.as_millis() < 500, "probe took {elapsed:?} on 100 MB");
eprintln!("probe of 100 MB: {elapsed:?}");
}
#[test]
fn fixture_sniffs_and_decodes_through_the_crate_entry_points() {
assert_eq!(makepad_audio_decode::sniff(FIXTURE), Some(AudioFormat::OggVorbis));
let a = decode_any(FIXTURE).unwrap();
let b = vorbis::decode_all(FIXTURE).unwrap();
assert_eq!(a, b);
assert!((makepad_audio_decode::probe_duration(FIXTURE).unwrap() - 0.278_062_5).abs() < 1e-6);
}
#[test]
fn fixture_tags_are_readable() {
let tags = vorbis::read_tags(FIXTURE).unwrap();
// This file carries only an encoder string, but the header must parse.
assert!(tags.all.len() < 8);
assert_eq!(tags, makepad_audio_decode::read_tags(FIXTURE).unwrap());
}
#[test]
fn streaming_blocks_concatenate_to_the_whole_decode() {
let whole = vorbis::decode_all(FIXTURE).unwrap();
let mut decoder = vorbis::VorbisDecoder::new(FIXTURE).unwrap();
assert_eq!(decoder.rate(), 48_000);
assert_eq!(decoder.channels(), 1);
let mut pieces = Vec::new();
let mut blocks = 0usize;
while let Some(block) = decoder.next_block().unwrap() {
assert!(!block.is_empty());
pieces.extend_from_slice(block);
blocks += 1;
}
assert!(blocks > 4, "expected several blocks, got {blocks}");
assert_eq!(pieces, whole.pcm_interleaved_f32);
}
#[test]
fn limits_are_enforced() {
use makepad_audio_decode::Limits;
let err = vorbis::decode_all_limited(FIXTURE, Limits::with_max_frames(100));
assert!(matches!(err, Err(AudioError::TooLarge(_))), "{err:?}");
let tight = Limits { max_channels: 0, ..Limits::default() };
assert!(matches!(
vorbis::decode_all_limited(FIXTURE, tight),
Err(AudioError::TooLarge(_))
));
// A limit that fits decodes as usual.
assert!(vorbis::decode_all_limited(FIXTURE, Limits::with_max_frames(20_000)).is_ok());
}
// -- totality: truncation and bit flips ------------------------------------
/// Tiny LCG, so the mangling is reproducible without a dependency.
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
}
}
/// How many mangled copies each fuzz test tries. The default keeps the suite
/// under a second; `MAKEPAD_VORBIS_FUZZ=20000` is the sweep to run when the
/// decoder changes.
fn fuzz_iterations() -> usize {
std::env::var("MAKEPAD_VORBIS_FUZZ").ok().and_then(|v| v.parse().ok()).unwrap_or(200)
}
fn exercise(bytes: &[u8]) {
// Whatever comes back, it must be a value or an error, never a panic, and
// any samples must be finite.
if let Ok(audio) = vorbis::decode_all(bytes) {
assert!(audio.pcm_interleaved_f32.iter().all(|v| v.is_finite()));
assert!(audio.channels > 0 && audio.rate > 0);
}
let _ = vorbis::probe_duration(bytes);
let _ = vorbis::read_tags(bytes);
if let Ok(mut d) = vorbis::VorbisDecoder::new(bytes) {
let mut guard = 0;
while let Ok(Some(block)) = d.next_block() {
assert!(block.iter().all(|v| v.is_finite()));
guard += 1;
assert!(guard < 100_000, "block loop did not terminate");
}
}
}
#[test]
fn truncation_at_every_sixteenth_never_panics() {
for i in 0..=16 {
let cut = FIXTURE.len() * i / 16;
exercise(&FIXTURE[..cut]);
}
// And a few odd byte counts around the header boundaries.
for cut in [1usize, 2, 27, 28, 29, 30, 57, 58, 100, 2000] {
exercise(&FIXTURE[..cut.min(FIXTURE.len())]);
}
}
#[test]
fn flipped_bytes_never_panic() {
let mut rng = Lcg(0x5EED_1234_ABCD_0001);
for _ in 0..fuzz_iterations() {
let mut bytes = FIXTURE.to_vec();
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);
}
}
/// Ogg's CRC, so a mangled fixture can be handed to the decoder with valid page
/// checksums. Without this the page reader rejects every flipped page and the
/// codec itself never sees corrupt data.
fn ogg_crc(bytes: &[u8]) -> u32 {
let mut table = [0u32; 256];
for (i, slot) in table.iter_mut().enumerate() {
let mut r = (i as u32) << 24;
for _ in 0..8 {
r = if r & 0x8000_0000 != 0 { (r << 1) ^ 0x04c1_1db7 } else { r << 1 };
}
*slot = r;
}
let mut crc = 0u32;
for &b in bytes {
crc = (crc << 8) ^ table[(((crc >> 24) as u8) ^ b) as usize];
}
crc
}
/// Recompute every page checksum in a (possibly mangled) stream.
fn repair_checksums(bytes: &mut [u8]) {
let mut at = 0usize;
while at + 27 <= bytes.len() {
if &bytes[at..at + 4] != b"OggS" {
at += 1;
continue;
}
let n_segments = bytes[at + 26] as usize;
let body = at + 27 + n_segments;
if body > bytes.len() {
break;
}
let body_len: usize = bytes[at + 27..body].iter().map(|&b| b as usize).sum();
let Some(end) = body.checked_add(body_len).filter(|&e| e <= bytes.len()) else {
break;
};
bytes[at + 22..at + 26].fill(0);
let crc = ogg_crc(&bytes[at..end]);
bytes[at + 22..at + 26].copy_from_slice(&crc.to_le_bytes());
at = end;
}
}
#[test]
fn flipped_bytes_behind_valid_checksums_never_panic() {
// The interesting fuzz: the container still checks out, so the mangled
// bytes reach the codebooks, the floor and the residue.
let mut rng = Lcg(0xFACE_B00C_0000_0001);
for _ in 0..fuzz_iterations() {
let mut bytes = FIXTURE.to_vec();
let flips = 1 + (rng.next() % 4) as usize;
for _ in 0..flips {
// Past the page header of the first page, so the stream stays
// findable.
let at = 58 + (rng.next() as usize) % (bytes.len() - 58);
bytes[at] ^= (rng.next() % 255 + 1) as u8;
}
repair_checksums(&mut bytes);
exercise(&bytes);
}
}
#[test]
fn truncation_behind_valid_checksums_never_panics() {
for i in 0..=64 {
let cut = FIXTURE.len() * i / 64;
let mut bytes = FIXTURE[..cut].to_vec();
repair_checksums(&mut bytes);
exercise(&bytes);
}
}
#[test]
fn mangled_headers_never_panic() {
// The first 200 bytes hold the identification header and the start of the
// comment header: the fields most likely to steer an allocation.
let mut rng = Lcg(0xC0FF_EE00_1234_5678);
for _ in 0..fuzz_iterations() {
let mut bytes = FIXTURE.to_vec();
let at = (rng.next() as usize) % 200;
bytes[at] ^= (rng.next() % 255 + 1) as u8;
exercise(&bytes);
}
}
#[test]
fn a_truncated_setup_header_is_not_an_allocation() {
// Cut inside the setup header: the codebook counts still say "thousands of
// entries", and the decoder must refuse rather than reserve for them.
for cut in (60..1200).step_by(7) {
exercise(&FIXTURE[..cut]);
}
}
// -- the CoreAudio oracle (opt-in) -----------------------------------------
/// Repo-relative Ogg files, paired with the reference WAV basename.
const ORACLE_FILES: &[&str] = &[
"local/three.js/examples/sounds/button-press.ogg",
"local/three.js/examples/sounds/button-release.ogg",
"local/lasertag/Assets/Anaglyph/LaserTag/Matches/SFX/alarm.ogg",
"local/three.js/examples/sounds/Project_Utopia.ogg",
"local/three.js/examples/sounds/358232_j_s_song.ogg",
"local/three.js/examples/sounds/376737_Skullbeatz___Bad_Cat_Maste.ogg",
];
/// Minimal RIFF/WAVE reader for the reference files: 32-bit float or 16-bit
/// PCM, any channel count.
fn read_wav(bytes: &[u8]) -> (u32, u16, Vec<f32>) {
assert_eq!(&bytes[0..4], b"RIFF");
assert_eq!(&bytes[8..12], b"WAVE");
let mut at = 12usize;
let (mut rate, mut channels, mut bits, mut float) = (0u32, 0u16, 0u16, false);
let mut samples = Vec::new();
while at + 8 <= bytes.len() {
let id = &bytes[at..at + 4];
let size = u32::from_le_bytes(bytes[at + 4..at + 8].try_into().unwrap()) as usize;
let body = &bytes[at + 8..(at + 8 + size).min(bytes.len())];
if id == b"fmt " {
let tag = u16::from_le_bytes(body[0..2].try_into().unwrap());
channels = u16::from_le_bytes(body[2..4].try_into().unwrap());
rate = u32::from_le_bytes(body[4..8].try_into().unwrap());
bits = u16::from_le_bytes(body[14..16].try_into().unwrap());
float = tag == 3 || (tag == 0xFFFE && bits == 32);
} else if id == b"data" {
if float {
samples = body
.chunks_exact(4)
.map(|c| f32::from_le_bytes(c.try_into().unwrap()))
.collect();
} else {
samples = body
.chunks_exact(2)
.map(|c| i16::from_le_bytes(c.try_into().unwrap()) as f32 / 32768.0)
.collect();
}
}
at += 8 + size + (size & 1);
}
assert!(bits == 32 || bits == 16, "unexpected wav depth {bits}");
(rate, channels, samples)
}
/// Best constant offset of `got` against `reference`: the shift that minimises
/// the squared error over a loud window. Positive means our samples lag the
/// reference. (Minimising the error rather than maximising the correlation
/// matters — a tonal passage correlates well at several lags.)
fn best_offset(reference: &[f32], got: &[f32], channels: usize, span: isize) -> isize {
let refc: Vec<f32> = reference.iter().step_by(channels).copied().collect();
let gotc: Vec<f32> = got.iter().step_by(channels).copied().collect();
// Pick a window with energy in it.
let win = 16_384.min(refc.len() / 2).max(64);
let mut start = 0usize;
let mut best_energy = -1.0f64;
let step = (refc.len() / 16).max(1);
let last = refc.len().saturating_sub(win + span as usize);
for s in (0..last).step_by(step) {
let e: f64 = refc[s..s + win].iter().map(|&v| (v * v) as f64).sum();
if e > best_energy {
best_energy = e;
start = s;
}
}
let mut best = 0isize;
let mut best_err = f64::INFINITY;
for off in -span..=span {
let mut err = 0.0f64;
let mut ok = true;
for i in 0..win {
let gi = start as isize + i as isize + off;
if gi < 0 || gi as usize >= gotc.len() {
ok = false;
break;
}
let d = refc[start + i] as f64 - gotc[gi as usize] as f64;
err += d * d;
}
if ok && err < best_err {
best_err = err;
best = off;
}
}
best
}
fn snr_db(reference: &[f32], got: &[f32], channels: usize, offset: isize) -> (f64, usize) {
let mut signal = 0.0f64;
let mut noise = 0.0f64;
let mut n = 0usize;
let frames_ref = reference.len() / channels;
let frames_got = got.len() / channels;
for f in 0..frames_ref {
let g = f as isize + offset;
if g < 0 || g as usize >= frames_got {
continue;
}
for c in 0..channels {
let r = reference[f * channels + c] as f64;
let v = got[g as usize * channels + c] as f64;
signal += r * r;
noise += (r - v) * (r - v);
}
n += 1;
}
if noise == 0.0 {
return (f64::INFINITY, n);
}
(10.0 * (signal / noise).log10(), n)
}
#[test]
fn oracle_matches_coreaudio() {
let Ok(dir) = std::env::var("MAKEPAD_VORBIS_ORACLE") else {
eprintln!("MAKEPAD_VORBIS_ORACLE not set; skipping the CoreAudio comparison");
return;
};
let repo = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.to_path_buf();
let gate: f64 = std::env::var("MAKEPAD_VORBIS_SNR")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(90.0);
println!(
"{:<34} {:>6} {:>3} {:>9} {:>9} {:>5} {:>8} {:>9}",
"file", "rate", "ch", "oracle", "ours", "off", "snr dB", "x realtime"
);
let mut worst = f64::INFINITY;
let mut checked = 0usize;
for rel in ORACLE_FILES {
let path = repo.join(rel);
let Ok(bytes) = std::fs::read(&path) else {
eprintln!("missing {rel}, skipping");
continue;
};
let name = path.file_stem().unwrap().to_string_lossy().to_string();
let wav = std::path::Path::new(&dir).join(format!("{name}.wav"));
let Ok(wav_bytes) = std::fs::read(&wav) else {
eprintln!("missing reference {}, skipping", wav.display());
continue;
};
let (ref_rate, ref_ch, reference) = read_wav(&wav_bytes);
let started = std::time::Instant::now();
let audio = vorbis::decode_all(&bytes).expect("decode failed");
let elapsed = started.elapsed().as_secs_f64();
assert_eq!(audio.rate, ref_rate, "{rel}: sample rate");
assert_eq!(audio.channels, ref_ch, "{rel}: channel count");
let ch = audio.channels as usize;
if let Ok(dump) = std::env::var("MAKEPAD_VORBIS_DUMP") {
let mut raw = Vec::with_capacity(audio.pcm_interleaved_f32.len() * 4);
for v in &audio.pcm_interleaved_f32 {
raw.extend_from_slice(&v.to_le_bytes());
}
std::fs::write(std::path::Path::new(&dump).join(format!("{name}.f32")), raw).unwrap();
}
let offset = best_offset(&reference, &audio.pcm_interleaved_f32, ch, 4096);
let (snr, overlap) = snr_db(&reference, &audio.pcm_interleaved_f32, ch, offset);
let ours = audio.frames();
let oracle = reference.len() / ch;
// Second pass through the streaming decoder, which is what a deck uses:
// same work, no output vector.
let started = std::time::Instant::now();
let mut stream = vorbis::VorbisDecoder::new(&bytes).unwrap();
let mut frames = 0usize;
while let Some(block) = stream.next_block().unwrap() {
frames += block.len();
}
let stream_elapsed = started.elapsed().as_secs_f64();
assert_eq!(frames, audio.pcm_interleaved_f32.len());
let realtime = audio.duration_secs() / elapsed;
let stream_realtime = audio.duration_secs() / stream_elapsed;
println!(
"{:<34} {:>6} {:>3} {:>9} {:>9} {:>5} {:>8.2} {:>9.1}",
name, audio.rate, ch, oracle, ours, offset, snr, realtime
);
println!("{:<34} streaming {stream_realtime:.1} x realtime", "");
assert!(overlap > 1000, "{rel}: only {overlap} frames overlapped");
// Frame counts must agree to within one long block.
assert!(
(ours as isize - oracle as isize).abs() <= 2048,
"{rel}: {ours} frames vs oracle {oracle}"
);
assert!(snr >= gate, "{rel}: SNR {snr:.2} dB is below {gate} dB");
worst = worst.min(snr);
checked += 1;
// The duration probe must agree with the decode without decoding.
let probed = vorbis::probe_duration(&bytes).unwrap();
assert!(
(probed - audio.duration_secs()).abs() < 1e-6,
"{rel}: probe {probed} vs decode {}",
audio.duration_secs()
);
}
assert!(checked > 0, "no oracle files were found");
println!("worst SNR over {checked} files: {worst:.2} dB");
}