//! 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, Vec) { 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::() / 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); }