diff --git a/docs/tickets.md b/docs/tickets.md index 007c06d..71d3a62 100644 --- a/docs/tickets.md +++ b/docs/tickets.md @@ -2003,3 +2003,45 @@ dielectrics should get IBL, not a mirror march), a firefly luminance clamp on hit radiance, and hit validation against the coarse Hi-Z level actually sampled. Re-enable in the shooter only after the interior capture stays clean. + +## EN-062 — Spatial audio v2: live voices, real distance/pan model, doppler ✅ *(shipped 2026-07-16)* + +The mixer could only fire-and-forget: `play_sound_3d` was a one-shot with a +fixed position, 1/d attenuation, linear pan — no way to loop an emitter, move +it, or stop it. Games faked ambience by re-triggering clips on timers (the +shooter's wind), and a river/creature emitter was simply not expressible. + +Shipped, all engine-side so every game gets it for free: + +- **Voice ids** — every play returns a stable id; `play_sound_3d_ex` + + `voice_set_position/volume/pitch/lowpass/stop` steer one live voice. + Looping voices persist until stopped; stop fades over a block (no click). +- **Inverse-clamped distance model** (`ref/(ref+rolloff·(d−ref))`, per-voice + ref/rolloff/max) — ref=1, rolloff=1 is byte-for-byte the old 1/d, so the + legacy API keeps its loudness. `max_dist` culls to a head-only advance. +- **Equal-power pan at 0.85 width** (was linear — center sat 6 dB down and + hard sides were headphone-artifact absolute). +- **STEREO WAS MIRRORED**: the listener "right" was `cross(up, fwd)` = screen + LEFT (mat4_look_at's `s` is `cross(fwd, up)`). Every spatial sound since + the beginning panned to the wrong side. Fixed and now pinned by a test. +- **Air absorption** (distance-driven low-pass) and a **rear head-shadow + cue** (low-pass toward 4.5 kHz + ~1.5 dB dip behind the listener), folded + with the occlusion filter into one one-pole per voice. +- **Doppler** from the per-block distance delta (listener + source motion + both count), clamped, smoothed, with a teleport guard so pool voices + re-targeted across the map don't chirp. +- **Fractional resampling** (linear interp) — which also means assets now + play at their AUTHORED rate: a 44.1 kHz file on the typical 48 kHz WASAPI + endpoint used to play ~9% fast and sharp, forever. The Windows backend now + reports the device rate to the renderer (it never had). Music streams are + untouched (still device-rate; separate ticket if it ever matters). +- Per-voice gains ramp linearly across each mix block — per-frame + position/volume rides cannot zipper. Consequence: any gain change fully + lands one block late (~5–20 ms); the duck test now measures the settled + block. +- Command ring 256 → 1024 (per-frame emitter updates + boot routing bursts). + +Tests: 10 new (loop lifecycle, stereo crossing, legacy-curve equivalence, +equal-power, air absorption, rear cue, pitch rate, doppler zero-crossing +count, max-dist cull/return, per-voice occlusion). watchOS stubs regenerated; +web target exports the same six calls. First consumer: shooter SH-050. diff --git a/native/shared/src/audio/mod.rs b/native/shared/src/audio/mod.rs index ea73fdb..bbcff3d 100644 --- a/native/shared/src/audio/mod.rs +++ b/native/shared/src/audio/mod.rs @@ -83,16 +83,20 @@ pub struct AudioMixer { /// EN-029 — per-sound routing: (bus, reverb send, low-pass cutoff Hz). /// A property of the sound, not of each play call. routes: std::collections::HashMap, + /// EN-062 — monotonic voice-id allocator. Every play gets one; the id is + /// the handle for moving/stopping/re-pitching that one voice later. + next_voice: u64, tx: spsc::Producer, /// Present until the platform takes it for its audio thread; used for /// inline mixing on single-threaded targets (web). renderer: Option, } -/// Command-ring capacity. 256 in-flight commands comfortably exceeds any -/// realistic burst (a command is one play/stop/volume change; the ring -/// drains every audio callback, i.e. every ~10ms). -const CMD_CAPACITY: usize = 256; +/// Command-ring capacity. Live emitters (EN-062) stream per-frame position +/// and volume updates — a dozen tracked voices at 60 fps is ~25 commands per +/// frame, and boot routes sounds in bursts of hundreds. 1024 gives a hitch +/// two full callback intervals of headroom before anything drops. +const CMD_CAPACITY: usize = 1024; impl Default for AudioMixer { fn default() -> Self { @@ -109,6 +113,7 @@ impl AudioMixer { sound_volumes: Vec::new(), master_volume: 1.0, routes: std::collections::HashMap::new(), + next_voice: 0, tx, renderer: Some(AudioRenderer::new(rx)), } @@ -141,27 +146,89 @@ impl AudioMixer { self.sounds.alloc(Arc::new(data)) } - pub fn play_sound(&mut self, handle: f64) { - let Some(data) = self.sounds.get(handle).cloned() else { return }; - let volume = self.get_sound_volume(handle); - let (bus, send, lowpass) = self.routing(handle); - self.send(Cmd::PlaySound { - sound_id: handle.to_bits(), data, volume, spatial: None, - bus, send, lowpass, - }); - } - - pub fn play_sound_3d(&mut self, handle: f64, x: f32, y: f32, z: f32) { - let Some(data) = self.sounds.get(handle).cloned() else { return }; + /// Shared play path. Returns the new voice's id (0.0 = unknown sound). + /// `ref_dist`/`rolloff` of 1 with a huge `max_dist` is exactly the + /// pre-EN-062 1/d behaviour, which is what the plain play calls use. + fn send_play( + &mut self, handle: f64, spatial: Option<[f32; 3]>, looping: bool, + ref_dist: f32, max_dist: f32, rolloff: f32, + ) -> f64 { + let Some(data) = self.sounds.get(handle).cloned() else { return 0.0 }; let volume = self.get_sound_volume(handle); let (bus, send, lowpass) = self.routing(handle); + self.next_voice += 1; + let voice_id = self.next_voice; self.send(Cmd::PlaySound { sound_id: handle.to_bits(), + voice_id, data, volume, - spatial: Some([x, y, z]), + spatial, + looping, + ref_dist, + max_dist, + rolloff, + pitch: 1.0, bus, send, lowpass, }); + voice_id as f64 + } + + pub fn play_sound(&mut self, handle: f64) { + self.send_play(handle, None, false, 1.0, 1.0e9, 1.0); + } + + pub fn play_sound_3d(&mut self, handle: f64, x: f32, y: f32, z: f32) { + self.send_play(handle, Some([x, y, z]), false, 1.0, 1.0e9, 1.0); + } + + // ---- EN-062: live emitters ------------------------------------------ + // + // A voice you can hold onto. `play_sound_3d_ex` returns a voice id; the + // id drives position/volume/pitch/low-pass updates and a click-free stop. + // This is what looping ambient emitters (river, wind, a creature's crawl) + // are made of — fire-and-forget can't move and can't loop. + + /// Play with full spatial control. `looping` voices persist until + /// [`Self::stop_voice`]. `ref_dist` is the range that plays at full + /// volume, `rolloff` how hard the level falls past it, `max_dist` where + /// the mixer culls entirely. Returns the voice id (0.0 = unknown sound). + pub fn play_sound_3d_ex( + &mut self, handle: f64, x: f32, y: f32, z: f32, + looping: bool, ref_dist: f32, max_dist: f32, rolloff: f32, + ) -> f64 { + self.send_play( + handle, Some([x, y, z]), looping, + ref_dist.max(1e-3), + if max_dist > 0.0 { max_dist } else { 1.0e9 }, + rolloff.max(0.0), + ) + } + + pub fn set_voice_position(&mut self, voice: f64, x: f32, y: f32, z: f32) { + self.send(Cmd::SetVoicePosition { voice_id: voice as u64, pos: [x, y, z] }); + } + + /// Fades the voice out over one mix block (~10 ms) and removes it — a + /// hard cut mid-waveform on a looping bed is an audible click. + pub fn stop_voice(&mut self, voice: f64) { + self.send(Cmd::StopVoice { voice_id: voice as u64 }); + } + + pub fn set_voice_volume(&mut self, voice: f64, volume: f32) { + self.send(Cmd::SetVoiceVolume { voice_id: voice as u64, volume }); + } + + /// Playback-rate multiplier, clamped 0.25..4. Doppler multiplies on top. + pub fn set_voice_pitch(&mut self, voice: f64, pitch: f32) { + self.send(Cmd::SetVoicePitch { voice_id: voice as u64, pitch }); + } + + /// Per-voice occlusion low-pass (Hz; 0 = bypass). Unlike + /// [`Self::set_sound_lowpass`] this muffles ONE emitter, not every voice + /// sharing the asset. + pub fn set_voice_lowpass(&mut self, voice: f64, cutoff: f32) { + self.send(Cmd::SetVoiceLowpass { voice_id: voice as u64, cutoff }); } // ---- EN-029 routing ------------------------------------------------ @@ -452,9 +519,14 @@ mod tests { let dry = peak(&out); // Duck hard, effectively instantly, and hold well past the block. + // EN-062 — per-voice gains ramp linearly across one mix block (the + // anti-zipper contract), so a gain change fully lands on the block + // AFTER the one it arrives in. Measure the settled block. a.duck_bus(render::bus::SFX, 0.9, 0.0001, 0.5, 1.0); let mut ducked = [0.0f32; 512]; a.mix_output(&mut ducked); + ducked = [0.0f32; 512]; + a.mix_output(&mut ducked); assert!(peak(&ducked) < dry * 0.5, "duck had no effect: {} vs {}", peak(&ducked), dry); } @@ -558,4 +630,234 @@ mod tests { a.mix_output(&mut out); // voice still holds its Arc assert!(out.iter().any(|&s| s != 0.0)); } + + // ---- EN-062: spatial voices ---------------------------------------- + + /// All-0.5 mono signal: peaks read gains directly. + fn flat(len: usize) -> SoundData { + SoundData { samples: vec![0.5; len], sample_rate: 44_100, channels: 1 } + } + + fn sine(freq: f32, len: usize) -> SoundData { + SoundData { + samples: (0..len) + .map(|i| (2.0 * std::f32::consts::PI * freq * i as f32 / 44_100.0).sin()) + .collect(), + sample_rate: 44_100, + channels: 1, + } + } + + fn peak_lr(buf: &[f32]) -> (f32, f32) { + let mut l = 0.0f32; + let mut r = 0.0f32; + let mut i = 0; + while i + 1 < buf.len() { + l = l.max(buf[i].abs()); + r = r.max(buf[i + 1].abs()); + i += 2; + } + (l, r) + } + + #[test] + fn looping_voice_persists_until_stop_voice() { + let mut a = AudioMixer::new(); + let h = a.load_sound(flat(64)); // 64 frames — far shorter than a block + // Listener at origin looking down -Z; source dead ahead at 1 m. + let v = a.play_sound_3d_ex(h, 0.0, 0.0, -1.0, true, 1.0, 0.0, 1.0); + assert!(v > 0.0, "no voice id returned"); + let mut out = [0.0f32; 512]; + for _ in 0..10 { + out = [0.0f32; 512]; + a.mix_output(&mut out); + } + assert!(peak(&out) > 0.1, "looping voice died before StopVoice"); + a.stop_voice(v); + // Fade block, then confirmed-silent blocks. + for _ in 0..3 { + out = [0.0f32; 512]; + a.mix_output(&mut out); + } + assert_eq!(peak(&out), 0.0, "voice still audible after StopVoice"); + } + + #[test] + fn moving_a_voice_crosses_the_stereo_field() { + let mut a = AudioMixer::new(); + let h = a.load_sound(flat(1 << 16)); + // Screen-left for a -Z-forward listener is -X (right = cross(f, up) = +X). + let v = a.play_sound_3d_ex(h, -10.0, 0.0, 0.0, true, 1.0, 0.0, 1.0); + let mut out = [0.0f32; 512]; + a.mix_output(&mut out); + let (l1, r1) = peak_lr(&out); + assert!(l1 > r1 * 2.0, "left source not left-dominant: L={} R={}", l1, r1); + + a.set_voice_position(v, 10.0, 0.0, 0.0); + // One block to ramp, one to settle. + for _ in 0..2 { + out = [0.0f32; 512]; + a.mix_output(&mut out); + } + let (l2, r2) = peak_lr(&out); + assert!(r2 > l2 * 2.0, "moved source not right-dominant: L={} R={}", l2, r2); + } + + #[test] + fn default_distance_model_is_still_one_over_d() { + // play_sound_3d (the pre-EN-062 API) must keep its loudness curve. + let run = |dist: f32| -> f32 { + let mut a = AudioMixer::new(); + let h = a.load_sound(flat(1 << 16)); + a.play_sound_3d(h, 0.0, 0.0, -dist); + let mut out = [0.0f32; 512]; + a.mix_output(&mut out); + let (l, r) = peak_lr(&out); + (l * l + r * r).sqrt() // combined energy — pan-independent + }; + let near = run(1.0); + let far = run(10.0); + let ratio = far / near; + assert!((ratio - 0.1).abs() < 0.02, + "distance curve changed: 10m/1m = {} (want ~0.1)", ratio); + } + + #[test] + fn centered_pan_is_equal_power() { + let mut a = AudioMixer::new(); + let h = a.load_sound(flat(1 << 16)); + a.play_sound_3d(h, 0.0, 0.0, -1.0); // dead ahead, 1 m → att 1 + let mut out = [0.0f32; 512]; + a.mix_output(&mut out); + let (l, r) = peak_lr(&out); + // 0.5 sample × cos(π/4) ≈ 0.3536 per channel (linear pan gave 0.25). + assert!((l - r).abs() < 0.01, "center source unbalanced: L={} R={}", l, r); + assert!(l > 0.32 && l < 0.39, "not equal-power: L={} (want ~0.354)", l); + } + + #[test] + fn air_absorption_dulls_with_distance() { + // Nyquist tone: any low-pass crushes it. Normalise by the distance + // gain so only the FILTER is compared. + let run = |dist: f32| -> f32 { + let mut a = AudioMixer::new(); + let h = a.load_sound(tone(1 << 16)); + a.play_sound_3d(h, 0.0, 0.0, -dist); + let mut out = [0.0f32; 2048]; + a.mix_output(&mut out); + let att = 1.0 / dist; + peak(&out) / att + }; + let near = run(2.0); + let far = run(150.0); + assert!(far < near * 0.7, + "no air absorption: near(norm)={} far(norm)={}", near, far); + } + + #[test] + fn rear_sources_are_darker_and_dipped() { + let run = |z: f32| -> f32 { + let mut a = AudioMixer::new(); + let h = a.load_sound(tone(1 << 16)); + a.play_sound_3d(h, 0.0, 0.0, z); // forward is -Z: -5 ahead, +5 behind + let mut out = [0.0f32; 2048]; + a.mix_output(&mut out); + peak(&out) + }; + let front = run(-5.0); + let behind = run(5.0); + assert!(behind < front * 0.6, + "rear cue missing: front={} behind={}", front, behind); + } + + #[test] + fn voice_pitch_scales_playback_rate() { + // A 1000-frame one-shot at pitch 2 must be finished by ~500 output + // frames; at pitch 1 it must still be sounding there. + let run = |pitch: f32| -> f32 { + let mut a = AudioMixer::new(); + let h = a.load_sound(flat(1000)); + let v = a.play_sound_3d_ex(h, 0.0, 0.0, -1.0, false, 1.0, 0.0, 1.0); + a.set_voice_pitch(v, pitch); + let mut first = [0.0f32; 1024]; // frames 0..512 + a.mix_output(&mut first); + let mut second = [0.0f32; 1024]; // frames 512..1024 + a.mix_output(&mut second); + peak(&second) + }; + assert!(run(1.0) > 0.1, "pitch-1 voice ended early"); + assert_eq!(run(2.0), 0.0, "pitch-2 voice still sounding past its data"); + } + + #[test] + fn doppler_raises_the_pitch_of_an_approaching_source() { + let crossings = |approach: bool| -> usize { + let mut a = AudioMixer::new(); + let h = a.load_sound(sine(500.0, 1 << 17)); + let mut z = -60.0f32; + let v = a.play_sound_3d_ex(h, 0.0, 0.0, z, true, 1.0, 0.0, 1.0); + let mut n = 0usize; + let mut last = 0.0f32; + for block in 0..40 { + if approach { + // 0.25 m per 256-frame block ≈ 43 m/s toward the listener. + z += 0.25; + a.set_voice_position(v, 0.0, 0.0, z); + } + let mut out = [0.0f32; 512]; + a.mix_output(&mut out); + if block < 10 { continue; } // let the smoothed rate settle + let mut i = 0; + while i < out.len() { + let s = out[i]; // left channel + if s != 0.0 { + if last != 0.0 && (s > 0.0) != (last > 0.0) { n += 1; } + last = s; + } + i += 2; + } + } + n + }; + let moving = crossings(true); + let still = crossings(false); + assert!(moving as f32 > still as f32 * 1.05, + "no doppler: approaching={} static={}", moving, still); + } + + #[test] + fn max_dist_culls_but_the_loop_survives() { + let mut a = AudioMixer::new(); + let h = a.load_sound(flat(1 << 12)); + let v = a.play_sound_3d_ex(h, 0.0, 0.0, -500.0, true, 1.0, 100.0, 1.0); + let mut out = [0.0f32; 512]; + a.mix_output(&mut out); + assert_eq!(peak(&out), 0.0, "voice audible past max_dist"); + // Walk into range: the same voice comes back. + a.set_voice_position(v, 0.0, 0.0, -5.0); + for _ in 0..2 { + out = [0.0f32; 512]; + a.mix_output(&mut out); + } + assert!(peak(&out) > 0.01, "culled loop never came back in range"); + } + + #[test] + fn per_voice_lowpass_muffles_one_emitter_only() { + let mut a = AudioMixer::new(); + let h = a.load_sound(tone(1 << 16)); + let muffled = a.play_sound_3d_ex(h, -2.0, 0.0, -2.0, true, 1.0, 0.0, 1.0); + a.set_voice_lowpass(muffled, 300.0); + let mut solo = [0.0f32; 2048]; + a.mix_output(&mut solo); + let muffled_peak = peak(&solo); + + let mut b = AudioMixer::new(); + let h2 = b.load_sound(tone(1 << 16)); + b.play_sound_3d_ex(h2, -2.0, 0.0, -2.0, true, 1.0, 0.0, 1.0); + let mut open = [0.0f32; 2048]; + b.mix_output(&mut open); + assert!(muffled_peak < peak(&open) * 0.3, + "voice lowpass had no effect: {} vs {}", muffled_peak, peak(&open)); + } } diff --git a/native/shared/src/audio/render.rs b/native/shared/src/audio/render.rs index 8000115..a40f218 100644 --- a/native/shared/src/audio/render.rs +++ b/native/shared/src/audio/render.rs @@ -9,6 +9,42 @@ //! Sample data is shared with the control side via `Arc` — a //! sound unloaded mid-playback keeps its samples alive until the last //! voice playing it finishes, then the Arc drops on this thread. +//! +//! # Spatialization (EN-062) +//! +//! Every voice carries a stable `voice_id`, so a playing voice can be +//! moved, re-volumed, re-pitched, filtered or stopped after the trigger — +//! that is what turns "a sound played at a point" into an *emitter* (a +//! river you walk along, a creature circling you). Spatial voices get: +//! +//! - **Inverse-clamped distance model** — `ref / (ref + rolloff·(d−ref))`, +//! which with ref=1, rolloff=1 is exactly the old 1/d curve, so the +//! pre-EN-062 API keeps its loudness. Past `max_dist` the voice is +//! culled from the mix but keeps its playback head advancing. +//! - **Equal-power panning** (was linear, which dipped centered sources +//! 6 dB and made walk-bys "swell" at the ears). Width is 0.85: a real +//! head leaks sound around itself; a 100%-one-ear source reads as a +//! headphone artifact, not a position. +//! - **Air absorption** — a distance-driven low-pass (20 kHz at the ear +//! falling exponentially with range). Distant gunfire is dull *before* +//! it is quiet; that ordering is most of what "far away" sounds like. +//! - **Rear cue** — sources behind the listener are low-passed toward +//! ~4.5 kHz and dipped ~1.5 dB. Cheap head-shadow approximation; it is +//! the difference between "somewhere" and "behind you". +//! - **Doppler** — playback rate bends with radial velocity (343 m/s +//! speed of sound, clamped, smoothed). Computed from the *distance +//! delta* per block, so listener motion contributes symmetrically. A +//! teleporting emitter (pool voice re-targeted to a new enemy) is +//! detected by an impossible radial speed and resets cleanly instead +//! of chirping. +//! +//! All per-voice gains ramp linearly across each mix block — a moving +//! emitter or a per-frame volume ride must never zipper or click. +//! +//! Voices resample with linear interpolation (`frame_pos` is fractional): +//! this is what makes doppler/pitch possible, and it also plays each +//! asset at its *authored* rate — previously a 44.1 kHz file on a 48 kHz +//! device played ~9% fast and sharp. Music streams are untouched. use super::spsc::Consumer; #[cfg(not(target_arch = "wasm32"))] @@ -23,11 +59,21 @@ use std::sync::Arc; pub enum Cmd { PlaySound { sound_id: u64, + /// Stable per-play id — the handle for every SetVoice*/StopVoice. + voice_id: u64, data: Arc, volume: f32, /// Some(world position) for spatial sounds. spatial: Option<[f32; 3]>, - /// EN-029 — mix bus (see [`Bus`]), reverb send (0..1) and low-pass + /// EN-062 — loop the sample seamlessly until StopVoice/StopSound. + looping: bool, + /// EN-062 — distance model. ref=1, rolloff=1 == the classic 1/d. + ref_dist: f32, + max_dist: f32, + rolloff: f32, + /// EN-062 — playback-rate multiplier (doppler multiplies on top). + pitch: f32, + /// EN-029 — mix bus (see [`bus`]), reverb send (0..1) and low-pass /// cutoff in Hz (<= 0 or >= NYQUIST = bypass). bus: u8, send: f32, @@ -57,6 +103,17 @@ pub enum Cmd { /// raycasts and decides; the mixer just filters. SetSoundLowpass { sound_id: u64, cutoff: f32 }, SetSoundSend { sound_id: u64, send: f32 }, + + // ---- EN-062: live-voice control ------------------------------------ + SetVoicePosition { voice_id: u64, pos: [f32; 3] }, + /// Fades out over ~1 block and removes — a hard cut on a looping bed + /// mid-waveform is an audible click. + StopVoice { voice_id: u64 }, + SetVoiceVolume { voice_id: u64, volume: f32 }, + SetVoicePitch { voice_id: u64, pitch: f32 }, + /// Per-VOICE occlusion; SetSoundLowpass muffles every voice of a sound, + /// which is wrong the moment two emitters share an asset. + SetVoiceLowpass { voice_id: u64, cutoff: f32 }, } /// Mix buses. Kept tiny and fixed: a general submix graph is a lot of @@ -119,12 +176,41 @@ impl BusState { fn current(&self) -> f32 { (self.gain * (1.0 - self.duck)).clamp(0.0, 4.0) } } +/// Speed of sound, m/s — the doppler constant. +const SOUND_SPEED: f32 = 343.0; +/// A radial speed past this is a teleport (voice re-targeted), not motion: +/// reset doppler instead of bending pitch through the jump. +const DOPPLER_TELEPORT: f32 = 150.0; +/// Cutoffs above this bypass the one-pole entirely (inaudible + not free). +const LP_ENGAGE_HZ: f32 = 16_000.0; + struct Voice { sound_id: u64, + voice_id: u64, data: Arc, - position: usize, + /// Playback head in FRAMES, fractional — doppler and pitch resample. + frame_pos: f64, volume: f32, spatial: Option<[f32; 3]>, + looping: bool, + ref_dist: f32, + max_dist: f32, + rolloff: f32, + /// Game-set playback rate. Doppler multiplies on top of it. + pitch: f32, + /// Smoothed doppler rate. + doppler: f32, + /// Listener distance last block; < 0 = not yet seeded. + prev_dist: f32, + /// Current combined per-channel gains (spatial × volume × master × bus), + /// ramped toward each block's target so moving emitters never zipper. + g_l: f32, + g_r: f32, + /// False until the first block computes real targets (skip the ramp-in; + /// the asset's own attack handles the onset). + seeded: bool, + /// StopVoice: fade to silence over a block, then drop. + stopping: bool, bus: u8, send: f32, /// Low-pass cutoff, Hz. <= 0 = bypass. @@ -133,6 +219,19 @@ struct Voice { lp_z: [f32; 2], } +impl Voice { + /// Read frame `idx` as stereo (mono duplicates). + #[inline] + fn frame(&self, idx: usize) -> (f32, f32) { + if self.data.channels <= 1 { + let s = self.data.samples[idx]; + (s, s) + } else { + (self.data.samples[idx * 2], self.data.samples[idx * 2 + 1]) + } + } +} + /// How a music voice gets its samples. pub enum MusicPayload { Full(Arc), @@ -277,9 +376,20 @@ impl AudioRenderer { fn apply(&mut self, cmd: Cmd) { match cmd { - Cmd::PlaySound { sound_id, data, volume, spatial, bus, send, lowpass } => { + Cmd::PlaySound { + sound_id, voice_id, data, volume, spatial, looping, + ref_dist, max_dist, rolloff, pitch, bus, send, lowpass, + } => { self.voices.push(Voice { - sound_id, data, position: 0, volume, spatial, + sound_id, voice_id, data, frame_pos: 0.0, volume, spatial, + looping, + ref_dist: ref_dist.max(1e-3), + max_dist: max_dist.max(0.0), + rolloff: rolloff.max(0.0), + pitch: pitch.clamp(0.25, 4.0), + doppler: 1.0, + prev_dist: -1.0, + g_l: 0.0, g_r: 0.0, seeded: false, stopping: false, bus, send, lowpass, lp_z: [0.0; 2], }); } @@ -357,6 +467,31 @@ impl AudioRenderer { if v.sound_id == sound_id { v.send = send.clamp(0.0, 1.0); } } } + Cmd::SetVoicePosition { voice_id, pos } => { + for v in &mut self.voices { + if v.voice_id == voice_id { v.spatial = Some(pos); } + } + } + Cmd::StopVoice { voice_id } => { + for v in &mut self.voices { + if v.voice_id == voice_id { v.stopping = true; } + } + } + Cmd::SetVoiceVolume { voice_id, volume } => { + for v in &mut self.voices { + if v.voice_id == voice_id { v.volume = volume.max(0.0); } + } + } + Cmd::SetVoicePitch { voice_id, pitch } => { + for v in &mut self.voices { + if v.voice_id == voice_id { v.pitch = pitch.clamp(0.25, 4.0); } + } + } + Cmd::SetVoiceLowpass { voice_id, cutoff } => { + for v in &mut self.voices { + if v.voice_id == voice_id { v.lowpass = cutoff.max(0.0); } + } + } } } @@ -375,7 +510,8 @@ impl AudioRenderer { // EN-029 — advance the per-bus duck envelopes once per block. Block // granularity is ~1-10 ms, far finer than any duck the ear resolves. - let block_dt = (output.len() as f32 / 2.0) / self.sample_rate; + let out_frames = output.len() / 2; + let block_dt = (out_frames as f32).max(1.0) / self.sample_rate; for b in self.buses.iter_mut() { b.advance(block_dt); } @@ -398,9 +534,12 @@ impl AudioRenderer { // Spatial audio: listener-relative parameters, computed once. let [lx, ly, lz] = self.listener_pos; let [lfx, _lfy, lfz] = self.listener_forward; // "right" math projects out Y - // Listener right vector (cross of forward and up=[0,1,0]) - let lrx = lfz; - let lrz = -lfx; + // Listener right = cross(forward, up=[0,1,0]) = (-fz, 0, fx) — the SAME + // vector mat4_look_at calls `s` (screen-right). EN-062 flipped the sign + // here: this used to be (fz, -fx), which is screen-LEFT, so the whole + // stereo field was mirrored — a shriek on screen-left panned right. + let lrx = -lfz; + let lrz = lfx; let lr_len = (lrx * lrx + lrz * lrz).sqrt().max(0.001); let master = self.master; let sample_rate = self.sample_rate; @@ -412,53 +551,125 @@ impl AudioRenderer { // Sound effects voices.retain_mut(|v| { - let sound = &v.data; - - let (gain_l, gain_r) = if let Some([sx, sy, sz]) = v.spatial { + let channels = v.data.channels.max(1) as usize; + let frames = v.data.samples.len() / channels; + if frames == 0 { return false; } + + // ---- per-block spatial targets -------------------------------- + // Manual (occlusion) low-pass; spatial cues can only lower it. + let mut cutoff = if v.lowpass > 0.0 { v.lowpass } else { f32::MAX }; + let (sg_l, sg_r, dist) = if let Some([sx, sy, sz]) = v.spatial { let dx = sx - lx; let dy = sy - ly; let dz = sz - lz; - let dist = (dx * dx + dy * dy + dz * dz).sqrt().max(0.1); - // Distance attenuation: 1/distance, clamped - let attenuation = (1.0 / dist).min(1.0); - // Pan: dot of source direction with listener right - let pan = ((dx * lrx + dz * lrz) / (dist * lr_len)).clamp(-1.0, 1.0); - (attenuation * (1.0 - pan) * 0.5, attenuation * (1.0 + pan) * 0.5) + let dist = (dx * dx + dy * dy + dz * dz).sqrt().max(1e-4); + // Inverse-clamped distance model. ref=1, rolloff=1 == the old + // 1/d exactly, so pre-EN-062 callers keep their loudness. + let refd = v.ref_dist; + let att = if dist > v.max_dist { + 0.0 + } else { + (refd / (refd + v.rolloff * (dist.max(refd) - refd))).min(1.0) + }; + // Equal-power pan from the horizontal azimuth, at 0.85 width + // (a head is not an infinite baffle). + let pan = ((dx * lrx + dz * lrz) / (dist * lr_len)).clamp(-1.0, 1.0) * 0.85; + let theta = (pan + 1.0) * std::f32::consts::FRAC_PI_4; + let mut gl = att * theta.cos(); + let mut gr = att * theta.sin(); + // Air absorption: distance dulls before it silences. + cutoff = cutoff.min(20_000.0 * (-0.006 * dist).exp()); + // Rear cue: head shadow behind the listener — darker and a + // touch quieter. Horizontal only; overhead stays neutral. + let f_len2 = lfx * lfx + lfz * lfz; + if f_len2 > 1e-6 { + let back = (-(dx * lfx + dz * lfz) / (dist * f_len2.sqrt())).clamp(0.0, 1.0); + if back > 0.0 { + cutoff = cutoff.min(18_000.0 - 13_500.0 * back); + let dip = 1.0 - 0.15 * back; + gl *= dip; + gr *= dip; + } + } + (gl, gr, dist) } else { - (1.0, 1.0) + (1.0, 1.0, -1.0) }; + // ---- doppler --------------------------------------------------- + // From the block-to-block distance delta, so listener motion and + // source motion both count. Impossible speeds are teleports + // (re-targeted pool voices) — snap, don't chirp. + if dist >= 0.0 { + if v.prev_dist >= 0.0 { + let vr = (dist - v.prev_dist) / block_dt.max(1e-4); + if vr.abs() > DOPPLER_TELEPORT { + v.doppler = 1.0; + } else { + let target = (SOUND_SPEED / (SOUND_SPEED + vr)).clamp(0.5, 2.0); + v.doppler += (target - v.doppler) * 0.25; + } + } + v.prev_dist = dist; + } + let step = (v.pitch * v.doppler) as f64 + * (v.data.sample_rate.max(8_000) as f64 / sample_rate as f64); + + // ---- combined gain targets, ramped across the block ------------ let bg = bus_gain[(v.bus as usize).min(bus::COUNT - 1)]; - let vol_l = v.volume * master * bg * gain_l; - let vol_r = v.volume * master * bg * gain_r; - - // One-pole low-pass coefficient. This is the occlusion knob: a - // muffled source is a source behind a wall, and it reads far more - // like geometry than simply turning the volume down does. - let lp_a = if v.lowpass > 0.0 && v.lowpass < sample_rate * 0.5 { - let x = (-2.0 * std::f32::consts::PI * v.lowpass / sample_rate).exp(); - Some(x) + let (t_l, t_r) = if v.stopping { + (0.0, 0.0) + } else { + (sg_l * v.volume * master * bg, sg_r * v.volume * master * bg) + }; + if !v.seeded { + v.g_l = t_l; + v.g_r = t_r; + v.seeded = true; + } + + // Fully silent (culled by distance, or a zero-volume ambient bed): + // advance the head arithmetically and skip the per-sample work. + if t_l < 1e-5 && t_r < 1e-5 && v.g_l < 1e-5 && v.g_r < 1e-5 { + if v.stopping { return false; } + v.g_l = t_l; + v.g_r = t_r; + v.frame_pos += step * out_frames as f64; + if v.frame_pos >= frames as f64 { + if v.looping { + v.frame_pos %= frames as f64; + } else { + return false; + } + } + return true; + } + + let inv = 1.0 / (out_frames as f32).max(1.0); + let dg_l = (t_l - v.g_l) * inv; + let dg_r = (t_r - v.g_r) * inv; + + // One-pole low-pass coefficient: occlusion, air and rear cues all + // fold into one cutoff. Muffling reads as geometry/distance in a + // way that turning the volume down never does. + let lp_a = if cutoff < LP_ENGAGE_HZ.min(sample_rate * 0.45) { + Some((-2.0 * std::f32::consts::PI * cutoff / sample_rate).exp()) } else { None }; let send = v.send; - let mut i = 0; - while i < output.len() && v.position < sound.samples.len() { - let (mut sl, mut sr) = if sound.channels == 1 { - let s = sound.samples[v.position]; - v.position += 1; - (s, s) - } else { - let l = sound.samples[v.position]; - v.position += 1; - let r = if v.position < sound.samples.len() { - let r = sound.samples[v.position]; - v.position += 1; - r - } else { l }; - (l, r) - }; + let mut ended = false; + let mut f = 0usize; + while f < out_frames { + let idx = v.frame_pos as usize; + if idx >= frames { ended = !v.looping; break; } + let frac = (v.frame_pos - idx as f64) as f32; + let (s0l, s0r) = v.frame(idx); + let nidx = if idx + 1 < frames { idx + 1 } else if v.looping { 0 } else { idx }; + let (s1l, s1r) = v.frame(nidx); + let mut sl = s0l + (s1l - s0l) * frac; + let mut sr = s0r + (s1r - s0r) * frac; if let Some(a) = lp_a { v.lp_z[0] = sl * (1.0 - a) + v.lp_z[0] * a; @@ -467,8 +678,9 @@ impl AudioRenderer { sr = v.lp_z[1]; } - let ol = sl * vol_l; - let or = sr * vol_r; + let i = f * 2; + let ol = sl * v.g_l; + let or = sr * v.g_r; output[i] += ol; if i + 1 < output.len() { output[i + 1] += or; } @@ -476,9 +688,29 @@ impl AudioRenderer { send_buf[i] += ol * send; if i + 1 < output.len() { send_buf[i + 1] += or * send; } } - i += 2; + + v.g_l += dg_l; + v.g_r += dg_r; + v.frame_pos += step; + if v.frame_pos >= frames as f64 { + if v.looping { + v.frame_pos -= frames as f64; + } else { + ended = true; + break; + } + } + f += 1; + } + if !ended { + // Land exactly on the target — no float drift across blocks. + v.g_l = t_l; + v.g_r = t_r; + } + if v.stopping && v.g_l < 1e-4 && v.g_r < 1e-4 { + return false; } - v.position < sound.samples.len() + !ended }); // Wet return. Processed after the dry voices so every send this block diff --git a/native/shared/src/ffi_core/audio_ffi.rs b/native/shared/src/ffi_core/audio_ffi.rs index b25508e..d9db48e 100644 --- a/native/shared/src/ffi_core/audio_ffi.rs +++ b/native/shared/src/ffi_core/audio_ffi.rs @@ -115,5 +115,70 @@ macro_rules! __bloom_ffi_audio_ffi { }) } + // ---- EN-062: live spatial voices --------------------------------- + // + // bloom_play_sound_3d is fire-and-forget: it cannot loop and it cannot + // move. These six calls are the emitter API — a voice id you hold onto + // and steer. A river you walk along, wind in a treeline, a creature + // circling closer: all of them are one looping voice plus a per-frame + // position/volume ride. + + // bloom_play_sound_3d_ex — returns the voice id (0 = unknown sound). + // looping != 0 loops until bloom_voice_stop. ref_dist = full-volume + // range, rolloff = fall-off steepness (1,1 == the classic 1/d), + // max_dist = cull range (<= 0 = never cull). + #[no_mangle] + pub extern "C" fn bloom_play_sound_3d_ex( + handle: f64, x: f64, y: f64, z: f64, + looping: f64, ref_dist: f64, max_dist: f64, rolloff: f64, + ) -> f64 { + $crate::ffi::guard("bloom_play_sound_3d_ex", move || { + engine().audio.play_sound_3d_ex( + handle, x as f32, y as f32, z as f32, + looping != 0.0, ref_dist as f32, max_dist as f32, rolloff as f32) + }) + } + + // bloom_voice_set_position — move a live voice (doppler falls out of + // the motion automatically). + #[no_mangle] + pub extern "C" fn bloom_voice_set_position(voice: f64, x: f64, y: f64, z: f64) { + $crate::ffi::guard("bloom_voice_set_position", move || { + engine().audio.set_voice_position(voice, x as f32, y as f32, z as f32); + }) + } + + // bloom_voice_stop — click-free: fades over one mix block, then drops. + #[no_mangle] + pub extern "C" fn bloom_voice_stop(voice: f64) { + $crate::ffi::guard("bloom_voice_stop", move || { + engine().audio.stop_voice(voice); + }) + } + + // bloom_voice_set_volume + #[no_mangle] + pub extern "C" fn bloom_voice_set_volume(voice: f64, volume: f64) { + $crate::ffi::guard("bloom_voice_set_volume", move || { + engine().audio.set_voice_volume(voice, volume as f32); + }) + } + + // bloom_voice_set_pitch — 0.25..4 playback-rate multiplier. + #[no_mangle] + pub extern "C" fn bloom_voice_set_pitch(voice: f64, pitch: f64) { + $crate::ffi::guard("bloom_voice_set_pitch", move || { + engine().audio.set_voice_pitch(voice, pitch as f32); + }) + } + + // bloom_voice_set_lowpass — per-VOICE occlusion (Hz; 0 = bypass). + #[no_mangle] + pub extern "C" fn bloom_voice_set_lowpass(voice: f64, cutoff: f64) { + $crate::ffi::guard("bloom_voice_set_lowpass", move || { + engine().audio.set_voice_lowpass(voice, cutoff as f32); + }) + } + }; } diff --git a/native/watchos/src/ffi_stubs.rs b/native/watchos/src/ffi_stubs.rs index 646617b..13625ce 100644 --- a/native/watchos/src/ffi_stubs.rs +++ b/native/watchos/src/ffi_stubs.rs @@ -34,6 +34,19 @@ } #[no_mangle] pub extern "C" fn bloom_set_listener_position(_p0: f64, _p1: f64, _p2: f64, _p3: f64, _p4: f64, _p5: f64) { } +#[no_mangle] pub extern "C" fn bloom_play_sound_3d_ex(_p0: f64, _p1: f64, _p2: f64, _p3: f64, _p4: f64, _p5: f64, _p6: f64, _p7: f64) -> f64 { + 0.0 +} +#[no_mangle] pub extern "C" fn bloom_voice_set_position(_p0: f64, _p1: f64, _p2: f64, _p3: f64) { +} +#[no_mangle] pub extern "C" fn bloom_voice_stop(_p0: f64) { +} +#[no_mangle] pub extern "C" fn bloom_voice_set_volume(_p0: f64, _p1: f64) { +} +#[no_mangle] pub extern "C" fn bloom_voice_set_pitch(_p0: f64, _p1: f64) { +} +#[no_mangle] pub extern "C" fn bloom_voice_set_lowpass(_p0: f64, _p1: f64) { +} #[no_mangle] pub extern "C" fn bloom_load_image(_p0: i64) -> f64 { 0.0 } @@ -162,10 +175,12 @@ } #[no_mangle] pub extern "C" fn bloom_draw_material(_p0: f64, _p1: f64, _p2: f64, _p3: f64, _p4: f64, _p5: f64, _p6: f64, _p7: f64, _p8: f64, _p9: f64, _p10: f64) { } -#[no_mangle] pub extern "C" fn bloom_instantiate_animation(_p0: f64) -> f64 { 0.0 } #[no_mangle] pub extern "C" fn bloom_load_model_animation(_p0: i64) -> f64 { 0.0 } +#[no_mangle] pub extern "C" fn bloom_instantiate_animation(_p0: f64) -> f64 { + 0.0 +} #[no_mangle] pub extern "C" fn bloom_update_model_animation(_p0: f64, _p1: f64, _p2: f64, _p3: f64, _p4: f64, _p5: f64, _p6: f64, _p7: f64) { } #[no_mangle] pub extern "C" fn bloom_anim_play(_p0: f64, _p1: f64, _p2: f64, _p3: f64, _p4: f64) { diff --git a/native/web/src/lib.rs b/native/web/src/lib.rs index 1d64a02..228afa8 100644 --- a/native/web/src/lib.rs +++ b/native/web/src/lib.rs @@ -879,6 +879,43 @@ pub fn bloom_set_listener_position(x: f64, y: f64, z: f64, fx: f64, fy: f64, fz: engine().audio.set_listener_position(x as f32, y as f32, z as f32, fx as f32, fy as f32, fz as f32); } +// ---- EN-062: live spatial voices (see shared audio_ffi.rs for docs) -------- + +#[wasm_bindgen] +pub fn bloom_play_sound_3d_ex( + handle: f64, x: f64, y: f64, z: f64, + looping: f64, ref_dist: f64, max_dist: f64, rolloff: f64, +) -> f64 { + engine().audio.play_sound_3d_ex( + handle, x as f32, y as f32, z as f32, + looping != 0.0, ref_dist as f32, max_dist as f32, rolloff as f32) +} + +#[wasm_bindgen] +pub fn bloom_voice_set_position(voice: f64, x: f64, y: f64, z: f64) { + engine().audio.set_voice_position(voice, x as f32, y as f32, z as f32); +} + +#[wasm_bindgen] +pub fn bloom_voice_stop(voice: f64) { + engine().audio.stop_voice(voice); +} + +#[wasm_bindgen] +pub fn bloom_voice_set_volume(voice: f64, volume: f64) { + engine().audio.set_voice_volume(voice, volume as f32); +} + +#[wasm_bindgen] +pub fn bloom_voice_set_pitch(voice: f64, pitch: f64) { + engine().audio.set_voice_pitch(voice, pitch as f32); +} + +#[wasm_bindgen] +pub fn bloom_voice_set_lowpass(voice: f64, cutoff: f64) { + engine().audio.set_voice_lowpass(voice, cutoff as f32); +} + #[wasm_bindgen] pub fn bloom_load_music(_path: f64) -> f64 { 0.0 } diff --git a/native/windows/src/lib.rs b/native/windows/src/lib.rs index b487f62..b8c77f0 100644 --- a/native/windows/src/lib.rs +++ b/native/windows/src/lib.rs @@ -1121,6 +1121,13 @@ unsafe fn wasapi_audio_thread(mut renderer: Option