makepad/libs/game/audio/examples/vorbis_probe.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

90 lines
3.7 KiB
Rust

//! Diagnostic: compare our Vorbis decode against a reference WAV.
//! Usage: vorbis_probe <file.ogg> <reference.wav>
use makepad_game_audio as audio;
fn main() {
let args: Vec<String> = std::env::args().collect();
let ogg = std::fs::read(&args[1]).expect("read ogg");
let refwav = std::fs::read(&args[2]).expect("read ref");
let got = audio::decode(&ogg).expect("decode ogg");
let want = audio::wav::decode(&refwav).expect("decode ref");
println!(
"got {}ch {}Hz {} frames | ref {}ch {}Hz {} frames",
got.channels,
got.sample_rate,
got.frames(),
want.channels,
want.sample_rate,
want.frames()
);
let g = &got.samples;
let w = &want.samples;
let rms = |v: &[f32]| (v.iter().map(|x| (*x as f64).powi(2)).sum::<f64>() / v.len() as f64).sqrt();
println!("rms got={:.6e} ref={:.6e} ratio(ref/got)={:.3}", rms(g), rms(w), rms(w) / rms(g));
println!(
"peak got={:.6e} ref={:.6e}",
g.iter().fold(0f32, |a, b| a.max(b.abs())),
w.iter().fold(0f32, |a, b| a.max(b.abs()))
);
// Best correlation over a lag sweep, plus the scale that best fits there.
let n = g.len().min(w.len());
let mut best = (0i64, 0f64, 0f64);
for lag in -2000i64..2000 {
let (mut num, mut dg, mut dw) = (0f64, 0f64, 0f64);
for i in 0..n {
let j = i as i64 + lag;
if j < 0 || j as usize >= n {
continue;
}
let (a, b) = (g[i] as f64, w[j as usize] as f64);
num += a * b;
dg += a * a;
dw += b * b;
}
if dg > 0.0 && dw > 0.0 {
let c = num / (dg.sqrt() * dw.sqrt());
if c.abs() > best.1.abs() {
best = (lag, c, num / dg.max(1e-30));
}
}
}
println!("best corr {:.4} at lag {} (fit scale ref=got*{:.4})", best.1, best.0, best.2);
for &l in &[-1088i64,-1024,-901,-773,-640,-576,-512,-256,-128,0,128] {
let (mut num, mut dg, mut dw) = (0f64,0f64,0f64);
for i in 0..n { let j=i as i64+l; if j<0||j as usize>=n {continue;}
let (a,b)=(g[i] as f64, w[j as usize] as f64); num+=a*b; dg+=a*a; dw+=b*b; }
if dg>0.0&&dw>0.0 { println!(" lag {:>6}: corr {:.4}", l, num/(dg.sqrt()*dw.sqrt())); }
}
let on = |v: &[f32], thr: f32| v.iter().position(|s| s.abs() > thr);
let (pg, pw) = (g.iter().fold(0f32,|a,b| a.max(b.abs())), w.iter().fold(0f32,|a,b| a.max(b.abs())));
println!("onset(1% of peak) got={:?} ref={:?}", on(g, pg*0.01), on(w, pw*0.01));
println!("first 6 got: {:?}", &g[..6.min(g.len())]);
println!("first 6 ref: {:?}", &w[..6.min(w.len())]);
if g.len() > 780 { println!("got[773..779]: {:?}", &g[773..779]); }
println!("tail 4 got: {:?}", &g[g.len().saturating_sub(4)..]);
println!("tail 4 ref: {:?}", &w[w.len().saturating_sub(4)..]);
// Per-window scale, to expose block-size dependence.
let win = 512;
let mut ratios = Vec::new();
for k in 0..(n / win) {
let (a, b) = (&g[k * win..(k + 1) * win], &w[k * win..(k + 1) * win]);
let (ra, rb) = (rms(a), rms(b));
if ra > 1e-9 && rb > 1e-9 {
ratios.push((k, rb / ra));
}
}
let show: Vec<String> = ratios.iter().take(14).map(|(k, r)| format!("{k}:{r:.1}")).collect();
println!("per-{win} ratios(ref/got): {}", show.join(" "));
if !ratios.is_empty() {
let mut v: Vec<f64> = ratios.iter().map(|(_, r)| *r).collect();
v.sort_by(|a, b| a.partial_cmp(b).unwrap());
println!("ratio median={:.2} min={:.2} max={:.2}", v[v.len() / 2], v[0], v[v.len() - 1]);
}
}
#[allow(dead_code)]
fn onset(v: &[f32], thr: f32) -> Option<usize> { v.iter().position(|s| s.abs() > thr) }