Skip to main content

proof_engine/audio/
spatial.rs

1//! Spatial audio — position-based stereo panning, distance attenuation,
2//! and per-room reverb.
3//!
4//! Maps entity world positions to stereo pan and volume. Provides room-type
5//! reverb presets (tight combat, long boss, cathedral shrine). All sounds
6//! can be positioned in the 2D arena for immersive audio.
7//!
8//! # Panning model
9//!
10//! Uses a linear stereo pan where X position maps to left/right:
11//! - X < 0 → more left channel
12//! - X > 0 → more right channel
13//! - X = 0 → center
14//!
15//! # Distance attenuation
16//!
17//! Inverse-distance model with configurable reference distance and rolloff.
18//! Sounds beyond `max_distance` are silent.
19//!
20//! # Reverb
21//!
22//! Simple Schroeder reverb with 4 comb filters + 2 allpass filters.
23//! Room presets configure delay lengths, feedback, and mix.
24
25use glam::Vec2;
26
27// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
28// Constants
29// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
30
31/// Sample rate (must match engine audio thread).
32const SAMPLE_RATE: f32 = 48000.0;
33/// Maximum number of positioned sound sources tracked simultaneously.
34const MAX_SOURCES: usize = 32;
35/// Speed of sound approximation (for very simple delay, unused in MVP).
36const _SPEED_OF_SOUND: f32 = 343.0;
37
38// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
39// Stereo pan
40// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
41
42/// Stereo pan result: gain for left and right channels.
43#[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    /// Compute stereo pan from a position relative to the listener.
53    /// `x` is in world units; `arena_half_width` defines the full-left/full-right boundary.
54    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); // -1 = full left, +1 = full right
57
58        // Equal-power panning (constant power across the stereo field)
59        let angle = (pan + 1.0) * 0.25 * std::f32::consts::PI; // 0 to PI/2
60        Self {
61            left: angle.cos(),
62            right: angle.sin(),
63        }
64    }
65
66    /// Compute from a 2D source position relative to listener center.
67    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    /// Apply upward bias (Y > listener → slight center widening for "above" feel).
72    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            // Sound from above: widen stereo slightly
76            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            // Sound from below: narrow stereo slightly
81            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// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
91// Distance attenuation
92// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
93
94/// Distance attenuation model.
95#[derive(Debug, Clone, Copy)]
96pub struct DistanceModel {
97    /// Distance at which attenuation begins (below this = full volume).
98    pub ref_distance: f32,
99    /// Maximum audible distance (beyond this = silent).
100    pub max_distance: f32,
101    /// Rolloff factor (1.0 = inverse distance, 2.0 = inverse square, etc.).
102    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    /// Compute gain [0, 1] based on distance between source and listener.
117    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        // Inverse distance rolloff
126        let d = distance.max(self.ref_distance);
127        let gain = self.ref_distance / (self.ref_distance + self.rolloff * (d - self.ref_distance));
128
129        // Clamp to [0, 1]
130        gain.clamp(0.0, 1.0)
131    }
132
133    /// Full spatial gain: distance attenuation applied to stereo pan.
134    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// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
143// Room reverb
144// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
145
146/// Room type for reverb preset selection.
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
148pub enum RoomType {
149    /// Tight, short reverb for standard combat rooms.
150    Combat,
151    /// Long, dramatic reverb for boss arenas.
152    Boss,
153    /// Cathedral-style reverb with heavy reflections for shrines.
154    Cathedral,
155    /// Medium reverb for shops and crafting stations.
156    Shop,
157    /// Minimal reverb for corridors and hallways.
158    Corridor,
159    /// No reverb (outdoor, void).
160    None,
161}
162
163/// Reverb parameters.
164#[derive(Debug, Clone)]
165pub struct ReverbParams {
166    /// Comb filter delay lengths in samples.
167    pub comb_delays: [usize; 4],
168    /// Comb filter feedback gains.
169    pub comb_feedback: [f32; 4],
170    /// Allpass filter delay lengths in samples.
171    pub allpass_delays: [usize; 2],
172    /// Allpass filter feedback coefficients.
173    pub allpass_feedback: f32,
174    /// Wet/dry mix (0 = dry only, 1 = wet only).
175    pub wet_mix: f32,
176    /// Pre-delay in samples.
177    pub pre_delay: usize,
178    /// High-frequency damping (0 = none, 1 = full).
179    pub damping: f32,
180}
181
182impl ReverbParams {
183    /// Get preset reverb parameters for a room type.
184    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
244/// Convert milliseconds to samples at SAMPLE_RATE.
245fn ms(milliseconds: f32) -> usize {
246    (milliseconds * SAMPLE_RATE / 1000.0).round() as usize
247}
248
249// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
250// Comb filter
251// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
252
253struct 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        // Low-pass damping on feedback path
276        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
291// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
292// Allpass filter
293// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
294
295struct 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
323// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
324// SpatialReverb — Schroeder reverberator with room presets
325// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
326
327/// Schroeder reverb: 4 parallel comb filters → 2 series allpass filters.
328pub 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    /// Switch to a different room preset. Clears delay buffers.
359    pub fn set_room(&mut self, room: RoomType) {
360        if room == self.current_room { return; }
361        *self = Self::new(room);
362    }
363
364    /// Process a single mono sample. Returns (left, right) with reverb.
365    pub fn process_sample(&mut self, input: f32) -> f32 {
366        if self.wet_mix < 0.001 {
367            return input;
368        }
369
370        // Pre-delay
371        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        // Parallel comb filters
376        let mut wet = 0.0_f32;
377        for comb in &mut self.combs {
378            wet += comb.process(pre_delayed);
379        }
380        wet *= 0.25; // average
381
382        // Series allpass filters
383        for ap in &mut self.allpasses {
384            wet = ap.process(wet);
385        }
386
387        // Mix
388        input * (1.0 - self.wet_mix) + wet * self.wet_mix
389    }
390
391    /// Process a buffer of mono samples in-place.
392    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    /// Process mono into stereo with pan applied.
399    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    /// Clear all delay buffers (use on room transition).
405    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// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
421// SpatialSound — a positioned sound source
422// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
423
424/// A sound source with a position in the arena.
425#[derive(Debug, Clone)]
426pub struct SpatialSound {
427    /// Unique identifier.
428    pub id: u32,
429    /// Name/tag of the sound.
430    pub name: String,
431    /// World-space position.
432    pub position: Vec2,
433    /// Base volume [0, 1].
434    pub volume: f32,
435    /// Whether this sound is currently active.
436    pub active: bool,
437    /// Remaining lifetime (0 = infinite / looping).
438    pub lifetime: f32,
439    /// Pan override (None = computed from position).
440    pub pan_override: Option<StereoPan>,
441}
442
443/// Origin of a sound in the game world.
444#[derive(Debug, Clone, Copy, PartialEq)]
445pub enum SoundOrigin {
446    /// Sound from an entity at a position.
447    Entity(Vec2),
448    /// Sound traveling from source to target over time.
449    Traveling { from: Vec2, to: Vec2, progress: f32 },
450    /// Centered with wide stereo.
451    Centered,
452    /// From above (weather, sky effects).
453    Above,
454    /// From below (floor effects).
455    Below,
456}
457
458impl SoundOrigin {
459    /// Compute the effective position and pan for this origin.
460    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 }, // wide center
470                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
486// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
487// SpatialAudioSystem — main manager
488// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
489
490/// Manages spatial audio: positioned sounds, distance attenuation, and reverb.
491pub struct SpatialAudioSystem {
492    /// Active positioned sound sources.
493    sounds: Vec<SpatialSound>,
494    /// Next sound ID.
495    next_id: u32,
496    /// Listener position (usually camera/player center).
497    pub listener_pos: Vec2,
498    /// Arena half-width for pan calculation.
499    pub arena_half_width: f32,
500    /// Distance model.
501    pub distance_model: DistanceModel,
502    /// Room reverb.
503    pub reverb: SpatialReverb,
504    /// Current room type.
505    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    /// Set the listener position (usually the camera center or player position).
522    pub fn set_listener(&mut self, pos: Vec2) {
523        self.listener_pos = pos;
524    }
525
526    /// Change the room reverb preset.
527    pub fn set_room(&mut self, room: RoomType) {
528        self.room_type = room;
529        self.reverb.set_room(room);
530    }
531
532    /// Spawn a positioned sound and return its ID.
533    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            // Remove oldest inactive, or just the oldest
558            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    /// Update a traveling sound's progress.
569    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    /// Stop a sound by ID.
576    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    /// Tick: update lifetimes, remove expired sounds.
583    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        // Remove inactive sounds older than a threshold
594        self.sounds.retain(|s| s.active);
595    }
596
597    /// Compute the spatial mix for a mono sample at a given origin.
598    /// Returns (left_sample, right_sample) with pan, attenuation, and reverb.
599    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    /// Compute pan and gain for a world position (for external use).
607    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    /// Number of active sounds.
612    pub fn active_count(&self) -> usize {
613        self.sounds.iter().filter(|s| s.active).count()
614    }
615
616    /// Clear all sounds (room transition).
617    pub fn clear(&mut self) {
618        self.sounds.clear();
619        self.reverb.clear();
620    }
621}
622
623// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
624// Tests
625// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
626
627#[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        // Feed an impulse
675        let out0 = reverb.process_sample(1.0);
676        // Process some silence
677        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        // Feed impulse
691        combat_rev.process_sample(1.0);
692        cathedral_rev.process_sample(1.0);
693
694        // Measure tail energy at 4000 samples
695        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        // Sound from the right
729        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        // At progress 0.5, position is (0, 0) — should be roughly centered
742        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); // 10ms at 48kHz
758    }
759}