makepad/libs/audio_synth/tests/bus_renders.rs
Admin 8e05517f07 render, model, scene, audio_synth, platform: the game renderer's lights, shadows, GI, VFX and HDR, a character kit, a shared synth, and the platform hooks the Sandbox needed -- libs/render is split into renderer/ and shaders/ modules (pure moves first) and gets stable cascaded shadow maps with a local shadow atlas, cascaded voxel-traced probe GI with SSAO (the CPU BVH placement and analytic emitters are gone), an opt-in HDR scene pass (Renderer::set_hdr_output: AgX, bloom, auto-exposure, FXAA), GPU particles with presets and decals (libs/scene/vfx, with a CPU fallback where float16 blending is missing), world streaming, skinned animation layers and a composed HUD (the HUD document no longer caps its element count; CameraState gains a far plane). Fab and Stage keep the 8-bit lane and in-scene VFX; only hosts that opt in change. libs/model gains the character kit (body, head, gear, clips, presets, FPS arms), construction shapes and decals, bevels, model programs with weathering, and import; mesh_edit decimation collapses every independent candidate per adjacency pass instead of one (a 4.6k-face mesh took 10 s). libs/csg drops the document/rig evaluator the Sandbox no longer uses. New libs/audio_synth (makepad-audio-synth): band-limited oscillators, filters, physically inspired engines, a sound-recipe format, FDN reverb, a spatializer and a look-ahead limiter, with the piano, drum kits, SoundFonts and Ironfish behind instruments; Stage's audio_dsp re-exports it. Platform: GpuInfo::float16_blend_targets, with Metal building an RGBA16Float PSO pair per Bgra8 shader on first use and Vulkan reporting the device's RGBA16F blend support; the WGSL backend forwards only the instance fields the fragment stage reads (39 vec4 varyings overflowed NVIDIA's limit and lost the device); an unbound Vulkan texture_depth slot gets the depth dummy instead of a colour view; an over-budget remote raw grab is box-halved until it fits instead of failing; Windows implements game_input_infos and XInput haptics, DirectInput FFB starts its constant force once and takes exclusive mode only in the foreground, iOS/tvOS get Core Haptics, and game_input gains an additive force API. The AI hub's Claude Code provider can serve one MCP server's tools natively (--mcp-config/--allowedTools, no think-tag litter for redacted thinking), the MCP server answers tool results with image content, and the fleet Qwen provider keeps its vision observations on the row they annotate. widgets gain the cyberpunk desktop style, and a Splash isolate inherits the stylesheet of the VM that allocates it. Checked: cargo check --workspace on macOS (clean apart from apps/source-library's own warnings), makepad-platform/widgets/render/scene/model/audio-synth/mesh-edit/csg for x86_64-unknown-linux-gnu and x86_64-pc-windows-msvc (clean); tests: platform 318, render 488, widgets-core 1307, model 158, scene 35, audio-synth 40, mesh-edit 9, all passing.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 01:55:13 +02:00

212 lines
9.7 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Offline renders through the whole game bus (spatializer, reverb, master
//! dynamics): the recipe bank by family, a car pass-by with doppler, a
//! multi-car race mix, room presets, and the render cost per voice.
//!
//! `SYNTH_RENDER_DIR=/tmp/x cargo test --release -p makepad-audio-synth
//! --test bus_renders -- --nocapture --test-threads 1`
use makepad_audio_synth::bus::{Emitter, Priority, SynthBus, RECIPE_VOICES};
use makepad_audio_synth::engine::{preset, EngineInput};
use makepad_audio_synth::recipe::{builtin, Recipe, BUILTIN_NAMES};
use makepad_audio_synth::reverb::RoomPreset;
use makepad_audio_synth::spatial::{Distance, Listener3};
use makepad_audio_synth::vehicle::{Surface, TyreInput};
use makepad_audio_synth::wav::stereo_wav_16;
use std::time::Instant;
const RATE: f32 = 48_000.0;
fn write(name: &str, l: &[f32], r: &[f32]) {
if let Some(dir) = std::env::var_os("SYNTH_RENDER_DIR") {
let dir = std::path::PathBuf::from(dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(format!("{name}.wav")), stereo_wav_16(l, r, RATE as u32)).unwrap();
}
}
fn render(bus: &mut SynthBus, secs: f32) -> (Vec<f32>, Vec<f32>) {
let n = (RATE * secs) as usize;
let (mut l, mut r) = (vec![0.0; n], vec![0.0; n]);
for (cl, cr) in l.chunks_mut(512).zip(r.chunks_mut(512)) {
bus.render(cl, cr);
}
(l, r)
}
fn stats(x: &[f32]) -> (f32, f32, f32) {
let peak = x.iter().fold(0.0f32, |a, v| a.max(v.abs()));
let rms = (x.iter().map(|v| v * v).sum::<f32>() / x.len().max(1) as f32).sqrt();
// Largest sample-to-sample step relative to the peak: a click detector
// (a hard edge is a step near 2× peak; a smooth sound stays far below).
let step = x.windows(2).map(|w| (w[1] - w[0]).abs()).fold(0.0f32, f32::max);
(20.0 * peak.max(1e-9).log10(), 20.0 * rms.max(1e-9).log10(), step / peak.max(1e-9))
}
/// Every built-in recipe, spaced 0.1 s apart after its own length, in
/// families; one WAV per family.
#[test]
fn recipe_bank_by_family() {
let families: &[(&str, &[&str])] = &[
("ui", &["click", "hover", "confirm", "cancel", "error", "notify", "beep", "coin", "pickup"]),
("weapons", &["gun_pistol", "gun_rifle", "gun_smg", "gun_shotgun", "gun_sniper", "gun_rocket", "gun_laser", "reload", "empty", "sword", "bow"]),
("explosions", &["explosion_small", "explosion", "explosion_big", "fireball", "thunder"]),
("impacts", &["thud", "impact_metal", "impact_stone", "impact_glass", "impact_flesh", "impact_plastic", "impact_car", "thump"]),
("footsteps", &["footstep", "footstep_grass", "footstep_gravel", "footstep_wood", "footstep_metal", "footstep_snow", "footstep_water", "land"]),
("magic_water", &["magic", "heal", "teleport", "shield", "water", "bubble", "splash", "wind_gust", "bell"]),
("classic", &["jump", "shoot", "zap", "grab", "angry", "calm", "rescue", "shove", "board", "hurt", "win", "lose", "squeak", "roar", "bark", "moo", "whip", "alarm", "horn"]),
];
let mut covered = 0;
for (family, names) in families {
let mut bus = SynthBus::new(RATE);
let (mut l, mut r) = (Vec::new(), Vec::new());
println!("-- {family}");
for name in names.iter() {
let recipe = Recipe::parse(builtin(name).unwrap()).unwrap();
let len = recipe.length().min(4.0) + 0.25;
bus.play_recipe(&recipe, 1.0, 1.0, Emitter::FLAT, Priority::Normal, 0);
let (a, b) = render(&mut bus, len);
let (peak, rms, step) = stats(&a);
println!(" {name:16} len {len:4.2}s peak {peak:6.1} dBFS rms {rms:6.1} step/peak {step:.2}");
assert!(peak < -0.5, "{name} peaks at {peak}");
l.extend_from_slice(&a);
r.extend_from_slice(&b);
covered += 1;
}
write(&format!("sfx_{family}"), &l, &r);
}
assert!(covered >= BUILTIN_NAMES.len() - 6, "families cover the bank");
}
/// A V8 passing the listener at 35 m/s, 6 m to the side: pan sweeps left to
/// right, doppler drops the pitch by ~20 %, air absorption darkens it far.
#[test]
fn car_pass_by_with_doppler() {
let mut bus = SynthBus::new(RATE);
let spec = preset("v8").unwrap();
bus.set_listener(Listener3::default());
let secs = 8.0;
let n = (RATE * secs) as usize;
let (mut l, mut r) = (vec![0.0; n], vec![0.0; n]);
let block = 512;
let mut t = 0.0f32;
for (cl, cr) in l.chunks_mut(block).zip(r.chunks_mut(block)) {
// Travels along x from -140 m to +140 m, 6 m in front (z = -6).
let x = -140.0 + 35.0 * t;
let e = Emitter {
vel: [35.0, 0.0, 0.0],
dist: Distance { near: 3.0, range: 200.0, rolloff: 0.7 },
..Emitter::at([x, 0.0, -6.0], 200.0)
};
bus.vehicle(1, &spec, EngineInput { rpm: 4800.0, throttle: 1.0, load: 1.0, gear: 4, speed: 35.0 }, TyreInput { slip: 0.02, load: 1.0, speed: 35.0, surface: Surface::Asphalt, grounded: 4 }, e, 0.0, 1.0);
bus.end_frame();
bus.render(cl, cr);
t += block as f32 / RATE;
}
let (pl, _, _) = stats(&l);
let (pr, _, _) = stats(&r);
// Before the pass (t=2..3 s) the left ear is louder; after (t=5..6) the right.
let seg = |x: &[f32], a: f32, b: f32| stats(&x[(a * RATE) as usize..(b * RATE) as usize]).1;
println!("pass-by: peak L {pl:.1} R {pr:.1}; before L {:.1} R {:.1}; after L {:.1} R {:.1}", seg(&l, 2.5, 3.5), seg(&r, 2.5, 3.5), seg(&l, 4.8, 5.8), seg(&r, 4.8, 5.8));
assert!(seg(&l, 2.5, 3.5) > seg(&r, 2.5, 3.5) + 3.0);
assert!(seg(&r, 4.8, 5.8) > seg(&l, 4.8, 5.8) + 3.0);
write("car_pass_by_doppler", &l, &r);
}
/// Six race cars of different presets circling the listener: the voice
/// pool, stealing and the master limiter under load, plus measured cost.
#[test]
fn race_mix_and_cost() {
let mut bus = SynthBus::new(RATE);
let cars = ["flat6", "v8", "v10", "inline4_turbo", "v8_flat", "v12"];
let secs = 10.0;
let n = (RATE * secs) as usize;
let (mut l, mut r) = (vec![0.0; n], vec![0.0; n]);
let block = 512;
let mut t = 0.0f32;
let started = Instant::now();
for (cl, cr) in l.chunks_mut(block).zip(r.chunks_mut(block)) {
for (i, name) in cars.iter().enumerate() {
let spec = preset(name).unwrap();
let phase = t * 0.35 + i as f32 * 1.047;
let radius = 25.0 + 8.0 * i as f32;
let pos = [phase.cos() * radius, 0.0, phase.sin() * radius];
let speed = 0.35 * radius;
let vel = [-phase.sin() * speed, 0.0, phase.cos() * speed];
let u = ((t * 0.5 + i as f32 * 0.3).sin() * 0.5 + 0.5).clamp(0.0, 1.0);
let rpm = spec.idle_rpm + (spec.redline_rpm - spec.idle_rpm) * (0.3 + 0.65 * u);
let throttle = if (t + i as f32).sin() > -0.6 { 1.0 } else { 0.0 };
let e = Emitter { vel, dist: Distance { near: 3.0, range: 160.0, rolloff: 0.7 }, ..Emitter::at(pos, 160.0) };
bus.vehicle(
i as u64 + 1,
&spec,
EngineInput { rpm, throttle, load: if throttle > 0.0 { 1.0 } else { -0.3 }, gear: (1 + (u * 5.0) as i8), speed },
TyreInput { slip: if (t * 0.7 + i as f32).sin() > 0.9 { 0.4 } else { 0.03 }, load: 1.1, speed, surface: Surface::Asphalt, grounded: 4 },
e,
0.0,
1.0,
);
}
bus.end_frame();
// Sprinkle impacts and gunfire on top.
if (t * 10.0) as u32 % 13 == 0 {
let recipe = Recipe::parse(builtin("impact_metal").unwrap()).unwrap();
bus.play_recipe(&recipe, 1.0, 0.6, Emitter::at([10.0, 0.0, -5.0], 60.0), Priority::Normal, 0);
}
bus.render(cl, cr);
t += block as f32 / RATE;
}
let spent = started.elapsed().as_secs_f32();
let s = bus.stats();
println!(
"race mix: {} cars, {secs} s rendered in {:.3} s = {:.1}x realtime ({:.1} % of one core); bus cpu_load {:.3} peak {:.3}; peak {:.2}; steals {}",
cars.len(),
spent,
secs / spent,
100.0 * spent / secs,
s.cpu_load,
s.peak_cpu_load,
s.peak,
s.steals
);
let (pl, _, _) = stats(&l);
assert!(pl < -0.9, "limiter ceiling -1 dBFS, got {pl}");
write("race_mix_6_cars", &l, &r);
}
/// The same gunshot in each room preset.
#[test]
fn rooms() {
let recipe = Recipe::parse(builtin("gun_pistol").unwrap()).unwrap();
let (mut l, mut r) = (Vec::new(), Vec::new());
for room in RoomPreset::ALL {
let mut bus = SynthBus::new(RATE);
bus.set_room(room.params());
// Let the crossfade settle from the default room.
render(&mut bus, 3.0);
bus.play_recipe(&recipe, 1.0, 1.0, Emitter::at([4.0, 0.0, -8.0], 80.0), Priority::High, 0);
let (a, b) = render(&mut bus, 2.5);
let tail = stats(&a[(RATE * 1.0) as usize..]).1;
println!("room {:8}: tail rms after 1 s {tail:.1} dB", room.name());
l.extend_from_slice(&a);
r.extend_from_slice(&b);
}
write("rooms_pistol", &l, &r);
}
/// Cost of one voice of each kind, and the pool under a 200-shot burst.
#[test]
fn voice_cost_and_pool() {
let mut bus = SynthBus::new(RATE);
let recipe = Recipe::parse(builtin("explosion").unwrap()).unwrap();
for i in 0..RECIPE_VOICES {
bus.play_recipe(&recipe, 1.0, 0.1, Emitter::at([i as f32, 0.0, -5.0], 100.0), Priority::Normal, 0);
}
let started = Instant::now();
render(&mut bus, 1.0);
let full = started.elapsed().as_secs_f32();
println!("{} explosion voices: {:.2} % of one core ({:.1} µs per voice per 10 ms block)", RECIPE_VOICES, full * 100.0, full * 1e6 / RECIPE_VOICES as f32 / 100.0);
for _ in 0..200 {
bus.play_recipe(&recipe, 1.0, 0.1, Emitter::FLAT, Priority::Normal, 0);
}
assert!(bus.live_counts().0 <= RECIPE_VOICES);
}