Skip to main content

gled_model/sound/
trigger.rs

1//! A running sound trigger: follows the spectrum in its frequency band and
2//! turns it into a level through an ADSR envelope.
3
4use super::trigger_params::FREQ_BINS;
5pub use super::trigger_params::SoundTriggerParams;
6use ndarray::Array1;
7use std::ops::Mul;
8
9/// How often the audio thread feeds a trigger a new spectrum.
10pub(crate) const SAMPLE_INTERVAL_MS: u64 = 10;
11
12const MAX_BUFFER_LENGTH: usize = 1001;
13
14#[derive(Debug, Clone, Copy, PartialEq, Default)]
15pub(crate) enum AdsrPhase {
16    #[default]
17    Idle,
18    Attack,
19    Decay,
20    Sustain,
21    Release,
22}
23
24#[derive(Debug, PartialEq, Clone)]
25pub struct SoundTrigger {
26    pub(crate) phase: AdsrPhase,
27    pub(crate) gate_active: bool,
28    pub params: SoundTriggerParams,
29    pub impulse: f32,
30    pub(crate) delta_time: f32,
31    pub spectrum: Array1<f32>,
32    pub current_level: f32,
33    sample_buffer: PrimitiveRingBuffer<Array1<f32>>,
34    running_sum: Array1<f32>,
35    prev_averaging_samples: usize,
36    echo_buffer: PrimitiveRingBuffer<f32>,
37    running_impulse_sum: f32,
38}
39
40impl Default for SoundTrigger {
41    fn default() -> Self {
42        SoundTrigger::new(
43            SoundTriggerParams::default(),
44            SAMPLE_INTERVAL_MS as f32 / 1000.,
45        )
46    }
47}
48
49impl SoundTrigger {
50    pub fn new(params: SoundTriggerParams, delta_time: f32) -> Self {
51        Self {
52            delta_time,
53            gate_active: false,
54            spectrum: Array1::zeros(FREQ_BINS),
55            phase: AdsrPhase::Idle,
56            params,
57            current_level: 0.0,
58            impulse: 0.0,
59            sample_buffer: PrimitiveRingBuffer::new(Array1::zeros(FREQ_BINS), MAX_BUFFER_LENGTH),
60            running_sum: Array1::zeros(FREQ_BINS),
61            prev_averaging_samples: params.averaging_samples,
62            echo_buffer: PrimitiveRingBuffer::new(0.0, MAX_BUFFER_LENGTH),
63            running_impulse_sum: 0.0,
64        }
65    }
66
67    pub fn reset(&mut self) {
68        self.phase = AdsrPhase::Idle;
69        self.gate_active = false;
70        self.current_level = 0.0;
71        self.impulse = 0.0;
72        self.sample_buffer = PrimitiveRingBuffer::new(Array1::zeros(FREQ_BINS), MAX_BUFFER_LENGTH);
73        self.running_sum = Array1::zeros(FREQ_BINS);
74        self.spectrum = Array1::zeros(FREQ_BINS);
75    }
76
77    pub fn tick(&mut self, samples: [f32; FREQ_BINS]) {
78        self.compute_running_average(samples);
79        //self.spectrum = self.spectrum.map(|x|{ 1.0 + (10.0 * self.params.sensitivity + 1.0) * x.mul(5.0).log10() });
80        self.spectrum.map_inplace(|x| {
81            *x = x.mul(self.delta_time * 3.0).clamp(0.0, f32::INFINITY);
82        });
83
84        let max_f = (self.params.center_bin + self.params.bin_radius).clamp(0, FREQ_BINS - 1);
85        let start = self
86            .params
87            .center_bin
88            .saturating_sub(self.params.bin_radius)
89            .clamp(1, FREQ_BINS - 1);
90        let len = max_f.saturating_sub(start);
91        self.impulse = (self
92            .spectrum
93            .iter()
94            .skip(start)
95            .take(len)
96            .map(|x| x.powf(2.))
97            .sum::<f32>()
98            / (2. * self.params.bin_radius as f32))
99            .sqrt()
100            .mul(4.0);
101
102        self.impulse = self.highpass(self.impulse);
103        self.impulse = self.impulse.mul(self.params.sensitivity);
104        self.tick_adsr(self.impulse);
105    }
106
107    /// Calculate the envelope amplitude at the current timepoint.
108    ///
109    /// # Arguments
110    /// * `input` - A continuous float control signal (Gate).
111    pub fn tick_adsr(&mut self, input: f32) -> f32 {
112        self.gate_active = if self.gate_active {
113            input > self.params.gate_deactivation_threshold
114        } else {
115            input > self.params.gate_activation_threshold
116        };
117
118        // 1. Handle State Transitions
119        match self.phase {
120            AdsrPhase::Idle => {
121                if self.gate_active {
122                    self.phase = AdsrPhase::Attack;
123                }
124            }
125            AdsrPhase::Attack => {
126                if !self.gate_active {
127                    self.phase = AdsrPhase::Release;
128                } else if self.current_level >= 1.0 {
129                    self.phase = AdsrPhase::Decay;
130                }
131            }
132            AdsrPhase::Decay => {
133                if !self.gate_active {
134                    self.phase = AdsrPhase::Release;
135                } else if self.current_level <= self.params.sustain_level {
136                    self.phase = AdsrPhase::Sustain;
137                }
138            }
139            AdsrPhase::Sustain => {
140                if !self.gate_active {
141                    self.phase = AdsrPhase::Release;
142                }
143                // In Sustain, we just hold the level (handled in calculation step)
144            }
145            AdsrPhase::Release => {
146                if self.gate_active {
147                    // Re-trigger: Standard ADSR behavior is to Attack from current level
148                    self.phase = AdsrPhase::Attack;
149                } else if self.current_level <= 0.0 {
150                    self.phase = AdsrPhase::Idle;
151                }
152            }
153        }
154
155        // 2. Calculate Amplitude Changes (Linear Logic)
156        match self.phase {
157            AdsrPhase::Idle => {
158                self.current_level = 0.0;
159            }
160            AdsrPhase::Attack => {
161                // Rate = 1.0 / duration
162                let rate = 1.0 / self.params.attack_duration.max(0.001);
163                self.current_level += rate * self.delta_time;
164                self.current_level = self.current_level.min(1.0);
165            }
166            AdsrPhase::Decay => {
167                // Rate = distance to sustain / duration
168                let dist = 1.0 - self.params.sustain_level;
169                let rate = dist / self.params.decay_duration.max(0.001);
170                self.current_level -= rate * self.delta_time;
171                self.current_level = self.current_level.max(self.params.sustain_level);
172            }
173            AdsrPhase::Sustain => {
174                // Constant level
175                self.current_level = self.params.sustain_level;
176            }
177            AdsrPhase::Release => {
178                // Rate = 1.0 / duration (standard is to release from max)
179                // or proportional to current level (exponential-ish)
180                // Here we use linear release from MAX for consistency
181                let rate = 1.0 / self.params.release_duration.max(0.001);
182                self.current_level -= rate * self.delta_time;
183                self.current_level = self.current_level.max(0.0);
184            }
185        }
186
187        // 3. Final Safety Clamp
188        self.current_level = self.current_level.clamp(0.0, 1.0);
189
190        self.current_level
191    }
192
193    pub(crate) fn compute_running_average(&mut self, current_sample: [f32; FREQ_BINS]) {
194        // idea of the running sum:
195        // the following condition shall always hold:
196        // running_sum == (current_buffer_index-averaging_samples..=current_buffer_index).folding_sum(|i| buffer[i])
197        if self.prev_averaging_samples != self.params.averaging_samples {
198            // dynamically adapt the running sample to the new length
199            // let delta = self.params.averaging_samples as i32 - self.prev_averaging_samples as i32;
200            // if delta > 0 {
201            //     // the new buffer is longer:
202            //     // add the samples from the *new* last index until the *old* last index to the running sum
203            //     // including the new last index, excluding the old last index
204            //     (self.prev_averaging_samples+1..self.params.averaging_samples+1).for_each(|i|
205            //         self.running_sum += &self.sample_buffer.get_from_offset(-(i as i32))
206            //     );
207            // } else {
208            //     // the new buffer is shorter:
209            //     // remove the samples from the *old* last index until the *new* last index from the running sum
210            //     // including the old last index, excluding the new last index
211            //     (self.params.averaging_samples..self.prev_averaging_samples).for_each(|i|
212            //         self.running_sum -= &self.sample_buffer.get_from_offset(-(i as i32))
213            //     );
214            // }
215
216            // the non-dynamic variant of this:
217            self.running_sum = (0..=self.params.averaging_samples)
218                .map(|i| self.sample_buffer.get_from_offset(i as i32))
219                .fold(Array1::zeros(FREQ_BINS), |acc, x| acc + x);
220
221            self.prev_averaging_samples = self.params.averaging_samples;
222        }
223
224        let current_rms_sample = Array1::from_vec(current_sample.to_vec());
225
226        // remove the oldest sample from the buffer
227        let remove = self
228            .sample_buffer
229            .get_from_offset(-(self.params.averaging_samples as i32));
230        self.running_sum = &self.running_sum - &remove;
231        // advance the buffer
232        self.sample_buffer.progress();
233        // add the new sample to the buffer
234        self.sample_buffer.insert(current_rms_sample.clone());
235        self.running_sum = &self.running_sum + &current_rms_sample;
236
237        // use a small decay here to counter the accumulation of errors
238        //self.running_sum = &self.running_sum * 0.98;
239        self.spectrum = &self.running_sum / self.params.averaging_samples as f32;
240    }
241
242    fn highpass(&mut self, x: f32) -> f32 {
243        let remove = self
244            .echo_buffer
245            .get_from_offset(-(self.params.echo_samples as i32));
246        self.running_impulse_sum -= remove;
247        // advance the buffer
248        self.echo_buffer.progress();
249        // add the new sample to the buffer
250        self.echo_buffer.insert(x);
251        self.running_impulse_sum += x;
252        let y = self.running_impulse_sum / (2.0 * self.params.echo_samples as f32);
253        x - y
254    }
255}
256
257#[derive(Clone, Debug, PartialEq)]
258struct PrimitiveRingBuffer<T>
259where
260    T: Clone,
261{
262    inner: Vec<T>,
263    capacity: usize,
264    front: usize,
265}
266
267impl<T> PrimitiveRingBuffer<T>
268where
269    T: Clone,
270{
271    pub(crate) fn new(default_elem: T, capacity: usize) -> Self {
272        Self {
273            inner: Vec::from_iter(std::iter::repeat_n(default_elem, capacity)),
274            capacity,
275            front: capacity.saturating_sub(1),
276        }
277    }
278    pub(crate) fn get_from_offset(&self, offset: i32) -> T {
279        self.inner[(self.front as i32 + offset).rem_euclid(self.capacity as i32) as usize].clone()
280    }
281    pub(crate) fn insert(&mut self, elem: T) {
282        self.inner[self.front] = elem;
283    }
284
285    pub(crate) fn progress(&mut self) {
286        self.front = (self.front + 1) % self.capacity;
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    fn run_for_with_impulse_at(run_for: usize, at: usize, impulse: f32) -> SoundTrigger {
295        let mut signal = SoundTrigger::new(SoundTriggerParams::default(), 0.01);
296        signal.params.averaging_samples = 1;
297        for i in 0..run_for {
298            let data = if i == at {
299                [impulse; FREQ_BINS]
300            } else {
301                [0f32; FREQ_BINS]
302            };
303            signal.tick(data);
304        }
305        signal
306    }
307
308    #[test]
309    fn test_running_sum_removal() {
310        let signal = run_for_with_impulse_at(4, 1, 1.0);
311        assert_eq!(signal.running_sum, Array1::from_vec(vec![0f32; FREQ_BINS]));
312    }
313
314    #[test]
315    fn test_wraparound() {
316        let mut signal = SoundTrigger::new(SoundTriggerParams::default(), 0.01);
317        signal.params.averaging_samples = 1;
318        for i in 0..(MAX_BUFFER_LENGTH + 10) {
319            signal.tick([1f32; FREQ_BINS]);
320            if i >= 2 {
321                assert_eq!(signal.running_sum, Array1::from_vec(vec![2f32; FREQ_BINS]))
322            }
323        }
324    }
325}