1use glam::Vec2;
26
27const SAMPLE_RATE: f32 = 48000.0;
33const MAX_SOURCES: usize = 32;
35const _SPEED_OF_SOUND: f32 = 343.0;
37
38#[derive(Debug, Clone, Copy)]
44pub struct StereoPan {
45 pub left: f32,
46 pub right: f32,
47}
48
49impl StereoPan {
50 pub fn center() -> Self { Self { left: 1.0, right: 1.0 } }
51
52 pub fn from_position(x: f32, arena_half_width: f32) -> Self {
55 let hw = arena_half_width.max(0.1);
56 let pan = (x / hw).clamp(-1.0, 1.0); let angle = (pan + 1.0) * 0.25 * std::f32::consts::PI; Self {
61 left: angle.cos(),
62 right: angle.sin(),
63 }
64 }
65
66 pub fn from_world_pos(source: Vec2, listener: Vec2, arena_half_width: f32) -> Self {
68 Self::from_position(source.x - listener.x, arena_half_width)
69 }
70
71 pub fn with_vertical_bias(mut self, source_y: f32, listener_y: f32) -> Self {
73 let dy = source_y - listener_y;
74 if dy > 0.5 {
75 let spread = (dy * 0.1).min(0.15);
77 self.left = (self.left + spread).min(1.0);
78 self.right = (self.right + spread).min(1.0);
79 } else if dy < -0.5 {
80 let narrow = (-dy * 0.05).min(0.1);
82 let center = (self.left + self.right) * 0.5;
83 self.left = self.left + (center - self.left) * narrow;
84 self.right = self.right + (center - self.right) * narrow;
85 }
86 self
87 }
88}
89
90#[derive(Debug, Clone, Copy)]
96pub struct DistanceModel {
97 pub ref_distance: f32,
99 pub max_distance: f32,
101 pub rolloff: f32,
103}
104
105impl Default for DistanceModel {
106 fn default() -> Self {
107 Self {
108 ref_distance: 1.0,
109 max_distance: 20.0,
110 rolloff: 1.0,
111 }
112 }
113}
114
115impl DistanceModel {
116 pub fn attenuation(&self, distance: f32) -> f32 {
118 if distance <= self.ref_distance {
119 return 1.0;
120 }
121 if distance >= self.max_distance {
122 return 0.0;
123 }
124
125 let d = distance.max(self.ref_distance);
127 let gain = self.ref_distance / (self.ref_distance + self.rolloff * (d - self.ref_distance));
128
129 gain.clamp(0.0, 1.0)
131 }
132
133 pub fn apply(&self, source: Vec2, listener: Vec2, arena_half_width: f32) -> (StereoPan, f32) {
135 let dist = (source - listener).length();
136 let gain = self.attenuation(dist);
137 let pan = StereoPan::from_world_pos(source, listener, arena_half_width);
138 (pan, gain)
139 }
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
148pub enum RoomType {
149 Combat,
151 Boss,
153 Cathedral,
155 Shop,
157 Corridor,
159 None,
161}
162
163#[derive(Debug, Clone)]
165pub struct ReverbParams {
166 pub comb_delays: [usize; 4],
168 pub comb_feedback: [f32; 4],
170 pub allpass_delays: [usize; 2],
172 pub allpass_feedback: f32,
174 pub wet_mix: f32,
176 pub pre_delay: usize,
178 pub damping: f32,
180}
181
182impl ReverbParams {
183 pub fn from_room(room: RoomType) -> Self {
185 match room {
186 RoomType::Combat => Self {
187 comb_delays: [ms(22.0), ms(25.0), ms(28.0), ms(31.0)],
188 comb_feedback: [0.60, 0.58, 0.56, 0.54],
189 allpass_delays: [ms(5.0), ms(1.7)],
190 allpass_feedback: 0.5,
191 wet_mix: 0.15,
192 pre_delay: ms(2.0),
193 damping: 0.6,
194 },
195 RoomType::Boss => Self {
196 comb_delays: [ms(40.0), ms(45.0), ms(50.0), ms(55.0)],
197 comb_feedback: [0.80, 0.78, 0.76, 0.74],
198 allpass_delays: [ms(8.0), ms(3.0)],
199 allpass_feedback: 0.6,
200 wet_mix: 0.30,
201 pre_delay: ms(8.0),
202 damping: 0.35,
203 },
204 RoomType::Cathedral => Self {
205 comb_delays: [ms(60.0), ms(68.0), ms(75.0), ms(82.0)],
206 comb_feedback: [0.88, 0.86, 0.84, 0.82],
207 allpass_delays: [ms(12.0), ms(4.0)],
208 allpass_feedback: 0.7,
209 wet_mix: 0.45,
210 pre_delay: ms(15.0),
211 damping: 0.2,
212 },
213 RoomType::Shop => Self {
214 comb_delays: [ms(30.0), ms(34.0), ms(37.0), ms(40.0)],
215 comb_feedback: [0.65, 0.63, 0.61, 0.59],
216 allpass_delays: [ms(6.0), ms(2.0)],
217 allpass_feedback: 0.55,
218 wet_mix: 0.20,
219 pre_delay: ms(4.0),
220 damping: 0.5,
221 },
222 RoomType::Corridor => Self {
223 comb_delays: [ms(15.0), ms(18.0), ms(20.0), ms(23.0)],
224 comb_feedback: [0.50, 0.48, 0.46, 0.44],
225 allpass_delays: [ms(3.0), ms(1.0)],
226 allpass_feedback: 0.45,
227 wet_mix: 0.10,
228 pre_delay: ms(1.0),
229 damping: 0.7,
230 },
231 RoomType::None => Self {
232 comb_delays: [1, 1, 1, 1],
233 comb_feedback: [0.0; 4],
234 allpass_delays: [1, 1],
235 allpass_feedback: 0.0,
236 wet_mix: 0.0,
237 pre_delay: 0,
238 damping: 0.0,
239 },
240 }
241 }
242}
243
244fn ms(milliseconds: f32) -> usize {
246 (milliseconds * SAMPLE_RATE / 1000.0).round() as usize
247}
248
249struct CombFilter {
254 buffer: Vec<f32>,
255 write_pos: usize,
256 feedback: f32,
257 damping: f32,
258 damp_state: f32,
259}
260
261impl CombFilter {
262 fn new(delay: usize, feedback: f32, damping: f32) -> Self {
263 Self {
264 buffer: vec![0.0; delay.max(1)],
265 write_pos: 0,
266 feedback,
267 damping,
268 damp_state: 0.0,
269 }
270 }
271
272 fn process(&mut self, input: f32) -> f32 {
273 let delayed = self.buffer[self.write_pos];
274
275 self.damp_state = delayed * (1.0 - self.damping) + self.damp_state * self.damping;
277
278 let output = self.damp_state;
279 self.buffer[self.write_pos] = input + output * self.feedback;
280 self.write_pos = (self.write_pos + 1) % self.buffer.len();
281
282 delayed
283 }
284
285 fn clear(&mut self) {
286 self.buffer.fill(0.0);
287 self.damp_state = 0.0;
288 }
289}
290
291struct AllpassFilter {
296 buffer: Vec<f32>,
297 write_pos: usize,
298 feedback: f32,
299}
300
301impl AllpassFilter {
302 fn new(delay: usize, feedback: f32) -> Self {
303 Self {
304 buffer: vec![0.0; delay.max(1)],
305 write_pos: 0,
306 feedback,
307 }
308 }
309
310 fn process(&mut self, input: f32) -> f32 {
311 let delayed = self.buffer[self.write_pos];
312 let output = -input + delayed;
313 self.buffer[self.write_pos] = input + delayed * self.feedback;
314 self.write_pos = (self.write_pos + 1) % self.buffer.len();
315 output
316 }
317
318 fn clear(&mut self) {
319 self.buffer.fill(0.0);
320 }
321}
322
323pub struct SpatialReverb {
329 combs: [CombFilter; 4],
330 allpasses: [AllpassFilter; 2],
331 pre_delay_buf: Vec<f32>,
332 pre_delay_pos: usize,
333 wet_mix: f32,
334 current_room: RoomType,
335}
336
337impl SpatialReverb {
338 pub fn new(room: RoomType) -> Self {
339 let params = ReverbParams::from_room(room);
340 Self {
341 combs: [
342 CombFilter::new(params.comb_delays[0], params.comb_feedback[0], params.damping),
343 CombFilter::new(params.comb_delays[1], params.comb_feedback[1], params.damping),
344 CombFilter::new(params.comb_delays[2], params.comb_feedback[2], params.damping),
345 CombFilter::new(params.comb_delays[3], params.comb_feedback[3], params.damping),
346 ],
347 allpasses: [
348 AllpassFilter::new(params.allpass_delays[0], params.allpass_feedback),
349 AllpassFilter::new(params.allpass_delays[1], params.allpass_feedback),
350 ],
351 pre_delay_buf: vec![0.0; params.pre_delay.max(1)],
352 pre_delay_pos: 0,
353 wet_mix: params.wet_mix,
354 current_room: room,
355 }
356 }
357
358 pub fn set_room(&mut self, room: RoomType) {
360 if room == self.current_room { return; }
361 *self = Self::new(room);
362 }
363
364 pub fn process_sample(&mut self, input: f32) -> f32 {
366 if self.wet_mix < 0.001 {
367 return input;
368 }
369
370 let pre_delayed = self.pre_delay_buf[self.pre_delay_pos];
372 self.pre_delay_buf[self.pre_delay_pos] = input;
373 self.pre_delay_pos = (self.pre_delay_pos + 1) % self.pre_delay_buf.len();
374
375 let mut wet = 0.0_f32;
377 for comb in &mut self.combs {
378 wet += comb.process(pre_delayed);
379 }
380 wet *= 0.25; for ap in &mut self.allpasses {
384 wet = ap.process(wet);
385 }
386
387 input * (1.0 - self.wet_mix) + wet * self.wet_mix
389 }
390
391 pub fn process_buffer(&mut self, buffer: &mut [f32]) {
393 for sample in buffer.iter_mut() {
394 *sample = self.process_sample(*sample);
395 }
396 }
397
398 pub fn process_stereo(&mut self, input: f32, pan: StereoPan) -> (f32, f32) {
400 let reverbed = self.process_sample(input);
401 (reverbed * pan.left, reverbed * pan.right)
402 }
403
404 pub fn clear(&mut self) {
406 for comb in &mut self.combs {
407 comb.clear();
408 }
409 for ap in &mut self.allpasses {
410 ap.clear();
411 }
412 self.pre_delay_buf.fill(0.0);
413 }
414
415 pub fn room(&self) -> RoomType {
416 self.current_room
417 }
418}
419
420#[derive(Debug, Clone)]
426pub struct SpatialSound {
427 pub id: u32,
429 pub name: String,
431 pub position: Vec2,
433 pub volume: f32,
435 pub active: bool,
437 pub lifetime: f32,
439 pub pan_override: Option<StereoPan>,
441}
442
443#[derive(Debug, Clone, Copy, PartialEq)]
445pub enum SoundOrigin {
446 Entity(Vec2),
448 Traveling { from: Vec2, to: Vec2, progress: f32 },
450 Centered,
452 Above,
454 Below,
456}
457
458impl SoundOrigin {
459 pub fn resolve(&self, listener: Vec2, arena_half_width: f32) -> (StereoPan, f32) {
461 let distance_model = DistanceModel::default();
462 match self {
463 SoundOrigin::Entity(pos) => distance_model.apply(*pos, listener, arena_half_width),
464 SoundOrigin::Traveling { from, to, progress } => {
465 let current = *from + (*to - *from) * progress.clamp(0.0, 1.0);
466 distance_model.apply(current, listener, arena_half_width)
467 }
468 SoundOrigin::Centered => (
469 StereoPan { left: 0.85, right: 0.85 }, 1.0,
471 ),
472 SoundOrigin::Above => (
473 StereoPan { left: 0.9, right: 0.9 }
474 .with_vertical_bias(5.0, 0.0),
475 0.8,
476 ),
477 SoundOrigin::Below => (
478 StereoPan { left: 0.7, right: 0.7 }
479 .with_vertical_bias(-3.0, 0.0),
480 0.7,
481 ),
482 }
483 }
484}
485
486pub struct SpatialAudioSystem {
492 sounds: Vec<SpatialSound>,
494 next_id: u32,
496 pub listener_pos: Vec2,
498 pub arena_half_width: f32,
500 pub distance_model: DistanceModel,
502 pub reverb: SpatialReverb,
504 pub room_type: RoomType,
506}
507
508impl SpatialAudioSystem {
509 pub fn new() -> Self {
510 Self {
511 sounds: Vec::new(),
512 next_id: 0,
513 listener_pos: Vec2::ZERO,
514 arena_half_width: 10.0,
515 distance_model: DistanceModel::default(),
516 reverb: SpatialReverb::new(RoomType::Combat),
517 room_type: RoomType::Combat,
518 }
519 }
520
521 pub fn set_listener(&mut self, pos: Vec2) {
523 self.listener_pos = pos;
524 }
525
526 pub fn set_room(&mut self, room: RoomType) {
528 self.room_type = room;
529 self.reverb.set_room(room);
530 }
531
532 pub fn play(&mut self, name: &str, origin: SoundOrigin, volume: f32, lifetime: f32) -> u32 {
534 let id = self.next_id;
535 self.next_id += 1;
536
537 let (pan, _gain) = origin.resolve(self.listener_pos, self.arena_half_width);
538 let position = match origin {
539 SoundOrigin::Entity(p) => p,
540 SoundOrigin::Traveling { from, .. } => from,
541 SoundOrigin::Centered => self.listener_pos,
542 SoundOrigin::Above => self.listener_pos + Vec2::new(0.0, 5.0),
543 SoundOrigin::Below => self.listener_pos + Vec2::new(0.0, -3.0),
544 };
545
546 let sound = SpatialSound {
547 id,
548 name: name.to_string(),
549 position,
550 volume,
551 active: true,
552 lifetime,
553 pan_override: None,
554 };
555
556 if self.sounds.len() >= MAX_SOURCES {
557 if let Some(pos) = self.sounds.iter().position(|s| !s.active) {
559 self.sounds.swap_remove(pos);
560 } else {
561 self.sounds.swap_remove(0);
562 }
563 }
564 self.sounds.push(sound);
565 id
566 }
567
568 pub fn update_travel(&mut self, id: u32, from: Vec2, to: Vec2, progress: f32) {
570 if let Some(sound) = self.sounds.iter_mut().find(|s| s.id == id) {
571 sound.position = from + (to - from) * progress.clamp(0.0, 1.0);
572 }
573 }
574
575 pub fn stop(&mut self, id: u32) {
577 if let Some(sound) = self.sounds.iter_mut().find(|s| s.id == id) {
578 sound.active = false;
579 }
580 }
581
582 pub fn tick(&mut self, dt: f32) {
584 for sound in &mut self.sounds {
585 if sound.lifetime > 0.0 {
586 sound.lifetime -= dt;
587 if sound.lifetime <= 0.0 {
588 sound.active = false;
589 }
590 }
591 }
592 self.sounds.retain(|s| s.active || s.lifetime > -1.0);
593 self.sounds.retain(|s| s.active);
595 }
596
597 pub fn spatialize(&mut self, sample: f32, origin: SoundOrigin, volume: f32) -> (f32, f32) {
600 let (pan, dist_gain) = origin.resolve(self.listener_pos, self.arena_half_width);
601 let gain = volume * dist_gain;
602 let mono = sample * gain;
603 self.reverb.process_stereo(mono, pan)
604 }
605
606 pub fn compute_pan_gain(&self, source_pos: Vec2) -> (StereoPan, f32) {
608 self.distance_model.apply(source_pos, self.listener_pos, self.arena_half_width)
609 }
610
611 pub fn active_count(&self) -> usize {
613 self.sounds.iter().filter(|s| s.active).count()
614 }
615
616 pub fn clear(&mut self) {
618 self.sounds.clear();
619 self.reverb.clear();
620 }
621}
622
623#[cfg(test)]
628mod tests {
629 use super::*;
630
631 #[test]
632 fn test_stereo_pan_center() {
633 let pan = StereoPan::from_position(0.0, 10.0);
634 assert!((pan.left - pan.right).abs() < 0.01, "center should be equal L/R");
635 }
636
637 #[test]
638 fn test_stereo_pan_left() {
639 let pan = StereoPan::from_position(-10.0, 10.0);
640 assert!(pan.left > pan.right, "negative X should favor left: L={}, R={}", pan.left, pan.right);
641 }
642
643 #[test]
644 fn test_stereo_pan_right() {
645 let pan = StereoPan::from_position(10.0, 10.0);
646 assert!(pan.right > pan.left, "positive X should favor right: L={}, R={}", pan.left, pan.right);
647 }
648
649 #[test]
650 fn test_distance_attenuation_near() {
651 let model = DistanceModel::default();
652 let gain = model.attenuation(0.5);
653 assert!((gain - 1.0).abs() < 0.01, "within ref_distance should be full volume");
654 }
655
656 #[test]
657 fn test_distance_attenuation_far() {
658 let model = DistanceModel::default();
659 let gain = model.attenuation(25.0);
660 assert!(gain < 0.01, "beyond max_distance should be silent: {gain}");
661 }
662
663 #[test]
664 fn test_distance_attenuation_mid() {
665 let model = DistanceModel::default();
666 let near = model.attenuation(2.0);
667 let far = model.attenuation(10.0);
668 assert!(near > far, "closer should be louder: near={near}, far={far}");
669 }
670
671 #[test]
672 fn test_reverb_combat_short() {
673 let mut reverb = SpatialReverb::new(RoomType::Combat);
674 let out0 = reverb.process_sample(1.0);
676 let mut max_tail = 0.0_f32;
678 for _ in 0..2000 {
679 let out = reverb.process_sample(0.0);
680 max_tail = max_tail.max(out.abs());
681 }
682 assert!(max_tail > 0.0, "combat reverb should have some tail");
683 }
684
685 #[test]
686 fn test_reverb_cathedral_longer() {
687 let mut combat_rev = SpatialReverb::new(RoomType::Combat);
688 let mut cathedral_rev = SpatialReverb::new(RoomType::Cathedral);
689
690 combat_rev.process_sample(1.0);
692 cathedral_rev.process_sample(1.0);
693
694 let mut combat_energy = 0.0_f32;
696 let mut cathedral_energy = 0.0_f32;
697 for _ in 0..4000 {
698 let c = combat_rev.process_sample(0.0);
699 let d = cathedral_rev.process_sample(0.0);
700 combat_energy += c * c;
701 cathedral_energy += d * d;
702 }
703 assert!(cathedral_energy > combat_energy,
704 "cathedral should have more tail energy: cathedral={cathedral_energy}, combat={combat_energy}");
705 }
706
707 #[test]
708 fn test_reverb_none_passthrough() {
709 let mut reverb = SpatialReverb::new(RoomType::None);
710 let out = reverb.process_sample(0.5);
711 assert!((out - 0.5).abs() < 0.01, "None room should pass through: {out}");
712 }
713
714 #[test]
715 fn test_spatial_system_play() {
716 let mut sys = SpatialAudioSystem::new();
717 let id = sys.play("hit", SoundOrigin::Entity(Vec2::new(5.0, 0.0)), 1.0, 0.5);
718 assert_eq!(sys.active_count(), 1);
719 sys.tick(0.6);
720 assert_eq!(sys.active_count(), 0, "sound should expire");
721 }
722
723 #[test]
724 fn test_spatial_system_spatialize() {
725 let mut sys = SpatialAudioSystem::new();
726 sys.set_listener(Vec2::ZERO);
727
728 let (l, r) = sys.spatialize(1.0, SoundOrigin::Entity(Vec2::new(8.0, 0.0)), 1.0);
730 assert!(r > l, "right-side sound should be louder in right channel: L={l}, R={r}");
731 }
732
733 #[test]
734 fn test_sound_origin_traveling() {
735 let origin = SoundOrigin::Traveling {
736 from: Vec2::new(-5.0, 0.0),
737 to: Vec2::new(5.0, 0.0),
738 progress: 0.5,
739 };
740 let (pan, _gain) = origin.resolve(Vec2::ZERO, 10.0);
741 assert!((pan.left - pan.right).abs() < 0.15, "midpoint should be near center");
743 }
744
745 #[test]
746 fn test_room_transition() {
747 let mut sys = SpatialAudioSystem::new();
748 sys.set_room(RoomType::Combat);
749 assert_eq!(sys.reverb.room(), RoomType::Combat);
750 sys.set_room(RoomType::Cathedral);
751 assert_eq!(sys.reverb.room(), RoomType::Cathedral);
752 }
753
754 #[test]
755 fn test_ms_conversion() {
756 let samples = ms(10.0);
757 assert_eq!(samples, 480); }
759}