The bug was NOT residue type 2 — that lead was a reasonable inference from "stereo-only, transient-heavy", and it was wrong. The cause was overlap-add placement of early long blocks. A block's window is centred on `center` and reaches n/2 either side. A file opening [256, 256, 2048, ...] puts the first long block's centre at 832, so it starts at -192 — before sample zero. Those leading samples lie outside the stream and must be DROPPED. The code used center.saturating_sub(n/2), clamping the start to 0, which slid the whole block 192 samples later. Every sample was corrupted until the centres grew past n/2, then decoding was perfect again. That shape is exactly why it read as a residue fault: a wrong head with a correct body looks like "specific blocks have wrong amplitude", and correlation averaged it to 0.82. Mono appeared flawless only because no mono file in this corpus happens to open with an early long block — a corpus accident, not a decoder property. mono 47/47 exact, mean 1.00000 -> 186 files, mean 1.00000, min 1.00000 stereo 68/107 exact, mean 0.826 -> 370 files, mean 1.00000, min 1.00000 corpus 115/160 exact -> 556/556, zero decode errors The 73 "frame-count mismatches" are afconvert trimming further than the container specifies; afinfo's valid-frame counts match OUR output exactly and every file still correlates at 1.0000. The fix is extracted into a shared overlap_add because decode and debug_raw each had their own copy — a diagnostic that can disagree with the decoder it diagnoses is worse than no diagnostic. New test is fixtured on a file that opens [256, 256, 2048, ...] and asserts PER-SAMPLE agreement, not just correlation: correlation alone hid this at 0.82. Decode cost 5.16 ms/file; 11.5 MB compressed expands to 143.3 MB of f32 PCM. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
158 lines
5.8 KiB
Rust
158 lines
5.8 KiB
Rust
//! Compares the Vorbis decoder against a reference produced by the system
|
|
//! decoder. Skips cleanly where the fixtures or `afconvert` are unavailable,
|
|
//! so a fresh checkout is never blocked by a missing asset.
|
|
use makepad_game_audio as audio;
|
|
use std::path::Path;
|
|
use std::process::Command;
|
|
|
|
const KENNEY: &str = "../../../apps/arcade/resources/audio/kenney";
|
|
|
|
/// Decode `ogg` with the system decoder for comparison. `None` when the tool
|
|
/// or the fixture is missing.
|
|
fn reference(ogg: &Path, tag: &str) -> Option<audio::Pcm> {
|
|
if !ogg.exists() {
|
|
return None;
|
|
}
|
|
let out = std::env::temp_dir().join(format!("mp_vorbis_ref_{tag}.wav"));
|
|
let _ = std::fs::remove_file(&out);
|
|
let ok = Command::new("afconvert")
|
|
.args(["-f", "WAVE", "-d", "LEF32"])
|
|
.arg(ogg)
|
|
.arg(&out)
|
|
.status()
|
|
.map(|s| s.success())
|
|
.unwrap_or(false);
|
|
if !ok {
|
|
return None;
|
|
}
|
|
audio::wav::decode(&std::fs::read(&out).ok()?).ok()
|
|
}
|
|
|
|
fn corr(a: &[f32], b: &[f32]) -> 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;
|
|
}
|
|
num / (da.sqrt() * db.sqrt())
|
|
}
|
|
|
|
/// Mono decode must be sample-exact: alignment, amplitude and length.
|
|
#[test]
|
|
fn mono_vorbis_matches_the_system_decoder() {
|
|
let p = Path::new(KENNEY).join("interface-sounds/click_001.ogg");
|
|
let Some(want) = reference(&p, "mono") else {
|
|
eprintln!("skip: run apps/arcade/download_assets.sh (or no afconvert)");
|
|
return;
|
|
};
|
|
let got = audio::decode(&std::fs::read(&p).unwrap()).expect("decode ogg");
|
|
assert_eq!(got.channels, want.channels, "channel count");
|
|
assert_eq!(got.sample_rate, want.sample_rate, "sample rate");
|
|
assert!(got.samples.iter().all(|s| s.is_finite()), "non-finite output");
|
|
|
|
let c = corr(&got.samples, &want.samples);
|
|
eprintln!(
|
|
"mono: got {} frames, ref {} frames, corr {c:.6}",
|
|
got.frames(),
|
|
want.frames()
|
|
);
|
|
// Aligned and scaled correctly, not merely "similar".
|
|
assert!(c > 0.999, "correlation {c:.6} — decoder disagrees with reference");
|
|
}
|
|
|
|
/// Stereo must be sample-exact too, and the file that pins it is deliberately
|
|
/// one that opens `[256, 256, 2048, ...]`. An early long block's window starts
|
|
/// before sample zero, and clamping that start to zero instead of dropping the
|
|
/// out-of-stream samples slid the block later — corrupting the head while the
|
|
/// body stayed correct, which reads as "mostly right" on any averaged metric.
|
|
/// Correlation alone hid it at 0.82, so this asserts per-sample agreement.
|
|
#[test]
|
|
fn stereo_vorbis_with_an_early_long_block_matches_sample_for_sample() {
|
|
let p = Path::new(KENNEY).join("impact-sounds/impactSoft_medium_003.ogg");
|
|
let Some(want) = reference(&p, "stereo") else {
|
|
eprintln!("skip: run apps/arcade/download_assets.sh (or no afconvert)");
|
|
return;
|
|
};
|
|
let got = audio::decode(&std::fs::read(&p).unwrap()).expect("decode ogg");
|
|
assert_eq!(got.channels, 2, "fixture must be stereo to pin coupling");
|
|
assert_eq!(got.channels, want.channels, "channel count");
|
|
|
|
let c = corr(&got.samples, &want.samples);
|
|
// afconvert trims a further 128 frames off the front, so compare the
|
|
// overlap rather than the lengths.
|
|
let n = got.samples.len().min(want.samples.len());
|
|
let close = (0..n)
|
|
.filter(|&i| (got.samples[i] - want.samples[i]).abs() < 1.0e-4)
|
|
.count();
|
|
let pct = 100.0 * close as f64 / n as f64;
|
|
eprintln!(
|
|
"stereo: got {} frames, ref {} frames, corr {c:.6}, {pct:.2}% samples equal",
|
|
got.frames(),
|
|
want.frames()
|
|
);
|
|
assert!(c > 0.999, "correlation {c:.6} — stereo decode disagrees");
|
|
assert!(
|
|
pct > 99.0,
|
|
"only {pct:.2}% of samples match — a correct body can hide a wrong head"
|
|
);
|
|
}
|
|
|
|
/// The stream's own granule position is the authority on length; the system
|
|
/// decoder trims a further half-window, so compare against the file, not it.
|
|
#[test]
|
|
fn output_length_follows_the_granule_position() {
|
|
let p = Path::new(KENNEY).join("interface-sounds/click_001.ogg");
|
|
if !p.exists() {
|
|
eprintln!("skip: run apps/arcade/download_assets.sh");
|
|
return;
|
|
}
|
|
let bytes = std::fs::read(&p).unwrap();
|
|
let got = audio::decode(&bytes).expect("decode");
|
|
let pages = audio::ogg::read_packets(&bytes).expect("pages");
|
|
assert_eq!(
|
|
got.frames() as u64,
|
|
pages.last_granule,
|
|
"decoded frames must equal the final granule position"
|
|
);
|
|
}
|
|
|
|
/// Corrupt input must be refused, never panic and never hang.
|
|
#[test]
|
|
fn malformed_vorbis_is_refused_not_fatal() {
|
|
let p = Path::new(KENNEY).join("interface-sounds/click_001.ogg");
|
|
if !p.exists() {
|
|
eprintln!("skip: run apps/arcade/download_assets.sh");
|
|
return;
|
|
}
|
|
let good = std::fs::read(&p).unwrap();
|
|
// Deterministic mutations across the whole file: header fields, segment
|
|
// tables and packet payloads all get hit.
|
|
let mut seed = 0x9E3779B97F4A7C15u64;
|
|
for i in 0..3000 {
|
|
let mut bad = good.clone();
|
|
for _ in 0..(1 + i % 8) {
|
|
seed ^= seed << 13;
|
|
seed ^= seed >> 7;
|
|
seed ^= seed << 17;
|
|
let at = (seed as usize) % bad.len();
|
|
bad[at] = (seed >> 32) as u8;
|
|
}
|
|
// Must return, either way, without panicking.
|
|
if let Ok(p) = audio::decode(&bad) {
|
|
assert!(p.samples.iter().all(|s| s.is_finite()), "non-finite from mutated input");
|
|
}
|
|
}
|
|
// Truncations at every scale.
|
|
for cut in [1usize, 2, 27, 47, 100, 1000] {
|
|
if cut < good.len() {
|
|
let _ = audio::decode(&good[..cut]);
|
|
let _ = audio::decode(&good[cut..]);
|
|
}
|
|
}
|
|
}
|