gled-model 2.32.1

The data model of gled: projects, scenes, effects, animations, curves and assets
Documentation
//! A running sound trigger: follows the spectrum in its frequency band and
//! turns it into a level through an ADSR envelope.

use super::trigger_params::FREQ_BINS;
pub use super::trigger_params::SoundTriggerParams;
use ndarray::Array1;
use std::ops::Mul;

/// How often the audio thread feeds a trigger a new spectrum.
pub(crate) const SAMPLE_INTERVAL_MS: u64 = 10;

const MAX_BUFFER_LENGTH: usize = 1001;

#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub(crate) enum AdsrPhase {
    #[default]
    Idle,
    Attack,
    Decay,
    Sustain,
    Release,
}

#[derive(Debug, PartialEq, Clone)]
pub struct SoundTrigger {
    pub(crate) phase: AdsrPhase,
    pub(crate) gate_active: bool,
    pub params: SoundTriggerParams,
    pub impulse: f32,
    pub(crate) delta_time: f32,
    pub spectrum: Array1<f32>,
    pub current_level: f32,
    sample_buffer: PrimitiveRingBuffer<Array1<f32>>,
    running_sum: Array1<f32>,
    prev_averaging_samples: usize,
    echo_buffer: PrimitiveRingBuffer<f32>,
    running_impulse_sum: f32,
}

impl Default for SoundTrigger {
    fn default() -> Self {
        SoundTrigger::new(
            SoundTriggerParams::default(),
            SAMPLE_INTERVAL_MS as f32 / 1000.,
        )
    }
}

impl SoundTrigger {
    pub fn new(params: SoundTriggerParams, delta_time: f32) -> Self {
        Self {
            delta_time,
            gate_active: false,
            spectrum: Array1::zeros(FREQ_BINS),
            phase: AdsrPhase::Idle,
            params,
            current_level: 0.0,
            impulse: 0.0,
            sample_buffer: PrimitiveRingBuffer::new(Array1::zeros(FREQ_BINS), MAX_BUFFER_LENGTH),
            running_sum: Array1::zeros(FREQ_BINS),
            prev_averaging_samples: params.averaging_samples,
            echo_buffer: PrimitiveRingBuffer::new(0.0, MAX_BUFFER_LENGTH),
            running_impulse_sum: 0.0,
        }
    }

    pub fn reset(&mut self) {
        self.phase = AdsrPhase::Idle;
        self.gate_active = false;
        self.current_level = 0.0;
        self.impulse = 0.0;
        self.sample_buffer = PrimitiveRingBuffer::new(Array1::zeros(FREQ_BINS), MAX_BUFFER_LENGTH);
        self.running_sum = Array1::zeros(FREQ_BINS);
        self.spectrum = Array1::zeros(FREQ_BINS);
    }

    pub fn tick(&mut self, samples: [f32; FREQ_BINS]) {
        self.compute_running_average(samples);
        //self.spectrum = self.spectrum.map(|x|{ 1.0 + (10.0 * self.params.sensitivity + 1.0) * x.mul(5.0).log10() });
        self.spectrum.map_inplace(|x| {
            *x = x.mul(self.delta_time * 3.0).clamp(0.0, f32::INFINITY);
        });

        let max_f = (self.params.center_bin + self.params.bin_radius).clamp(0, FREQ_BINS - 1);
        let start = self
            .params
            .center_bin
            .saturating_sub(self.params.bin_radius)
            .clamp(1, FREQ_BINS - 1);
        let len = max_f.saturating_sub(start);
        self.impulse = (self
            .spectrum
            .iter()
            .skip(start)
            .take(len)
            .map(|x| x.powf(2.))
            .sum::<f32>()
            / (2. * self.params.bin_radius as f32))
            .sqrt()
            .mul(4.0);

        self.impulse = self.highpass(self.impulse);
        self.impulse = self.impulse.mul(self.params.sensitivity);
        self.tick_adsr(self.impulse);
    }

    /// Calculate the envelope amplitude at the current timepoint.
    ///
    /// # Arguments
    /// * `input` - A continuous float control signal (Gate).
    pub fn tick_adsr(&mut self, input: f32) -> f32 {
        self.gate_active = if self.gate_active {
            input > self.params.gate_deactivation_threshold
        } else {
            input > self.params.gate_activation_threshold
        };

        // 1. Handle State Transitions
        match self.phase {
            AdsrPhase::Idle => {
                if self.gate_active {
                    self.phase = AdsrPhase::Attack;
                }
            }
            AdsrPhase::Attack => {
                if !self.gate_active {
                    self.phase = AdsrPhase::Release;
                } else if self.current_level >= 1.0 {
                    self.phase = AdsrPhase::Decay;
                }
            }
            AdsrPhase::Decay => {
                if !self.gate_active {
                    self.phase = AdsrPhase::Release;
                } else if self.current_level <= self.params.sustain_level {
                    self.phase = AdsrPhase::Sustain;
                }
            }
            AdsrPhase::Sustain => {
                if !self.gate_active {
                    self.phase = AdsrPhase::Release;
                }
                // In Sustain, we just hold the level (handled in calculation step)
            }
            AdsrPhase::Release => {
                if self.gate_active {
                    // Re-trigger: Standard ADSR behavior is to Attack from current level
                    self.phase = AdsrPhase::Attack;
                } else if self.current_level <= 0.0 {
                    self.phase = AdsrPhase::Idle;
                }
            }
        }

        // 2. Calculate Amplitude Changes (Linear Logic)
        match self.phase {
            AdsrPhase::Idle => {
                self.current_level = 0.0;
            }
            AdsrPhase::Attack => {
                // Rate = 1.0 / duration
                let rate = 1.0 / self.params.attack_duration.max(0.001);
                self.current_level += rate * self.delta_time;
                self.current_level = self.current_level.min(1.0);
            }
            AdsrPhase::Decay => {
                // Rate = distance to sustain / duration
                let dist = 1.0 - self.params.sustain_level;
                let rate = dist / self.params.decay_duration.max(0.001);
                self.current_level -= rate * self.delta_time;
                self.current_level = self.current_level.max(self.params.sustain_level);
            }
            AdsrPhase::Sustain => {
                // Constant level
                self.current_level = self.params.sustain_level;
            }
            AdsrPhase::Release => {
                // Rate = 1.0 / duration (standard is to release from max)
                // or proportional to current level (exponential-ish)
                // Here we use linear release from MAX for consistency
                let rate = 1.0 / self.params.release_duration.max(0.001);
                self.current_level -= rate * self.delta_time;
                self.current_level = self.current_level.max(0.0);
            }
        }

        // 3. Final Safety Clamp
        self.current_level = self.current_level.clamp(0.0, 1.0);

        self.current_level
    }

    pub(crate) fn compute_running_average(&mut self, current_sample: [f32; FREQ_BINS]) {
        // idea of the running sum:
        // the following condition shall always hold:
        // running_sum == (current_buffer_index-averaging_samples..=current_buffer_index).folding_sum(|i| buffer[i])
        if self.prev_averaging_samples != self.params.averaging_samples {
            // dynamically adapt the running sample to the new length
            // let delta = self.params.averaging_samples as i32 - self.prev_averaging_samples as i32;
            // if delta > 0 {
            //     // the new buffer is longer:
            //     // add the samples from the *new* last index until the *old* last index to the running sum
            //     // including the new last index, excluding the old last index
            //     (self.prev_averaging_samples+1..self.params.averaging_samples+1).for_each(|i|
            //         self.running_sum += &self.sample_buffer.get_from_offset(-(i as i32))
            //     );
            // } else {
            //     // the new buffer is shorter:
            //     // remove the samples from the *old* last index until the *new* last index from the running sum
            //     // including the old last index, excluding the new last index
            //     (self.params.averaging_samples..self.prev_averaging_samples).for_each(|i|
            //         self.running_sum -= &self.sample_buffer.get_from_offset(-(i as i32))
            //     );
            // }

            // the non-dynamic variant of this:
            self.running_sum = (0..=self.params.averaging_samples)
                .map(|i| self.sample_buffer.get_from_offset(i as i32))
                .fold(Array1::zeros(FREQ_BINS), |acc, x| acc + x);

            self.prev_averaging_samples = self.params.averaging_samples;
        }

        let current_rms_sample = Array1::from_vec(current_sample.to_vec());

        // remove the oldest sample from the buffer
        let remove = self
            .sample_buffer
            .get_from_offset(-(self.params.averaging_samples as i32));
        self.running_sum = &self.running_sum - &remove;
        // advance the buffer
        self.sample_buffer.progress();
        // add the new sample to the buffer
        self.sample_buffer.insert(current_rms_sample.clone());
        self.running_sum = &self.running_sum + &current_rms_sample;

        // use a small decay here to counter the accumulation of errors
        //self.running_sum = &self.running_sum * 0.98;
        self.spectrum = &self.running_sum / self.params.averaging_samples as f32;
    }

    fn highpass(&mut self, x: f32) -> f32 {
        let remove = self
            .echo_buffer
            .get_from_offset(-(self.params.echo_samples as i32));
        self.running_impulse_sum -= remove;
        // advance the buffer
        self.echo_buffer.progress();
        // add the new sample to the buffer
        self.echo_buffer.insert(x);
        self.running_impulse_sum += x;
        let y = self.running_impulse_sum / (2.0 * self.params.echo_samples as f32);
        x - y
    }
}

#[derive(Clone, Debug, PartialEq)]
struct PrimitiveRingBuffer<T>
where
    T: Clone,
{
    inner: Vec<T>,
    capacity: usize,
    front: usize,
}

impl<T> PrimitiveRingBuffer<T>
where
    T: Clone,
{
    pub(crate) fn new(default_elem: T, capacity: usize) -> Self {
        Self {
            inner: Vec::from_iter(std::iter::repeat_n(default_elem, capacity)),
            capacity,
            front: capacity.saturating_sub(1),
        }
    }
    pub(crate) fn get_from_offset(&self, offset: i32) -> T {
        self.inner[(self.front as i32 + offset).rem_euclid(self.capacity as i32) as usize].clone()
    }
    pub(crate) fn insert(&mut self, elem: T) {
        self.inner[self.front] = elem;
    }

    pub(crate) fn progress(&mut self) {
        self.front = (self.front + 1) % self.capacity;
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn run_for_with_impulse_at(run_for: usize, at: usize, impulse: f32) -> SoundTrigger {
        let mut signal = SoundTrigger::new(SoundTriggerParams::default(), 0.01);
        signal.params.averaging_samples = 1;
        for i in 0..run_for {
            let data = if i == at {
                [impulse; FREQ_BINS]
            } else {
                [0f32; FREQ_BINS]
            };
            signal.tick(data);
        }
        signal
    }

    #[test]
    fn test_running_sum_removal() {
        let signal = run_for_with_impulse_at(4, 1, 1.0);
        assert_eq!(signal.running_sum, Array1::from_vec(vec![0f32; FREQ_BINS]));
    }

    #[test]
    fn test_wraparound() {
        let mut signal = SoundTrigger::new(SoundTriggerParams::default(), 0.01);
        signal.params.averaging_samples = 1;
        for i in 0..(MAX_BUFFER_LENGTH + 10) {
            signal.tick([1f32; FREQ_BINS]);
            if i >= 2 {
                assert_eq!(signal.running_sum, Array1::from_vec(vec![2f32; FREQ_BINS]))
            }
        }
    }
}