makepad/libs/game/audio/examples/vorbis_batch.rs
Admin 62d6732504 Vorbis: mono decodes sample-exact (115/160 files exact, was 0)
Two root causes, both found by building an oracle rather than guessing.

1. Amplitude ~75x low: the IMDCT applied a 2/n normalisation the encoder's
   forward transform had already carried. Because 2/n varies with block size
   it produced DIFFERENT errors on 256- vs 2048-sample blocks — exactly the
   reported symptom. Removing it gives fit scale 1.0000.

2. Leading trim, the real remaining defect. The first audio packet produces NO
   output (its window only primes the overlap-add) but we emitted from the
   first block's centre, injecting half a priming window of garbage and
   shifting everything early. And Vorbis carries encoder delay in the GRANULE
   POSITION, which varies per file — afinfo confirms 128 / 1103 / 960 frames
   on three samples — while our Ogg reader kept only last_granule and
   discarded per-page granules, making it unrecoverable. Added per-page
   granule tracking: the first page reporting a granule pins priming as
   centre - granule, and valid audio starts at priming + blocksize_0/2. That
   reproduces afinfo's numbers exactly on all three.

A premise in the brief was also wrong and worth recording: our output length
was already correct. afinfo reports valid frames matching OUR output — it is
afconvert that trims a further 128. The reference WAV was short, not us.

  mono    47 files  mean corr 1.00000 (min 1.00000)  47/47 exact
  stereo 107 files  mean corr 0.826                  68/107 exact

Decode cost 5.13 ms/file average; 11.5 MB compressed expands to 143.3 MB of
f32 PCM, which is why the sample bank's LRU cap matters.

Honest remaining defect: ~39 stereo files decode wrongly and it is NOT
alignment — a full lag sweep peaks at 0.40-0.89 with fit scales 0.40-1.87, so
specific blocks have wrong amplitude. Mono being 47/47 rules out floor,
residue 0/1, MDCT, windowing and priming; coupling matches the spec's
square-polar mapping including reverse order; floor 0 is rejected rather than
mis-decoded; and both channels are identical in the failing files, so it is
not a swap. The failing set is transient-heavy impact/footstep sounds, so the
lead is residue type 2 partition counting on short blocks.

reference_decode.rs is no longer #[ignore]d: 3 real tests asserting mono
correlation > 0.999 and length == granule, plus a 3000-mutation fuzz that must
never panic, all skipping cleanly without fixtures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 09:26:27 +02:00

102 lines
3.8 KiB
Rust

//! Batch-validate the Vorbis decoder against afconvert references.
//! Usage: vorbis_batch <dir-with-ogg> <tmpdir> [limit]
//!
//! Reports the correlation distribution so a systematic decoder fault is
//! visible as a cluster, not hidden behind one lucky or unlucky file.
use makepad_game_audio as audio;
use std::process::Command;
fn corr(a: &[f32], b: &[f32]) -> (f64, f64) {
let n = a.len().min(b.len());
let (mut num, mut da, mut db) = (0f64, 0f64, 0f64);
for i in 0..n {
let (x, y) = (a[i] as f64, b[i] as f64);
num += x * y;
da += x * x;
db += y * y;
}
if da <= 0.0 || db <= 0.0 {
return (0.0, 0.0);
}
(num / (da.sqrt() * db.sqrt()), num / da)
}
fn main() {
let args: Vec<String> = std::env::args().collect();
let dir = &args[1];
let tmp = &args[2];
let limit: usize = args.get(3).and_then(|s| s.parse().ok()).unwrap_or(60);
let mut files: Vec<_> = walk(dir);
files.sort();
let mut done = 0usize;
let mut worst: Vec<(f64, String, usize, usize, u16)> = Vec::new();
let (mut ok, mut framebad, mut failed) = (0usize, 0usize, 0usize);
for f in files.iter() {
if done >= limit {
break;
}
let refwav = format!("{tmp}/ref_batch.wav");
let _ = std::fs::remove_file(&refwav);
let st = Command::new("afconvert")
.args(["-f", "WAVE", "-d", "LEF32", f, &refwav])
.status();
if !matches!(st, Ok(s) if s.success()) {
continue;
}
let (Ok(o), Ok(w)) = (std::fs::read(f), std::fs::read(&refwav)) else { continue };
done += 1;
let got = match audio::decode(&o) {
Ok(p) => p,
Err(e) => {
failed += 1;
worst.push((-1.0, format!("{f} DECODE ERR {e:?}"), 0, 0, 0));
continue;
}
};
let want = match audio::wav::decode(&w) {
Ok(p) => p,
Err(_) => continue,
};
let (c, _scale) = corr(&got.samples, &want.samples);
// afconvert trims a further half-window off the tail, so our output
// being exactly 128 frames longer is correct, not a mismatch.
let d = got.frames() as i64 - want.frames() as i64;
if d != 0 && d != 128 {
framebad += 1;
}
if c > 0.999 && (d == 0 || d == 128) {
ok += 1;
}
worst.push((c, f.clone(), got.frames(), want.frames(), got.channels as u16));
}
worst.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
println!("=== {done} files: {ok} exact(>0.999 & frames match), {framebad} frame-count mismatch, {failed} decode errors");
println!("--- 12 worst:");
for (c, f, gf, wf, ch) in worst.iter().take(12) {
let name = f.rsplit('/').next().unwrap_or(f);
println!(" corr {c:.4} {ch}ch frames {gf}/{wf} {name}");
}
let mono: Vec<f64> = worst.iter().filter(|x| x.4 == 1 && x.0 >= 0.0).map(|x| x.0).collect();
let ster: Vec<f64> = worst.iter().filter(|x| x.4 == 2 && x.0 >= 0.0).map(|x| x.0).collect();
let mean = |v: &[f64]| if v.is_empty() { 0.0 } else { v.iter().sum::<f64>() / v.len() as f64 };
let lo = |v: &[f64]| v.iter().cloned().fold(f64::INFINITY, f64::min);
println!("--- mono n={} mean {:.5} min {:.5}", mono.len(), mean(&mono), lo(&mono));
println!("--- stereo n={} mean {:.5} min {:.5}", ster.len(), mean(&ster), lo(&ster));
}
fn walk(dir: &str) -> Vec<String> {
let mut out = Vec::new();
let Ok(rd) = std::fs::read_dir(dir) else { return out };
for e in rd.flatten() {
let p = e.path();
if p.is_dir() {
out.extend(walk(&p.to_string_lossy()));
} else if p.extension().map(|x| x == "ogg").unwrap_or(false) {
out.push(p.to_string_lossy().into_owned());
}
}
out
}