Skip to main content

rill_core_model/cavity/
mod.rs

1//! Cavity resonator models — Helmholtz single-cavity and coupled cavity arrays.
2//!
3//! # Sub-models
4//!
5//! - **HelmholtzCavity** — single Helmholtz resonator with reed excitation for
6//!   wind instrument modeling.
7//! - **CavityArray** — 1D chain of coupled Helmholtz cavities for wave
8//!   propagation experiments (acoustic metamaterials, band gaps, dispersion).
9
10mod params;
11
12pub use params::{CavityArrayParams, HelmholtzParams};
13
14use rill_core::traits::algorithm::{
15    Algorithm, AlgorithmCategory, AlgorithmMetadata, ParameterizedAlgorithm,
16};
17use rill_core::traits::ParamValue;
18use rill_core::Transcendental;
19
20/// Single Helmholtz cavity resonator.
21///
22/// Models the acoustic resonance of a cavity with a neck (bottle, vessel,
23/// instrument body). Implements a 2-pole bandpass filter at the Helmholtz
24/// frequency:
25///
26/// ```text
27/// f_res = c / (2π) · √(A / (V · L_eff))
28/// ```
29///
30/// where `L_eff = L + 1.7·r` (end correction for flanged opening).
31///
32/// With `excitation = 1`, the reed nonlinearity drives self-oscillation
33/// for wind instrument simulation (clarinet/saxophone-like behavior).
34#[derive(Debug, Clone)]
35pub struct HelmholtzCavity<T: Transcendental> {
36    params: HelmholtzParams<T>,
37    prev_out: T,
38    prev_prev_out: T,
39    reed_state: T,
40    r: T,
41    cos_omega: T,
42    sample_rate: f32,
43}
44
45impl<T: Transcendental> HelmholtzCavity<T> {
46    /// Create a Helmholtz cavity resonator.
47    pub fn new(params: HelmholtzParams<T>, sample_rate: f32) -> Self {
48        let mut cavity = Self {
49            params,
50            prev_out: T::ZERO,
51            prev_prev_out: T::ZERO,
52            reed_state: T::ZERO,
53            r: T::ONE,
54            cos_omega: T::ONE,
55            sample_rate,
56        };
57        cavity.recompute_coeffs();
58        cavity
59    }
60
61    /// Compute the Helmholtz resonant frequency in Hz.
62    pub fn resonant_frequency(&self) -> T {
63        let two_pi = T::from_f32(2.0 * std::f32::consts::PI);
64        let radius = (self.params.neck_area / T::PI).sqrt();
65        let l_eff = self.params.neck_length + T::from_f32(1.7) * radius;
66        self.params.sound_speed / two_pi
67            * (self.params.neck_area / (self.params.volume * l_eff)).sqrt()
68    }
69
70    fn recompute_coeffs(&mut self) {
71        if self.sample_rate == 0.0 {
72            return;
73        }
74        let sr = T::from_f32(self.sample_rate);
75        let f_res = self.resonant_frequency();
76        let omega = T::from_f32(2.0 * std::f32::consts::PI) * f_res / sr;
77        let damping = T::ONE - self.params.radiation_loss - self.params.viscous_loss;
78        self.r = damping;
79        self.cos_omega = omega.cos();
80    }
81
82    /// Reed nonlinearity — simplified single-reed model.
83    fn reed_flow(&mut self) -> T {
84        let closing = T::ONE - self.params.pressure - self.reed_state;
85        if closing > T::ZERO {
86            self.params.pressure * closing.sqrt()
87        } else {
88            T::ZERO
89        }
90    }
91
92    fn process_sample(&mut self, input: T) -> T {
93        if self.sample_rate == 0.0 {
94            return T::ZERO;
95        }
96        let excitation = if self.params.excitation == 1 {
97            let flow = self.reed_flow();
98            self.reed_state = flow;
99            flow
100        } else {
101            input
102        };
103
104        // 2-pole bandpass resonator (center frequency = Helmholtz freq)
105        let two_r_cos = T::from_f32(2.0) * self.r * self.cos_omega;
106        let r2 = self.r * self.r;
107        let y = excitation + two_r_cos * self.prev_out - r2 * self.prev_prev_out;
108
109        self.prev_prev_out = self.prev_out;
110        self.prev_out = y;
111
112        y
113    }
114}
115
116impl<T: Transcendental> Algorithm<T> for HelmholtzCavity<T> {
117    fn process(
118        &mut self,
119        input: Option<&[T]>,
120        output: &mut [T],
121    ) -> rill_core::traits::ProcessResult<()> {
122        for (i, out) in output.iter_mut().enumerate() {
123            let inp = input
124                .map(|s| s.get(i).copied().unwrap_or(T::ZERO))
125                .unwrap_or(T::ZERO);
126            *out = self.process_sample(inp);
127        }
128        Ok(())
129    }
130
131    fn reset(&mut self) {
132        self.prev_out = T::ZERO;
133        self.prev_prev_out = T::ZERO;
134        self.reed_state = T::ZERO;
135        self.recompute_coeffs();
136    }
137
138    fn init(&mut self, sample_rate: f32) {
139        self.sample_rate = sample_rate;
140        self.recompute_coeffs();
141    }
142
143    fn metadata(&self) -> AlgorithmMetadata {
144        AlgorithmMetadata {
145            name: "Helmholtz Cavity",
146            category: AlgorithmCategory::Filter,
147            description: "Single Helmholtz resonator with optional reed excitation",
148            author: "Rill",
149            version: "0.5",
150        }
151    }
152}
153
154impl<T: Transcendental> ParameterizedAlgorithm<T> for HelmholtzCavity<T> {
155    type Params = HelmholtzParams<T>;
156
157    fn params(&self) -> &Self::Params {
158        &self.params
159    }
160
161    fn set_params(&mut self, params: Self::Params) {
162        self.params = params;
163        self.recompute_coeffs();
164    }
165
166    fn set_parameter(&mut self, name: &str, value: ParamValue) -> Result<(), &'static str> {
167        match name {
168            "volume" => {
169                let mut p = self.params.clone();
170                p.volume = T::from_f64(value.as_f32().map(|v| v as f64).unwrap_or(0.001));
171                self.set_params(p);
172                Ok(())
173            }
174            "neck_area" => {
175                let mut p = self.params.clone();
176                p.neck_area = T::from_f64(value.as_f32().map(|v| v as f64).unwrap_or(0.0001));
177                self.set_params(p);
178                Ok(())
179            }
180            "neck_length" => {
181                let mut p = self.params.clone();
182                p.neck_length = T::from_f64(value.as_f32().map(|v| v as f64).unwrap_or(0.02));
183                self.set_params(p);
184                Ok(())
185            }
186            "pressure" => {
187                let mut p = self.params.clone();
188                p.pressure = T::from_f64(value.as_f32().map(|v| v as f64).unwrap_or(0.0));
189                self.set_params(p);
190                Ok(())
191            }
192            "excitation" => {
193                let mut p = self.params.clone();
194                p.excitation = value.as_i32().map(|v| v as u8).unwrap_or(0);
195                self.set_params(p);
196                Ok(())
197            }
198            _ => Err("Unknown parameter"),
199        }
200    }
201}
202
203/// 1D array of N coupled Helmholtz cavities.
204///
205/// Each cavity is coupled to its nearest neighbors with strength `coupling`.
206/// The array supports wave propagation experiments — injecting a signal at
207/// `input_index` and measuring at `output_index` reveals acoustic band gaps,
208/// slow-wave propagation, and nonlinear dispersion effects.
209#[derive(Debug, Clone)]
210pub struct CavityArray<T: Transcendental, const MAX_CAVITIES: usize> {
211    params: CavityArrayParams<T>,
212    cavities: [HelmholtzCavity<T>; MAX_CAVITIES],
213    prev_outputs: [T; MAX_CAVITIES],
214}
215
216impl<T: Transcendental, const MAX_CAVITIES: usize> CavityArray<T, MAX_CAVITIES> {
217    /// Create a cavity array with N identical Helmholtz cavities.
218    pub fn new(params: CavityArrayParams<T>, sample_rate: f32) -> Self {
219        let default_cavity = HelmholtzCavity::new(params.cavity_params.clone(), sample_rate);
220        let cavities = core::array::from_fn(|_| default_cavity.clone());
221        Self {
222            params,
223            cavities,
224            prev_outputs: [T::ZERO; MAX_CAVITIES],
225        }
226    }
227
228    fn process_sample(&mut self, input: T) -> T {
229        let n = self.params.num_cavities.min(MAX_CAVITIES);
230
231        // Store current outputs for coupling
232        let prev = self.prev_outputs;
233
234        let mut output = T::ZERO;
235        for i in 0..n {
236            // Coupled input: nearest-neighbor coupling
237            let coupling_input = if i > 0 {
238                prev[i - 1] * self.params.coupling
239            } else {
240                T::ZERO
241            } + if i + 1 < n {
242                prev[i + 1] * self.params.coupling
243            } else {
244                T::ZERO
245            };
246
247            // External input at the designated position
248            let ext_input = if i == self.params.input_index {
249                input
250            } else {
251                T::ZERO
252            };
253
254            let y = self.cavities[i].process_sample(coupling_input + ext_input);
255            self.prev_outputs[i] = y;
256
257            if i == self.params.output_index {
258                output = y;
259            }
260        }
261
262        output
263    }
264}
265
266impl<T: Transcendental, const MAX_CAVITIES: usize> Algorithm<T> for CavityArray<T, MAX_CAVITIES> {
267    fn process(
268        &mut self,
269        input: Option<&[T]>,
270        output: &mut [T],
271    ) -> rill_core::traits::ProcessResult<()> {
272        for (i, out) in output.iter_mut().enumerate() {
273            let inp = input
274                .map(|s| s.get(i).copied().unwrap_or(T::ZERO))
275                .unwrap_or(T::ZERO);
276            *out = self.process_sample(inp);
277        }
278        Ok(())
279    }
280
281    fn reset(&mut self) {
282        for cavity in self.cavities.iter_mut() {
283            cavity.reset();
284        }
285        self.prev_outputs = [T::ZERO; MAX_CAVITIES];
286    }
287
288    fn init(&mut self, sample_rate: f32) {
289        for cavity in self.cavities.iter_mut() {
290            cavity.init(sample_rate);
291        }
292    }
293
294    fn metadata(&self) -> AlgorithmMetadata {
295        AlgorithmMetadata {
296            name: "Cavity Array",
297            category: AlgorithmCategory::Filter,
298            description: "1D chain of coupled Helmholtz cavities for wave propagation",
299            author: "Rill",
300            version: "0.5",
301        }
302    }
303}
304
305impl<T: Transcendental, const MAX_CAVITIES: usize> ParameterizedAlgorithm<T>
306    for CavityArray<T, MAX_CAVITIES>
307{
308    type Params = CavityArrayParams<T>;
309
310    fn params(&self) -> &Self::Params {
311        &self.params
312    }
313
314    fn set_params(&mut self, params: Self::Params) {
315        self.params = params;
316        for cavity in self.cavities.iter_mut() {
317            cavity.set_params(self.params.cavity_params.clone());
318        }
319    }
320
321    fn set_parameter(&mut self, name: &str, value: ParamValue) -> Result<(), &'static str> {
322        match name {
323            "coupling" => {
324                let mut p = self.params.clone();
325                p.coupling = T::from_f64(value.as_f32().map(|v| v as f64).unwrap_or(0.1));
326                self.set_params(p);
327                Ok(())
328            }
329            _ => Err("Unknown parameter: use HelmholtzCavity for per-cavity params"),
330        }
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    // --- HelmholtzCavity tests ---
339
340    #[test]
341    fn test_helmholtz_creation() {
342        let params = HelmholtzParams::<f64>::default();
343        let cavity = HelmholtzCavity::<f64>::new(params, 44100.0);
344        let f = cavity.resonant_frequency();
345        assert!(f.to_f64() > 0.0);
346        assert!(f.to_f64() < 44100.0 / 2.0);
347    }
348
349    #[test]
350    fn test_helmholtz_frequency_known_bottle() {
351        // 1L bottle with 2cm neck, 1 cm² area → ~120 Hz resonance
352        let params = HelmholtzParams {
353            volume: 0.001.into(),
354            neck_area: 0.0001.into(),
355            neck_length: 0.02.into(),
356            sound_speed: 343.0.into(),
357            ..Default::default()
358        };
359        let cavity = HelmholtzCavity::<f64>::new(params, 44100.0);
360        let f = cavity.resonant_frequency().to_f64();
361        assert!(f > 50.0);
362        assert!(f < 300.0);
363    }
364
365    #[test]
366    fn test_helmholtz_algorithm_process() {
367        // Feed a sine at the resonance frequency — should pass through
368        let params = HelmholtzParams::<f64>::default();
369        let mut cavity = HelmholtzCavity::<f64>::new(params.clone(), 44100.0);
370        let f_res = cavity.resonant_frequency().to_f64();
371        let mut output = [0.0f64; 128];
372        let input: Vec<f64> = (0..128)
373            .map(|i| (2.0 * std::f64::consts::PI * f_res * i as f64 / 44100.0).sin() * 0.5)
374            .collect();
375        cavity.process(Some(&input), &mut output).unwrap();
376        let rms = (output.iter().map(|x| x * x).sum::<f64>() / 128.0).sqrt();
377        assert!(
378            rms > 0.01,
379            "RMS should be non-zero at resonance: {:.6}",
380            rms
381        );
382    }
383
384    #[test]
385    fn test_helmholtz_reed_self_oscillation() {
386        // With pressure and excitation=1, should produce non-zero output
387        let params = HelmholtzParams {
388            pressure: 0.5.into(),
389            excitation: 1,
390            ..Default::default()
391        };
392        let mut cavity = HelmholtzCavity::<f64>::new(params, 44100.0);
393        let mut output = [0.0f64; 128];
394        cavity.process(None, &mut output).unwrap();
395        let max_abs = output.iter().map(|x| x.abs()).fold(0.0, f64::max);
396        assert!(max_abs > 0.0, "Reed excitation should produce output");
397    }
398
399    #[test]
400    fn test_helmholtz_params() {
401        let params = HelmholtzParams::<f64>::default();
402        let mut cavity = HelmholtzCavity::<f64>::new(params, 44100.0);
403        let new_params = HelmholtzParams {
404            volume: 0.002.into(),
405            ..HelmholtzParams::default()
406        };
407        cavity.set_params(new_params);
408        assert!((cavity.params.volume.to_f64() - 0.002).abs() < 1e-10);
409    }
410
411    // --- CavityArray tests ---
412
413    #[test]
414    fn test_cavity_array_creation() {
415        let params = CavityArrayParams::<f64>::default();
416        let array = CavityArray::<f64, 8>::new(params, 44100.0);
417        assert!(array.params.num_cavities == 4);
418    }
419
420    #[test]
421    fn test_cavity_array_wave_propagation() {
422        // Input at cavity 0, output at cavity 3 with coupling — should see signal
423        let params = CavityArrayParams {
424            num_cavities: 4,
425            coupling: 0.3.into(),
426            input_index: 0,
427            output_index: 3,
428            ..Default::default()
429        };
430        let mut array = CavityArray::<f64, 8>::new(params, 44100.0);
431        let mut output = [0.0f64; 256];
432        let input: Vec<f64> = (0..256)
433            .map(|i| (2.0 * std::f64::consts::PI * 440.0 * i as f64 / 44100.0).sin() * 0.5)
434            .collect();
435        array.process(Some(&input), &mut output).unwrap();
436        let rms = (output.iter().map(|x| x * x).sum::<f64>() / 256.0).sqrt();
437        assert!(
438            rms > 0.001,
439            "Wave should propagate through coupled cavities"
440        );
441    }
442
443    #[test]
444    fn test_cavity_array_zero_coupling() {
445        // Zero coupling — no propagation, output should be near zero
446        let params = CavityArrayParams {
447            num_cavities: 4,
448            coupling: 0.0.into(),
449            input_index: 0,
450            output_index: 3,
451            ..Default::default()
452        };
453        let mut array = CavityArray::<f64, 8>::new(params, 44100.0);
454        let mut output = [0.0f64; 256];
455        let input: Vec<f64> = (0..256)
456            .map(|i| (2.0 * std::f64::consts::PI * 440.0 * i as f64 / 44100.0).sin() * 0.5)
457            .collect();
458        array.process(Some(&input), &mut output).unwrap();
459        let rms = (output.iter().map(|x| x * x).sum::<f64>() / 256.0).sqrt();
460        assert!(rms < 0.01, "Zero coupling should block propagation");
461    }
462}