Skip to main content

math_audio_xem_common/
source.rs

1//! Sound source definitions for room acoustics simulations
2
3use crate::types::Point3D;
4use ndarray::Array2;
5use serde::{Deserialize, Serialize};
6
7/// Directivity pattern sampled on a grid
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct DirectivityGrid {
10    /// Horizontal angles (azimuth) in degrees [0, 360) with step 10°
11    pub horizontal_angles: Vec<f64>,
12    /// Vertical angles (elevation) in degrees [0, 180] with step 10°
13    pub vertical_angles: Vec<f64>,
14    /// Magnitude at each (horizontal, vertical) angle pair
15    /// Shape: [n_vertical, n_horizontal]
16    pub magnitude: Array2<f64>,
17}
18
19impl DirectivityGrid {
20    /// Create omnidirectional pattern (uniform radiation)
21    pub fn omnidirectional() -> Self {
22        let horizontal_angles: Vec<f64> = (0..36).map(|i| i as f64 * 10.0).collect();
23        let vertical_angles: Vec<f64> = (0..19).map(|i| i as f64 * 10.0).collect();
24
25        let magnitude = Array2::ones((vertical_angles.len(), horizontal_angles.len()));
26
27        Self {
28            horizontal_angles,
29            vertical_angles,
30            magnitude,
31        }
32    }
33
34    /// Create a cardioid directivity pattern
35    pub fn cardioid() -> Self {
36        let horizontal_angles: Vec<f64> = (0..36).map(|i| i as f64 * 10.0).collect();
37        let vertical_angles: Vec<f64> = (0..19).map(|i| i as f64 * 10.0).collect();
38
39        let mut magnitude = Array2::zeros((vertical_angles.len(), horizontal_angles.len()));
40
41        for (v_idx, &v_angle) in vertical_angles.iter().enumerate() {
42            for (h_idx, &h_angle) in horizontal_angles.iter().enumerate() {
43                // Cardioid pattern: 0.5 * (1 + cos(theta))
44                let theta_rad = v_angle.to_radians();
45                let phi_rad = h_angle.to_radians();
46                // Forward direction is along +Y (phi=90, theta=90)
47                let forward_dot = theta_rad.sin() * phi_rad.sin();
48                magnitude[[v_idx, h_idx]] = 0.5 * (1.0 + forward_dot).max(0.0);
49            }
50        }
51
52        Self {
53            horizontal_angles,
54            vertical_angles,
55            magnitude,
56        }
57    }
58
59    /// Interpolate directivity at arbitrary direction
60    pub fn interpolate(&self, theta: f64, phi: f64) -> f64 {
61        // Convert spherical to degrees
62        let theta_deg = theta.to_degrees();
63        let mut phi_deg = phi.to_degrees();
64
65        // Normalize phi to [0, 360)
66        while phi_deg < 0.0 {
67            phi_deg += 360.0;
68        }
69        while phi_deg >= 360.0 {
70            phi_deg -= 360.0;
71        }
72
73        // Find surrounding angles
74        let h_idx = (phi_deg / 10.0).floor() as usize;
75        let v_idx = (theta_deg / 10.0).floor() as usize;
76
77        let h_idx = h_idx.min(self.horizontal_angles.len() - 1);
78        let v_idx = v_idx.min(self.vertical_angles.len() - 1);
79
80        let h_next = (h_idx + 1) % self.horizontal_angles.len();
81        let v_next = (v_idx + 1).min(self.vertical_angles.len() - 1);
82
83        // Bilinear interpolation
84        let h_frac = (phi_deg / 10.0) - h_idx as f64;
85        let v_frac = (theta_deg / 10.0) - v_idx as f64;
86
87        let m00 = self.magnitude[[v_idx, h_idx]];
88        let m01 = self.magnitude[[v_idx, h_next]];
89        let m10 = self.magnitude[[v_next, h_idx]];
90        let m11 = self.magnitude[[v_next, h_next]];
91
92        let m0 = m00 * (1.0 - h_frac) + m01 * h_frac;
93        let m1 = m10 * (1.0 - h_frac) + m11 * h_frac;
94
95        m0 * (1.0 - v_frac) + m1 * v_frac
96    }
97}
98
99/// Directivity model
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub enum Directivity {
102    /// Sampled grid (static)
103    Grid(DirectivityGrid),
104    /// Classical frequency-dependent directional source
105    Classical {
106        /// Horizontal beamwidth (degrees) at high frequency
107        h_angle: f64,
108        /// Vertical beamwidth (degrees) at high frequency
109        v_angle: f64,
110    },
111}
112
113impl Directivity {
114    /// Get amplitude scaling factor for a direction and frequency
115    pub fn amplitude(&self, theta: f64, phi: f64, frequency: f64) -> f64 {
116        match self {
117            Directivity::Grid(grid) => grid.interpolate(theta, phi),
118            Directivity::Classical { h_angle, v_angle } => {
119                // Classical pattern: Gaussian-like beam narrowing with frequency
120                // Transition from Omni (360) at 80Hz to Target at 800Hz (approx)
121
122                let min_f = 80.0;
123                let max_f = 800.0; // Transition end
124
125                let t = if frequency <= min_f {
126                    0.0
127                } else if frequency >= max_f {
128                    1.0
129                } else {
130                    (frequency.ln() - min_f.ln()) / (max_f.ln() - min_f.ln())
131                };
132
133                let h_width = 360.0 * (1.0 - t) + h_angle * t;
134                let v_width = 360.0 * (1.0 - t) + v_angle * t;
135
136                // Direction relative to forward axis (+Y: theta=90, phi=90)
137                // We need angle from axis.
138                // Axis vector: (0, 1, 0)
139                // Point vector: (sin(theta)cos(phi), sin(theta)sin(phi), cos(theta))
140                // Dot product = sin(theta)sin(phi)
141                // Angle off axis 'alpha' = acos(dot)
142
143                // But we have separate H and V widths.
144                // H angle is deviation in XY plane (phi). Forward is 90.
145                // V angle is deviation in Z plane (theta). Forward is 90.
146
147                let mut phi_deg = phi.to_degrees();
148                while phi_deg < 0.0 {
149                    phi_deg += 360.0;
150                }
151                let delta_phi = (phi_deg - 90.0).abs().min((360.0 - (phi_deg - 90.0)).abs()); // Shortest distance to 90
152
153                let theta_deg = theta.to_degrees();
154                let delta_theta = (theta_deg - 90.0).abs();
155
156                // Normalized deviations
157                // x = angle / (width/2)
158                let x_h = delta_phi / (h_width / 2.0);
159                let x_v = delta_theta / (v_width / 2.0);
160
161                // Combined distance in parameter space (elliptical cone)
162                let r_sq = x_h * x_h + x_v * x_v;
163
164                // Gaussian: m = 0.5^(r^2) -> -6dB at edge (r=1)
165                0.5_f64.powf(r_sq)
166            }
167        }
168    }
169}
170
171/// Crossover filter for frequency-limited sources
172#[derive(Debug, Clone, Default, Serialize, Deserialize)]
173pub enum CrossoverFilter {
174    /// Full range (no filter)
175    #[default]
176    FullRange,
177    /// Low-pass filter (Butterworth)
178    Lowpass {
179        /// Cutoff frequency (Hz)
180        cutoff_freq: f64,
181        /// Filter order (e.g., 2, 4)
182        order: u32,
183    },
184    /// High-pass filter (Butterworth)
185    Highpass {
186        /// Cutoff frequency (Hz)
187        cutoff_freq: f64,
188        /// Filter order (e.g., 2, 4)
189        order: u32,
190    },
191    /// Band-pass filter (combined Lowpass and Highpass)
192    Bandpass {
193        /// Low cutoff frequency (Hz)
194        low_cutoff: f64,
195        /// High cutoff frequency (Hz)
196        high_cutoff: f64,
197        /// Filter order
198        order: u32,
199    },
200}
201
202impl CrossoverFilter {
203    /// Get amplitude multiplier at a given frequency
204    pub fn amplitude_at_frequency(&self, frequency: f64) -> f64 {
205        match self {
206            CrossoverFilter::FullRange => 1.0,
207            CrossoverFilter::Lowpass { cutoff_freq, order } => {
208                let ratio = frequency / cutoff_freq;
209                1.0 / (1.0 + ratio.powi(*order as i32 * 2)).sqrt()
210            }
211            CrossoverFilter::Highpass { cutoff_freq, order } => {
212                let ratio = cutoff_freq / frequency;
213                1.0 / (1.0 + ratio.powi(*order as i32 * 2)).sqrt()
214            }
215            CrossoverFilter::Bandpass {
216                low_cutoff,
217                high_cutoff,
218                order,
219            } => {
220                let high_ratio = low_cutoff / frequency;
221                let low_ratio = frequency / high_cutoff;
222                let hp_response = 1.0 / (1.0 + high_ratio.powi(*order as i32 * 2)).sqrt();
223                let lp_response = 1.0 / (1.0 + low_ratio.powi(*order as i32 * 2)).sqrt();
224                hp_response * lp_response
225            }
226        }
227    }
228}
229
230/// Sound source with position and directivity
231#[derive(Debug, Clone, Serialize, Deserialize)]
232pub struct Source {
233    /// Source position
234    pub position: Point3D,
235    /// Directivity pattern
236    pub directivity: Directivity,
237    /// Source amplitude (strength)
238    pub amplitude: f64,
239    /// Crossover filter
240    pub crossover: CrossoverFilter,
241    /// Source name (e.g., "Left Main", "Subwoofer")
242    pub name: String,
243}
244
245impl Source {
246    /// Create a new source
247    pub fn new(position: Point3D, directivity: Directivity, amplitude: f64) -> Self {
248        Self {
249            position,
250            directivity,
251            amplitude,
252            crossover: CrossoverFilter::FullRange,
253            name: String::from("Source"),
254        }
255    }
256
257    /// Create a simple omnidirectional source at position
258    pub fn omnidirectional(position: Point3D, amplitude: f64) -> Self {
259        Self::new(
260            position,
261            Directivity::Grid(DirectivityGrid::omnidirectional()),
262            amplitude,
263        )
264    }
265
266    /// Create a classical directional source
267    pub fn classical(position: Point3D, h_angle: f64, v_angle: f64, amplitude: f64) -> Self {
268        Self::new(
269            position,
270            Directivity::Classical { h_angle, v_angle },
271            amplitude,
272        )
273    }
274
275    /// Set crossover filter
276    pub fn with_crossover(mut self, crossover: CrossoverFilter) -> Self {
277        self.crossover = crossover;
278        self
279    }
280
281    /// Set source name
282    pub fn with_name(mut self, name: String) -> Self {
283        self.name = name;
284        self
285    }
286
287    /// Get directional amplitude towards a point at a specific frequency
288    pub fn amplitude_towards(&self, point: &Point3D, frequency: f64) -> f64 {
289        let dx = point.x - self.position.x;
290        let dy = point.y - self.position.y;
291        let dz = point.z - self.position.z;
292
293        let r = (dx * dx + dy * dy + dz * dz).sqrt();
294        if r < 1e-10 {
295            return self.amplitude * self.crossover.amplitude_at_frequency(frequency);
296        }
297
298        let theta = (dz / r).acos();
299        let phi = dy.atan2(dx);
300
301        let directivity_factor = self.directivity.amplitude(theta, phi, frequency);
302        let crossover_factor = self.crossover.amplitude_at_frequency(frequency);
303        self.amplitude * directivity_factor * crossover_factor
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use std::f64::consts::PI;
311
312    #[test]
313    fn test_omnidirectional_pattern() {
314        let grid = DirectivityGrid::omnidirectional();
315        // Should be 1.0 in all directions
316        assert!((grid.interpolate(0.0, 0.0) - 1.0).abs() < 1e-6);
317        assert!((grid.interpolate(PI / 2.0, PI) - 1.0).abs() < 1e-6);
318        assert!((grid.interpolate(PI, 0.0) - 1.0).abs() < 1e-6);
319    }
320
321    #[test]
322    fn test_crossover_lowpass() {
323        let crossover = CrossoverFilter::Lowpass {
324            cutoff_freq: 100.0,
325            order: 2,
326        };
327        // Well below cutoff
328        assert!((crossover.amplitude_at_frequency(10.0) - 1.0).abs() < 0.1);
329        // At cutoff (-3dB point)
330        let at_cutoff = crossover.amplitude_at_frequency(100.0);
331        assert!(at_cutoff > 0.6 && at_cutoff < 0.8);
332        // Well above cutoff
333        assert!(crossover.amplitude_at_frequency(1000.0) < 0.1);
334    }
335
336    #[test]
337    fn test_source_amplitude() {
338        let source = Source::omnidirectional(Point3D::new(0.0, 0.0, 0.0), 1.0);
339        let amp = source.amplitude_towards(&Point3D::new(1.0, 0.0, 0.0), 1000.0);
340        assert!((amp - 1.0).abs() < 1e-6);
341    }
342
343    #[test]
344    fn test_classical_source_beaming() {
345        let source = Source::classical(Point3D::new(0.0, 0.0, 0.0), 60.0, 40.0, 1.0);
346
347        // At low freq (50Hz), should be omni
348        let amp_low = source.amplitude_towards(&Point3D::new(0.0, -1.0, 0.0), 50.0); // Rear (180 deg)
349        // Forward is +Y. Rear is -Y.
350        // Forward: theta=90, phi=90.
351        // Rear: theta=90, phi=270 (-90).
352        // Delta phi = 180.
353        // If omni, width=360. x = 180/180 = 1. m = 0.5.
354        // Wait, omni means width=360 implies +/- 180 deg?
355        // Width is full width. +/- 180 covers full circle.
356        // x = delta / 180.
357        // Rear delta = 180. x=1. m=0.5 (-6dB).
358        // So Omni via this formula is -6dB at rear.
359        // True Omni should be 1.0 everywhere.
360        // My Gaussian model with width=360 is not perfectly Omni.
361        // But "Typically omni below 80Hz" usually allows some directivity or just "wide".
362        // If I want true Omni, I should handle width >= 360 specially or increase width to infinity.
363        // But 360 width (-6dB at +/-180) is acceptable for "Omni-like".
364        assert!(amp_low > 0.4);
365
366        // At high freq (2000Hz), should be narrow
367        // H=60 -> +/- 30 deg is -6dB.
368        // Side (90 deg off axis): x = 90/30 = 3. m = 0.5^9 = 0.0019.
369        let amp_high_side = source.amplitude_towards(&Point3D::new(1.0, 0.0, 0.0), 2000.0); // Side (X axis, phi=0, delta=90)
370        assert!(amp_high_side < 0.1);
371
372        // On axis should be 1.0
373        let amp_high_on = source.amplitude_towards(&Point3D::new(0.0, 1.0, 0.0), 2000.0); // Forward (+Y)
374        assert!((amp_high_on - 1.0).abs() < 1e-6);
375    }
376}