Skip to main content

ringkernel_wavesim3d/audio/
binaural.rs

1//! Binaural audio system for 3D spatial hearing simulation.
2//!
3//! Implements a virtual head with two ears for stereo audio output.
4//! Uses physical modeling based on:
5//! - Interaural Time Difference (ITD)
6//! - Interaural Level Difference (ILD)
7//! - Head shadow effect
8
9use crate::simulation::grid3d::SimulationGrid3D;
10use crate::simulation::physics::{constants, Orientation3D, Position3D};
11use std::collections::VecDeque;
12
13/// Virtual head for binaural audio capture.
14#[derive(Debug, Clone)]
15pub struct VirtualHead {
16    /// Center position of the head (between ears)
17    pub position: Position3D,
18    /// Head orientation (yaw, pitch, roll)
19    pub orientation: Orientation3D,
20    /// Distance between ears (default: 0.17m)
21    pub ear_spacing: f32,
22    /// Head radius for shadowing calculations (default: 0.0875m)
23    pub head_radius: f32,
24}
25
26impl Default for VirtualHead {
27    fn default() -> Self {
28        Self {
29            position: Position3D::origin(),
30            orientation: Orientation3D::default(),
31            ear_spacing: constants::EAR_SPACING,
32            head_radius: 0.0875, // Average adult head radius
33        }
34    }
35}
36
37impl VirtualHead {
38    /// Create a new virtual head at the given position.
39    pub fn new(position: Position3D) -> Self {
40        Self {
41            position,
42            ..Default::default()
43        }
44    }
45
46    /// Create a virtual head with custom ear spacing.
47    pub fn with_ear_spacing(mut self, spacing: f32) -> Self {
48        self.ear_spacing = spacing.max(0.05);
49        self
50    }
51
52    /// Set head orientation.
53    pub fn with_orientation(mut self, orientation: Orientation3D) -> Self {
54        self.orientation = orientation;
55        self
56    }
57
58    /// Move the head to a new position.
59    pub fn set_position(&mut self, pos: Position3D) {
60        self.position = pos;
61    }
62
63    /// Rotate the head.
64    pub fn set_orientation(&mut self, orientation: Orientation3D) {
65        self.orientation = orientation;
66    }
67
68    /// Get the position of the left ear.
69    pub fn left_ear_position(&self) -> Position3D {
70        let (rx, ry, rz) = self.orientation.right();
71        let half_spacing = self.ear_spacing / 2.0;
72        Position3D::new(
73            self.position.x - rx * half_spacing,
74            self.position.y - ry * half_spacing,
75            self.position.z - rz * half_spacing,
76        )
77    }
78
79    /// Get the position of the right ear.
80    pub fn right_ear_position(&self) -> Position3D {
81        let (rx, ry, rz) = self.orientation.right();
82        let half_spacing = self.ear_spacing / 2.0;
83        Position3D::new(
84            self.position.x + rx * half_spacing,
85            self.position.y + ry * half_spacing,
86            self.position.z + rz * half_spacing,
87        )
88    }
89
90    /// Calculate the angle to a sound source relative to the head's forward direction.
91    pub fn angle_to_source(&self, source: Position3D) -> (f32, f32) {
92        // Vector from head to source
93        let dx = source.x - self.position.x;
94        let dy = source.y - self.position.y;
95        let dz = source.z - self.position.z;
96
97        // Forward and right vectors
98        let (fx, fy, fz) = self.orientation.forward();
99        let (rx, ry, rz) = self.orientation.right();
100
101        // Project onto forward-right plane for azimuth
102        let forward_comp = dx * fx + dy * fy + dz * fz;
103        let right_comp = dx * rx + dy * ry + dz * rz;
104        let azimuth = right_comp.atan2(forward_comp);
105
106        // Elevation (angle above/below horizontal)
107        let horizontal_dist = (dx * dx + dz * dz).sqrt();
108        let elevation = dy.atan2(horizontal_dist);
109
110        (azimuth, elevation)
111    }
112
113    /// Calculate ITD (Interaural Time Difference) for a source position.
114    ///
115    /// Returns the time difference in seconds (positive = right ear leads).
116    pub fn calculate_itd(&self, source: Position3D, speed_of_sound: f32) -> f32 {
117        let left_dist = self.left_ear_position().distance_to(&source);
118        let right_dist = self.right_ear_position().distance_to(&source);
119
120        // Time difference based on path length difference
121        (left_dist - right_dist) / speed_of_sound
122    }
123
124    /// Calculate ILD (Interaural Level Difference) for a source.
125    ///
126    /// Returns the amplitude ratio (left/right) based on head shadow.
127    pub fn calculate_ild(&self, source: Position3D) -> (f32, f32) {
128        let (azimuth, _elevation) = self.angle_to_source(source);
129
130        // Simple head shadow model
131        // At 90° (source directly to the right), left ear is shadowed
132        // At -90° (source directly to the left), right ear is shadowed
133
134        let shadow_factor = 0.4; // Maximum shadow attenuation
135
136        let left_gain;
137        let right_gain;
138
139        if azimuth >= 0.0 {
140            // Source is to the right
141            right_gain = 1.0;
142            left_gain = 1.0 - shadow_factor * (azimuth / std::f32::consts::FRAC_PI_2).min(1.0);
143        } else {
144            // Source is to the left
145            left_gain = 1.0;
146            right_gain = 1.0 - shadow_factor * ((-azimuth) / std::f32::consts::FRAC_PI_2).min(1.0);
147        }
148
149        (left_gain, right_gain)
150    }
151}
152
153/// Binaural microphone for capturing stereo audio from the simulation.
154pub struct BinauralMicrophone {
155    /// Virtual head
156    pub head: VirtualHead,
157    /// Sample rate for output audio
158    pub sample_rate: u32,
159    /// Buffer for left ear samples
160    left_buffer: VecDeque<f32>,
161    /// Buffer for right ear samples
162    right_buffer: VecDeque<f32>,
163    /// Maximum buffer size
164    buffer_size: usize,
165    /// Interpolation factor (simulation steps per audio sample)
166    interp_factor: f32,
167    /// Accumulated simulation samples for interpolation
168    accumulated_left: Vec<f32>,
169    accumulated_right: Vec<f32>,
170}
171
172impl BinauralMicrophone {
173    /// Create a new binaural microphone.
174    ///
175    /// # Arguments
176    /// * `head` - Virtual head configuration
177    /// * `sample_rate` - Output audio sample rate (e.g., 44100 Hz)
178    /// * `simulation_dt` - Simulation time step in seconds
179    pub fn new(head: VirtualHead, sample_rate: u32, simulation_dt: f32) -> Self {
180        let buffer_size = sample_rate as usize * 2; // 2 seconds buffer
181
182        // Calculate how many simulation steps per audio sample
183        let audio_period = 1.0 / sample_rate as f32;
184        let interp_factor = audio_period / simulation_dt;
185
186        Self {
187            head,
188            sample_rate,
189            left_buffer: VecDeque::with_capacity(buffer_size),
190            right_buffer: VecDeque::with_capacity(buffer_size),
191            buffer_size,
192            interp_factor,
193            accumulated_left: Vec::new(),
194            accumulated_right: Vec::new(),
195        }
196    }
197
198    /// Capture a sample from the simulation grid.
199    ///
200    /// Should be called once per simulation step.
201    pub fn capture(&mut self, grid: &SimulationGrid3D) {
202        let left_pos = self.head.left_ear_position();
203        let right_pos = self.head.right_ear_position();
204
205        // Sample pressure at ear positions
206        let left_pressure = grid.sample_pressure(left_pos);
207        let right_pressure = grid.sample_pressure(right_pos);
208
209        // Accumulate samples for resampling
210        self.accumulated_left.push(left_pressure);
211        self.accumulated_right.push(right_pressure);
212
213        // Downsample to audio rate when we have enough samples
214        // Ensure we always remove at least 1 sample to prevent infinite loop
215        let min_remove = self.interp_factor.max(1.0).ceil() as usize;
216        while self.accumulated_left.len() >= min_remove {
217            let n = min_remove;
218
219            // Simple averaging for now (could use better filter)
220            let left_sample: f32 = self.accumulated_left.iter().take(n).sum::<f32>() / n as f32;
221            let right_sample: f32 = self.accumulated_right.iter().take(n).sum::<f32>() / n as f32;
222
223            // Add to output buffers
224            if self.left_buffer.len() < self.buffer_size {
225                self.left_buffer.push_back(left_sample);
226                self.right_buffer.push_back(right_sample);
227            }
228
229            // Remove processed samples (always at least 1)
230            let to_remove = min_remove.max(1);
231            self.accumulated_left
232                .drain(0..to_remove.min(self.accumulated_left.len()));
233            self.accumulated_right
234                .drain(0..to_remove.min(self.accumulated_right.len()));
235        }
236    }
237
238    /// Get available stereo samples.
239    ///
240    /// Returns (left_samples, right_samples).
241    pub fn get_samples(&mut self, count: usize) -> (Vec<f32>, Vec<f32>) {
242        let count = count.min(self.left_buffer.len());
243        let left: Vec<f32> = self.left_buffer.drain(0..count).collect();
244        let right: Vec<f32> = self.right_buffer.drain(0..count).collect();
245        (left, right)
246    }
247
248    /// Get interleaved stereo samples (for audio output).
249    pub fn get_interleaved(&mut self, frames: usize) -> Vec<f32> {
250        let frames = frames.min(self.left_buffer.len());
251        let mut output = Vec::with_capacity(frames * 2);
252
253        for _ in 0..frames {
254            if let (Some(left), Some(right)) =
255                (self.left_buffer.pop_front(), self.right_buffer.pop_front())
256            {
257                output.push(left);
258                output.push(right);
259            }
260        }
261
262        output
263    }
264
265    /// Get the number of available samples.
266    pub fn available_samples(&self) -> usize {
267        self.left_buffer.len()
268    }
269
270    /// Clear all buffers.
271    pub fn clear(&mut self) {
272        self.left_buffer.clear();
273        self.right_buffer.clear();
274        self.accumulated_left.clear();
275        self.accumulated_right.clear();
276    }
277
278    /// Update head position.
279    pub fn set_head_position(&mut self, pos: Position3D) {
280        self.head.set_position(pos);
281    }
282
283    /// Update head orientation.
284    pub fn set_head_orientation(&mut self, orientation: Orientation3D) {
285        self.head.set_orientation(orientation);
286    }
287}
288
289/// Audio output handler using cpal.
290#[cfg(feature = "audio-output")]
291pub struct AudioOutput {
292    stream: Option<cpal::Stream>,
293    sample_rate: u32,
294}
295
296/// Simple delay line for ITD simulation.
297pub struct DelayLine {
298    buffer: VecDeque<f32>,
299    max_delay_samples: usize,
300}
301
302impl DelayLine {
303    /// Create a new delay line.
304    pub fn new(max_delay_seconds: f32, sample_rate: u32) -> Self {
305        let max_delay_samples = (max_delay_seconds * sample_rate as f32).ceil() as usize;
306        Self {
307            buffer: VecDeque::from(vec![0.0; max_delay_samples]),
308            max_delay_samples,
309        }
310    }
311
312    /// Process a sample with the given delay.
313    pub fn process(&mut self, input: f32, delay_samples: f32) -> f32 {
314        // Add new sample
315        self.buffer.push_back(input);
316
317        // Get delayed sample (with linear interpolation)
318        let delay_int = delay_samples.floor() as usize;
319        let delay_frac = delay_samples - delay_int as f32;
320
321        let idx1 = self.buffer.len().saturating_sub(1 + delay_int);
322        let idx2 = idx1.saturating_sub(1);
323
324        let s1 = self.buffer.get(idx1).copied().unwrap_or(0.0);
325        let s2 = self.buffer.get(idx2).copied().unwrap_or(0.0);
326
327        // Remove old samples
328        while self.buffer.len() > self.max_delay_samples {
329            self.buffer.pop_front();
330        }
331
332        // Linear interpolation
333        s1 * (1.0 - delay_frac) + s2 * delay_frac
334    }
335
336    /// Clear the delay line.
337    pub fn clear(&mut self) {
338        self.buffer.clear();
339        self.buffer.resize(self.max_delay_samples, 0.0);
340    }
341}
342
343/// HRTF-based binaural processor (simplified).
344///
345/// Applies ITD and ILD based on source position relative to head.
346pub struct BinauralProcessor {
347    /// Left ear delay line
348    left_delay: DelayLine,
349    /// Right ear delay line
350    right_delay: DelayLine,
351    /// Sample rate
352    sample_rate: u32,
353    /// Speed of sound for ITD calculation
354    speed_of_sound: f32,
355}
356
357impl BinauralProcessor {
358    /// Create a new binaural processor.
359    pub fn new(sample_rate: u32, speed_of_sound: f32) -> Self {
360        // Maximum ITD is about 0.7ms (ear spacing / speed of sound)
361        let max_delay = 0.001;
362
363        Self {
364            left_delay: DelayLine::new(max_delay, sample_rate),
365            right_delay: DelayLine::new(max_delay, sample_rate),
366            sample_rate,
367            speed_of_sound,
368        }
369    }
370
371    /// Process a mono sample into stereo based on source and head position.
372    pub fn process(&mut self, input: f32, head: &VirtualHead, source: Position3D) -> (f32, f32) {
373        // Calculate ITD
374        let itd = head.calculate_itd(source, self.speed_of_sound);
375        let itd_samples = itd * self.sample_rate as f32;
376
377        // Calculate ILD
378        let (left_gain, right_gain) = head.calculate_ild(source);
379
380        // Apply delays
381        let (left_delay, right_delay) = if itd_samples >= 0.0 {
382            // Right ear leads (source to the right)
383            (itd_samples, 0.0)
384        } else {
385            // Left ear leads (source to the left)
386            (0.0, -itd_samples)
387        };
388
389        let left = self.left_delay.process(input * left_gain, left_delay);
390        let right = self.right_delay.process(input * right_gain, right_delay);
391
392        (left, right)
393    }
394
395    /// Clear all delay buffers.
396    pub fn clear(&mut self) {
397        self.left_delay.clear();
398        self.right_delay.clear();
399    }
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405
406    #[test]
407    fn test_virtual_head_ear_positions() {
408        let head = VirtualHead::new(Position3D::new(0.0, 0.0, 0.0));
409
410        let left = head.left_ear_position();
411        let right = head.right_ear_position();
412
413        // Ears should be symmetric
414        assert!((left.x + right.x).abs() < 0.001);
415
416        // Distance between ears should be ear_spacing
417        let distance = left.distance_to(&right);
418        assert!((distance - head.ear_spacing).abs() < 0.001);
419    }
420
421    #[test]
422    fn test_itd_calculation() {
423        let head = VirtualHead::new(Position3D::new(0.0, 0.0, 0.0));
424        let speed = 343.0;
425
426        // Source directly to the right
427        let source_right = Position3D::new(10.0, 0.0, 0.0);
428        let itd_right = head.calculate_itd(source_right, speed);
429
430        // Source directly to the left
431        let source_left = Position3D::new(-10.0, 0.0, 0.0);
432        let itd_left = head.calculate_itd(source_left, speed);
433
434        // ITD should be opposite for left vs right sources
435        assert!(itd_right > 0.0);
436        assert!(itd_left < 0.0);
437        assert!((itd_right + itd_left).abs() < 0.001);
438    }
439
440    #[test]
441    fn test_ild_calculation() {
442        let head = VirtualHead::new(Position3D::new(0.0, 0.0, 0.0));
443
444        // Source directly in front
445        let source_front = Position3D::new(0.0, 0.0, 10.0);
446        let (left_front, right_front) = head.calculate_ild(source_front);
447
448        // Should be equal for source in front
449        assert!((left_front - right_front).abs() < 0.1);
450
451        // Source to the right
452        let source_right = Position3D::new(10.0, 0.0, 0.0);
453        let (left_right, right_right) = head.calculate_ild(source_right);
454
455        // Right ear should have more gain
456        assert!(right_right > left_right);
457    }
458
459    #[test]
460    fn test_delay_line() {
461        let mut delay = DelayLine::new(0.01, 44100);
462
463        // Feed impulse
464        delay.process(1.0, 0.0);
465        for _ in 0..100 {
466            delay.process(0.0, 0.0);
467        }
468
469        // Delayed output should appear after delay
470        let output = delay.process(0.0, 50.0);
471        // The impulse should have passed through
472        assert!(output.abs() < 0.1);
473    }
474
475    #[test]
476    fn test_binaural_processor() {
477        let mut processor = BinauralProcessor::new(44100, 343.0);
478        let head = VirtualHead::new(Position3D::new(0.0, 0.0, 0.0));
479
480        // Source to the right
481        let source = Position3D::new(5.0, 0.0, 0.0);
482
483        // Process a burst
484        let mut left_energy = 0.0;
485        let mut right_energy = 0.0;
486
487        for i in 0..100 {
488            let input = if i < 10 { 1.0 } else { 0.0 };
489            let (left, right) = processor.process(input, &head, source);
490            left_energy += left * left;
491            right_energy += right * right;
492        }
493
494        // Right ear should have more energy (source is to the right)
495        assert!(
496            right_energy > left_energy,
497            "Right energy {} should be > left {}",
498            right_energy,
499            left_energy
500        );
501    }
502
503    #[test]
504    fn test_head_rotation() {
505        let mut head = VirtualHead::new(Position3D::new(0.0, 0.0, 0.0));
506
507        // Face forward, source to the right
508        let source = Position3D::new(5.0, 0.0, 0.0);
509        let (left1, right1) = head.calculate_ild(source);
510
511        // Rotate 90° right (now facing the source)
512        head.set_orientation(Orientation3D::new(std::f32::consts::FRAC_PI_2, 0.0, 0.0));
513        let (left2, right2) = head.calculate_ild(source);
514
515        // After rotation, ILD should be more balanced
516        let diff1 = (right1 - left1).abs();
517        let diff2 = (right2 - left2).abs();
518        assert!(
519            diff2 < diff1,
520            "After rotation, ILD diff {} should be < {}",
521            diff2,
522            diff1
523        );
524    }
525}