Files
skate-3-rust-engine/crates/skate-audio/tests/render_alloc.rs
T
Hails a4ec831a02 Audio: host clock on physics steps, review fixes, world / NPC audio hook-in
Review fixes:
- The audio host takes each physics step exactly once (one-step pulses latched, nothing while paused or silenced,
  at most 4 steps per frame), so pushes and other one-frame events are never dropped or doubled. e2e at 60 fps and
  above is byte-identical; 30 / 45 fps change only by the latch and the step pair.
- Removed the emitter PROFILES fallback, the stray trace dump and the audition script; data-gated tests are
  #[ignore = "needs the private install data"] and fail loudly when run without it; per-post log lines behind
  SKATE_AUDIO_TRACE; checked bank parsing, finite output, camera-cut Doppler reset, fewer render allocations.
- The 15 verdict-settled A/B switches are removed; their "on" behaviour is the only one (byte-identical).

World / NPC audio, hookable by engine systems and mods:
- Map-change fix: world and NPC hosts reset when the map's banks are unloaded, so surviving objects sound again.
- Engine surface (skate-game::world_audio): TrafficAudio / PedAudio / NpcSkaterAudio components, horn / alarm /
  speech messages, read-back of who is audible; retail limits (4 cars within 40 m, 15 peds within 50 m with
  footsteps for the nearest 3, one NPC skater within 30 m), measured in the recompilation.
- Mod surface (Lua API 2, `sdk.world_audio.*`); test mod `mods/world-audio-test/` (cars, peds, a ghost skater).
- Per-skater audio state builder (local player output identical); remote players take the NPC skater slot; an
  opt-in non-retail "more audible" setting.
- Docs: doc 15 "Hooking up world audio", doc 11 sections, PR plan.
2026-10-03 16:21:12 -06:00

162 lines
7.5 KiB
Rust

//! The audio thread's render must not allocate in steady state (optimisation pass 2026-10-03,
//! doc 11 "Optimisation pass"): an allocation in the device callback can wait on the process heap
//! behind the game thread. A counting allocator wraps the system one; only this thread's
//! allocations between the markers count. The scene: AEMS voices (mono and stereo, filters on,
//! azimuths and cutoffs moving every evaluator tick) routed into the eEQChain buses and the
//! FlangeSub returns, the env network on a preset, and a bound grain truck with its full chain.
use std::alloc::{GlobalAlloc, Layout, System};
use std::cell::Cell;
use std::sync::Arc;
use skate_audio::bus::env::{DEFAULT_PRESET, Preset};
use skate_audio::bus::flange::FlangePreset;
use skate_audio::eval::{OpenRequest, VoiceHost};
use skate_audio::formats::SampleHeader;
use skate_audio::grain::player::{GrainParams, GrainSource, Record};
use skate_audio::mixer::Pcm;
use skate_audio::runtime::Runtime;
struct Counting;
thread_local! {
static COUNTING: Cell<bool> = const { Cell::new(false) };
/// This thread's counted allocations (each test reads its own).
static ALLOCATIONS: Cell<u64> = const { Cell::new(0) };
}
fn count_one() {
let _ = ALLOCATIONS.try_with(|n| n.set(n.get() + 1));
}
fn allocations() -> u64 {
ALLOCATIONS.with(Cell::get)
}
unsafe impl GlobalAlloc for Counting {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
if COUNTING.try_with(Cell::get).unwrap_or(false) {
count_one();
}
unsafe { System.alloc(layout) }
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
unsafe { System.dealloc(ptr, layout) }
}
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
if COUNTING.try_with(Cell::get).unwrap_or(false) {
count_one();
}
unsafe { System.realloc(ptr, layout, new_size) }
}
}
#[global_allocator]
static GLOBAL: Counting = Counting;
fn reverb01() -> Preset {
Preset([
4000.0, 600.0, 1.0, 0.08, 0.0, 1.5, 70.0, 1.0, 0.7, 4000.0, 500.0, 1.0, 1.0, 0.25, 1.0, 1.0, 3.0, 0.1, 0.34, 1.0, 1161.0, 0.5,
0.2, 0.6, 1.0, 1.0, 270.0, 5000.0, 0.5, 0.45, 500.0, 0.75, 0.3, 0.3, 1.0, 1.0, 90.0, 5000.0, 0.51, 0.5, 500.0, 0.75, 0.28, 0.25,
])
}
fn scene() -> (Runtime, Vec<u32>) {
let mut rt = Runtime::new();
let frames = 44_100 * 4;
let wave: Vec<f32> = (0..frames).map(|i| (i as f32 * 0.03).sin() * 0.3).collect();
let mono = Arc::new(Pcm { rate: 44_100, channels: vec![wave.clone()] });
let stereo = Arc::new(Pcm { rate: 44_100, channels: vec![wave.clone(), wave.clone()] });
let header = |channels| SampleHeader { codec: 3, channels, rate: 44_100, frames: frames as u32, loop_start: Some(0) };
rt.mixer.add_bank(0, vec![Some(header(1)), Some(header(2))], vec![Some(mono), Some(stereo)]);
rt.mixer.buses.env.presets.insert(DEFAULT_PRESET, reverb01());
rt.mixer.buses.env.request(DEFAULT_PRESET);
rt.mixer.buses.flange.set_presets(
FlangePreset([20.0, 0.3, 0.2, 0.1, 1500.0, 0.002, 0.5, 0.9, 0.03]),
FlangePreset([1.7, 0.3, 0.0, 1.0, 250.0, 0.0005, 0.25, 0.6, 0.11]),
);
rt.mixer.buses.flange.frame([32692, 2313, 32692, 2313]);
rt.mixer.buses.eq.clear(Some([5000.0, 1.5, 2.0, 2000.0, 0.8, 3.0]));
let routes: Vec<[(u8, i32); 4]> = (0..8).map(|b| [(9u8, b), (10, 4096), (11, 1), (12, 1638)]).collect();
let ids = (0..24)
.filter_map(|k| {
rt.mixer.open(&OpenRequest { bank: 0, slot: (k % 2) as u16, level: 100, azimuth: [224, 32, 0, 0, 0, 0], stream_offset: u32::MAX, inputs: &routes[k % 8] })
})
.collect();
let source = Arc::new(GrainSource { name: "t".into(), duration: 4.0, pcm: Arc::new(Pcm { rate: 44_100, channels: vec![wave] }) });
let a = GrainParams { attack: 0.1, sustain: 0.2, release: 0.1, window: 1.6, drift: 0.05 };
let b = GrainParams { attack: 0.2, sustain: 0.1, release: 0.2, window: 1.5, drift: 0.05 };
rt.grains.bind_truck(0, source, [a, b], [Record { gain: 0.5, pitch: 1.0, position: 0.3 }, Record { gain: 0.25, pitch: 1.0, position: 0.2 }]);
(rt, ids)
}
#[test]
fn steady_state_render_does_not_allocate() {
let (mut rt, ids) = scene();
let before = allocations();
let mut measured = 0;
for block in 0..400usize {
if block % 6 == 0 {
for (k, &v) in ids.iter().enumerate() {
rt.mixer.set_azimuth(v, ((block * 37 + k * 911) % 65536) as i32);
rt.mixer.set(v, 6, 2000 + ((block + k * 13) % 20000) as i32);
rt.mixer.set(v, 7, 77);
rt.mixer.set(v, 2, 20000);
rt.mixer.set(v, 5, 3000);
rt.mixer.set(v, 0, 4096 + ((block + k) % 400) as i32);
}
}
// Warm-up: the first blocks size the delay lines, reverb combs and the voices' caches.
let count = block >= 100;
COUNTING.with(|c| c.set(count));
let _ = rt.render_block();
COUNTING.with(|c| c.set(false));
measured += usize::from(count);
}
let n = allocations() - before;
eprintln!("{n} allocations in {measured} steady-state blocks ({} voices)", rt.mixer.voice_count());
assert_eq!(n, 0, "render_block allocated {n} times in {measured} blocks");
}
/// The game thread's per-frame calls under the runtime lock must not allocate either (PR #32
/// review, 2026-10-03): with an installed (hand-built) AEMS bank and a posted program playing,
/// `redeliver` every console frame, `release` and the evaluator walks inside `render_block` run
/// without an allocation once warm (the walk reuses its order snapshot; `redeliver` / `release`
/// read their client lists in place; a destroy reads the module's object lists in place).
#[test]
fn installed_bank_redeliver_release_and_walks_do_not_allocate() {
use skate_audio::eval::synthetic::{Ex, bank, player_module, project};
let (mut rt, _) = scene();
rt.eval.install_project(&project());
let pcm = |frames: usize| Some(Arc::new(Pcm { rate: 48_000, channels: vec![(0..frames).map(|i| (i as f32 * 0.01).sin() * 0.2).collect()] }));
let id = rt.load_bank(bank(&[player_module(4)], &[Ex { module: 0, kind: 1, name_id: 1, name: "c_test", at: None }], &[(48_000, true), (24_000, true)]), vec![pcm(48_000), pcm(24_000)]);
let class = rt.eval.class_id("c_test").expect("the bank answers c_test");
let node = rt.post(class, &[1, 4096, 1]);
let doomed = rt.post(class, &[1, 4096, 0]);
// A release during the warm-up too: the first destroy sizes the evaluator's free list.
let warm = rt.post(class, &[1, 4096, 0]);
let before = allocations();
let mut measured = 0;
for block in 0..600usize {
// Warm-up: the first walks create the instances and open their voices (allocating).
let count = block >= 120;
COUNTING.with(|c| c.set(count));
if block % 6 == 0 {
rt.redeliver(node, &[1, 4096 + (block % 600) as i32, 1]);
}
if block == 30 {
rt.release(warm);
}
if block == 300 {
rt.release(doomed);
}
let _ = rt.render_block();
COUNTING.with(|c| c.set(false));
measured += usize::from(count);
}
assert!(rt.eval.bank(id).is_some(), "the bank stayed installed");
let n = allocations() - before;
eprintln!("{n} allocations in {measured} blocks with an installed bank ({} voices)", rt.mixer.voice_count());
assert!(rt.mixer.voice_count() > 0, "the program plays");
assert_eq!(n, 0, "redeliver / release / walks allocated {n} times in {measured} blocks");
}