use glam::Vec2;
const SAMPLE_RATE: f32 = 48000.0;
const MAX_SOURCES: usize = 32;
const _SPEED_OF_SOUND: f32 = 343.0;
#[derive(Debug, Clone, Copy)]
pub struct StereoPan {
pub left: f32,
pub right: f32,
}
impl StereoPan {
pub fn center() -> Self { Self { left: 1.0, right: 1.0 } }
pub fn from_position(x: f32, arena_half_width: f32) -> Self {
let hw = arena_half_width.max(0.1);
let pan = (x / hw).clamp(-1.0, 1.0);
let angle = (pan + 1.0) * 0.25 * std::f32::consts::PI; Self {
left: angle.cos(),
right: angle.sin(),
}
}
pub fn from_world_pos(source: Vec2, listener: Vec2, arena_half_width: f32) -> Self {
Self::from_position(source.x - listener.x, arena_half_width)
}
pub fn with_vertical_bias(mut self, source_y: f32, listener_y: f32) -> Self {
let dy = source_y - listener_y;
if dy > 0.5 {
let spread = (dy * 0.1).min(0.15);
self.left = (self.left + spread).min(1.0);
self.right = (self.right + spread).min(1.0);
} else if dy < -0.5 {
let narrow = (-dy * 0.05).min(0.1);
let center = (self.left + self.right) * 0.5;
self.left = self.left + (center - self.left) * narrow;
self.right = self.right + (center - self.right) * narrow;
}
self
}
}
#[derive(Debug, Clone, Copy)]
pub struct DistanceModel {
pub ref_distance: f32,
pub max_distance: f32,
pub rolloff: f32,
}
impl Default for DistanceModel {
fn default() -> Self {
Self {
ref_distance: 1.0,
max_distance: 20.0,
rolloff: 1.0,
}
}
}
impl DistanceModel {
pub fn attenuation(&self, distance: f32) -> f32 {
if distance <= self.ref_distance {
return 1.0;
}
if distance >= self.max_distance {
return 0.0;
}
let d = distance.max(self.ref_distance);
let gain = self.ref_distance / (self.ref_distance + self.rolloff * (d - self.ref_distance));
gain.clamp(0.0, 1.0)
}
pub fn apply(&self, source: Vec2, listener: Vec2, arena_half_width: f32) -> (StereoPan, f32) {
let dist = (source - listener).length();
let gain = self.attenuation(dist);
let pan = StereoPan::from_world_pos(source, listener, arena_half_width);
(pan, gain)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RoomType {
Combat,
Boss,
Cathedral,
Shop,
Corridor,
None,
}
#[derive(Debug, Clone)]
pub struct ReverbParams {
pub comb_delays: [usize; 4],
pub comb_feedback: [f32; 4],
pub allpass_delays: [usize; 2],
pub allpass_feedback: f32,
pub wet_mix: f32,
pub pre_delay: usize,
pub damping: f32,
}
impl ReverbParams {
pub fn from_room(room: RoomType) -> Self {
match room {
RoomType::Combat => Self {
comb_delays: [ms(22.0), ms(25.0), ms(28.0), ms(31.0)],
comb_feedback: [0.60, 0.58, 0.56, 0.54],
allpass_delays: [ms(5.0), ms(1.7)],
allpass_feedback: 0.5,
wet_mix: 0.15,
pre_delay: ms(2.0),
damping: 0.6,
},
RoomType::Boss => Self {
comb_delays: [ms(40.0), ms(45.0), ms(50.0), ms(55.0)],
comb_feedback: [0.80, 0.78, 0.76, 0.74],
allpass_delays: [ms(8.0), ms(3.0)],
allpass_feedback: 0.6,
wet_mix: 0.30,
pre_delay: ms(8.0),
damping: 0.35,
},
RoomType::Cathedral => Self {
comb_delays: [ms(60.0), ms(68.0), ms(75.0), ms(82.0)],
comb_feedback: [0.88, 0.86, 0.84, 0.82],
allpass_delays: [ms(12.0), ms(4.0)],
allpass_feedback: 0.7,
wet_mix: 0.45,
pre_delay: ms(15.0),
damping: 0.2,
},
RoomType::Shop => Self {
comb_delays: [ms(30.0), ms(34.0), ms(37.0), ms(40.0)],
comb_feedback: [0.65, 0.63, 0.61, 0.59],
allpass_delays: [ms(6.0), ms(2.0)],
allpass_feedback: 0.55,
wet_mix: 0.20,
pre_delay: ms(4.0),
damping: 0.5,
},
RoomType::Corridor => Self {
comb_delays: [ms(15.0), ms(18.0), ms(20.0), ms(23.0)],
comb_feedback: [0.50, 0.48, 0.46, 0.44],
allpass_delays: [ms(3.0), ms(1.0)],
allpass_feedback: 0.45,
wet_mix: 0.10,
pre_delay: ms(1.0),
damping: 0.7,
},
RoomType::None => Self {
comb_delays: [1, 1, 1, 1],
comb_feedback: [0.0; 4],
allpass_delays: [1, 1],
allpass_feedback: 0.0,
wet_mix: 0.0,
pre_delay: 0,
damping: 0.0,
},
}
}
}
fn ms(milliseconds: f32) -> usize {
(milliseconds * SAMPLE_RATE / 1000.0).round() as usize
}
struct CombFilter {
buffer: Vec<f32>,
write_pos: usize,
feedback: f32,
damping: f32,
damp_state: f32,
}
impl CombFilter {
fn new(delay: usize, feedback: f32, damping: f32) -> Self {
Self {
buffer: vec![0.0; delay.max(1)],
write_pos: 0,
feedback,
damping,
damp_state: 0.0,
}
}
fn process(&mut self, input: f32) -> f32 {
let delayed = self.buffer[self.write_pos];
self.damp_state = delayed * (1.0 - self.damping) + self.damp_state * self.damping;
let output = self.damp_state;
self.buffer[self.write_pos] = input + output * self.feedback;
self.write_pos = (self.write_pos + 1) % self.buffer.len();
delayed
}
fn clear(&mut self) {
self.buffer.fill(0.0);
self.damp_state = 0.0;
}
}
struct AllpassFilter {
buffer: Vec<f32>,
write_pos: usize,
feedback: f32,
}
impl AllpassFilter {
fn new(delay: usize, feedback: f32) -> Self {
Self {
buffer: vec![0.0; delay.max(1)],
write_pos: 0,
feedback,
}
}
fn process(&mut self, input: f32) -> f32 {
let delayed = self.buffer[self.write_pos];
let output = -input + delayed;
self.buffer[self.write_pos] = input + delayed * self.feedback;
self.write_pos = (self.write_pos + 1) % self.buffer.len();
output
}
fn clear(&mut self) {
self.buffer.fill(0.0);
}
}
pub struct SpatialReverb {
combs: [CombFilter; 4],
allpasses: [AllpassFilter; 2],
pre_delay_buf: Vec<f32>,
pre_delay_pos: usize,
wet_mix: f32,
current_room: RoomType,
}
impl SpatialReverb {
pub fn new(room: RoomType) -> Self {
let params = ReverbParams::from_room(room);
Self {
combs: [
CombFilter::new(params.comb_delays[0], params.comb_feedback[0], params.damping),
CombFilter::new(params.comb_delays[1], params.comb_feedback[1], params.damping),
CombFilter::new(params.comb_delays[2], params.comb_feedback[2], params.damping),
CombFilter::new(params.comb_delays[3], params.comb_feedback[3], params.damping),
],
allpasses: [
AllpassFilter::new(params.allpass_delays[0], params.allpass_feedback),
AllpassFilter::new(params.allpass_delays[1], params.allpass_feedback),
],
pre_delay_buf: vec![0.0; params.pre_delay.max(1)],
pre_delay_pos: 0,
wet_mix: params.wet_mix,
current_room: room,
}
}
pub fn set_room(&mut self, room: RoomType) {
if room == self.current_room { return; }
*self = Self::new(room);
}
pub fn process_sample(&mut self, input: f32) -> f32 {
if self.wet_mix < 0.001 {
return input;
}
let pre_delayed = self.pre_delay_buf[self.pre_delay_pos];
self.pre_delay_buf[self.pre_delay_pos] = input;
self.pre_delay_pos = (self.pre_delay_pos + 1) % self.pre_delay_buf.len();
let mut wet = 0.0_f32;
for comb in &mut self.combs {
wet += comb.process(pre_delayed);
}
wet *= 0.25;
for ap in &mut self.allpasses {
wet = ap.process(wet);
}
input * (1.0 - self.wet_mix) + wet * self.wet_mix
}
pub fn process_buffer(&mut self, buffer: &mut [f32]) {
for sample in buffer.iter_mut() {
*sample = self.process_sample(*sample);
}
}
pub fn process_stereo(&mut self, input: f32, pan: StereoPan) -> (f32, f32) {
let reverbed = self.process_sample(input);
(reverbed * pan.left, reverbed * pan.right)
}
pub fn clear(&mut self) {
for comb in &mut self.combs {
comb.clear();
}
for ap in &mut self.allpasses {
ap.clear();
}
self.pre_delay_buf.fill(0.0);
}
pub fn room(&self) -> RoomType {
self.current_room
}
}
#[derive(Debug, Clone)]
pub struct SpatialSound {
pub id: u32,
pub name: String,
pub position: Vec2,
pub volume: f32,
pub active: bool,
pub lifetime: f32,
pub pan_override: Option<StereoPan>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum SoundOrigin {
Entity(Vec2),
Traveling { from: Vec2, to: Vec2, progress: f32 },
Centered,
Above,
Below,
}
impl SoundOrigin {
pub fn resolve(&self, listener: Vec2, arena_half_width: f32) -> (StereoPan, f32) {
let distance_model = DistanceModel::default();
match self {
SoundOrigin::Entity(pos) => distance_model.apply(*pos, listener, arena_half_width),
SoundOrigin::Traveling { from, to, progress } => {
let current = *from + (*to - *from) * progress.clamp(0.0, 1.0);
distance_model.apply(current, listener, arena_half_width)
}
SoundOrigin::Centered => (
StereoPan { left: 0.85, right: 0.85 }, 1.0,
),
SoundOrigin::Above => (
StereoPan { left: 0.9, right: 0.9 }
.with_vertical_bias(5.0, 0.0),
0.8,
),
SoundOrigin::Below => (
StereoPan { left: 0.7, right: 0.7 }
.with_vertical_bias(-3.0, 0.0),
0.7,
),
}
}
}
pub struct SpatialAudioSystem {
sounds: Vec<SpatialSound>,
next_id: u32,
pub listener_pos: Vec2,
pub arena_half_width: f32,
pub distance_model: DistanceModel,
pub reverb: SpatialReverb,
pub room_type: RoomType,
}
impl SpatialAudioSystem {
pub fn new() -> Self {
Self {
sounds: Vec::new(),
next_id: 0,
listener_pos: Vec2::ZERO,
arena_half_width: 10.0,
distance_model: DistanceModel::default(),
reverb: SpatialReverb::new(RoomType::Combat),
room_type: RoomType::Combat,
}
}
pub fn set_listener(&mut self, pos: Vec2) {
self.listener_pos = pos;
}
pub fn set_room(&mut self, room: RoomType) {
self.room_type = room;
self.reverb.set_room(room);
}
pub fn play(&mut self, name: &str, origin: SoundOrigin, volume: f32, lifetime: f32) -> u32 {
let id = self.next_id;
self.next_id += 1;
let (pan, _gain) = origin.resolve(self.listener_pos, self.arena_half_width);
let position = match origin {
SoundOrigin::Entity(p) => p,
SoundOrigin::Traveling { from, .. } => from,
SoundOrigin::Centered => self.listener_pos,
SoundOrigin::Above => self.listener_pos + Vec2::new(0.0, 5.0),
SoundOrigin::Below => self.listener_pos + Vec2::new(0.0, -3.0),
};
let sound = SpatialSound {
id,
name: name.to_string(),
position,
volume,
active: true,
lifetime,
pan_override: None,
};
if self.sounds.len() >= MAX_SOURCES {
if let Some(pos) = self.sounds.iter().position(|s| !s.active) {
self.sounds.swap_remove(pos);
} else {
self.sounds.swap_remove(0);
}
}
self.sounds.push(sound);
id
}
pub fn update_travel(&mut self, id: u32, from: Vec2, to: Vec2, progress: f32) {
if let Some(sound) = self.sounds.iter_mut().find(|s| s.id == id) {
sound.position = from + (to - from) * progress.clamp(0.0, 1.0);
}
}
pub fn stop(&mut self, id: u32) {
if let Some(sound) = self.sounds.iter_mut().find(|s| s.id == id) {
sound.active = false;
}
}
pub fn tick(&mut self, dt: f32) {
for sound in &mut self.sounds {
if sound.lifetime > 0.0 {
sound.lifetime -= dt;
if sound.lifetime <= 0.0 {
sound.active = false;
}
}
}
self.sounds.retain(|s| s.active || s.lifetime > -1.0);
self.sounds.retain(|s| s.active);
}
pub fn spatialize(&mut self, sample: f32, origin: SoundOrigin, volume: f32) -> (f32, f32) {
let (pan, dist_gain) = origin.resolve(self.listener_pos, self.arena_half_width);
let gain = volume * dist_gain;
let mono = sample * gain;
self.reverb.process_stereo(mono, pan)
}
pub fn compute_pan_gain(&self, source_pos: Vec2) -> (StereoPan, f32) {
self.distance_model.apply(source_pos, self.listener_pos, self.arena_half_width)
}
pub fn active_count(&self) -> usize {
self.sounds.iter().filter(|s| s.active).count()
}
pub fn clear(&mut self) {
self.sounds.clear();
self.reverb.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_stereo_pan_center() {
let pan = StereoPan::from_position(0.0, 10.0);
assert!((pan.left - pan.right).abs() < 0.01, "center should be equal L/R");
}
#[test]
fn test_stereo_pan_left() {
let pan = StereoPan::from_position(-10.0, 10.0);
assert!(pan.left > pan.right, "negative X should favor left: L={}, R={}", pan.left, pan.right);
}
#[test]
fn test_stereo_pan_right() {
let pan = StereoPan::from_position(10.0, 10.0);
assert!(pan.right > pan.left, "positive X should favor right: L={}, R={}", pan.left, pan.right);
}
#[test]
fn test_distance_attenuation_near() {
let model = DistanceModel::default();
let gain = model.attenuation(0.5);
assert!((gain - 1.0).abs() < 0.01, "within ref_distance should be full volume");
}
#[test]
fn test_distance_attenuation_far() {
let model = DistanceModel::default();
let gain = model.attenuation(25.0);
assert!(gain < 0.01, "beyond max_distance should be silent: {gain}");
}
#[test]
fn test_distance_attenuation_mid() {
let model = DistanceModel::default();
let near = model.attenuation(2.0);
let far = model.attenuation(10.0);
assert!(near > far, "closer should be louder: near={near}, far={far}");
}
#[test]
fn test_reverb_combat_short() {
let mut reverb = SpatialReverb::new(RoomType::Combat);
let out0 = reverb.process_sample(1.0);
let mut max_tail = 0.0_f32;
for _ in 0..2000 {
let out = reverb.process_sample(0.0);
max_tail = max_tail.max(out.abs());
}
assert!(max_tail > 0.0, "combat reverb should have some tail");
}
#[test]
fn test_reverb_cathedral_longer() {
let mut combat_rev = SpatialReverb::new(RoomType::Combat);
let mut cathedral_rev = SpatialReverb::new(RoomType::Cathedral);
combat_rev.process_sample(1.0);
cathedral_rev.process_sample(1.0);
let mut combat_energy = 0.0_f32;
let mut cathedral_energy = 0.0_f32;
for _ in 0..4000 {
let c = combat_rev.process_sample(0.0);
let d = cathedral_rev.process_sample(0.0);
combat_energy += c * c;
cathedral_energy += d * d;
}
assert!(cathedral_energy > combat_energy,
"cathedral should have more tail energy: cathedral={cathedral_energy}, combat={combat_energy}");
}
#[test]
fn test_reverb_none_passthrough() {
let mut reverb = SpatialReverb::new(RoomType::None);
let out = reverb.process_sample(0.5);
assert!((out - 0.5).abs() < 0.01, "None room should pass through: {out}");
}
#[test]
fn test_spatial_system_play() {
let mut sys = SpatialAudioSystem::new();
let id = sys.play("hit", SoundOrigin::Entity(Vec2::new(5.0, 0.0)), 1.0, 0.5);
assert_eq!(sys.active_count(), 1);
sys.tick(0.6);
assert_eq!(sys.active_count(), 0, "sound should expire");
}
#[test]
fn test_spatial_system_spatialize() {
let mut sys = SpatialAudioSystem::new();
sys.set_listener(Vec2::ZERO);
let (l, r) = sys.spatialize(1.0, SoundOrigin::Entity(Vec2::new(8.0, 0.0)), 1.0);
assert!(r > l, "right-side sound should be louder in right channel: L={l}, R={r}");
}
#[test]
fn test_sound_origin_traveling() {
let origin = SoundOrigin::Traveling {
from: Vec2::new(-5.0, 0.0),
to: Vec2::new(5.0, 0.0),
progress: 0.5,
};
let (pan, _gain) = origin.resolve(Vec2::ZERO, 10.0);
assert!((pan.left - pan.right).abs() < 0.15, "midpoint should be near center");
}
#[test]
fn test_room_transition() {
let mut sys = SpatialAudioSystem::new();
sys.set_room(RoomType::Combat);
assert_eq!(sys.reverb.room(), RoomType::Combat);
sys.set_room(RoomType::Cathedral);
assert_eq!(sys.reverb.room(), RoomType::Cathedral);
}
#[test]
fn test_ms_conversion() {
let samples = ms(10.0);
assert_eq!(samples, 480); }
}