makepad/libs/game/audio/tests/reference_decode.rs
andodeki 435135ed50 feat(arcade): port makepad game libs + arcade from makepad-internal onto the fork
Reintroduce apps/arcade and the ten game crates that were ripped out of
the fork (assets, audio, blocks, coedit, gen, net, pkg, render, script,
session), from makepad-internal/dev. Reconcile against the fork's layout:

- makepad-game-sim/math come from the fork's existing libs/sim + sim/math;
  the copied internal libs/game/{sim,math} are dropped and the kept game
  crates plus arcade point at ../../sim and ../../sim/math.
- arcade AI is rewritten off the removed makepad-ai agent API onto the
  makepad-ai-hub headless ChatProvider worker (example/cad pattern):
  AiWorker{send,cancel,poll}, ai_worker_loop driving
  ClaudeApiChatProvider(ClaudeCli), AiWorkerEvent availability/delta/
  done/error mapped into the chat feed and authoring land_edit, with
  main.rs send_message/cancel_request/event-drain rebuilt around it.
- the game-script sandbox is adapted to the fork's jailed splash storage:
  every isolate gets mod.fs = splash_storage jail; splaes splash_storage
  set_root_for_heap is now public so the ScriptHost (or Splash) can grant
  a per-game jail root. ENT font: build.rs/dispatch.rs entities fill new
  fork sim Entity/Part fields via ..Default::default(); world_raycast now
  borrows mutably and returns the material id.

cargo check -p makepad-arcade and the game + arcade test suites pass.
2026-09-05 05:49:12 +03:00

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..]);
}
}
}