math-sonify 1.4.0

Real-time procedural audio from mathematical dynamical systems (Lorenz, Rossler, Double Pendulum, and more)
use super::{quantize_to_scale, AudioParams, Scale, SonifMode, Sonification};
use crate::config::SonificationConfig;

/// Granular mode: trajectory speed → grain density; position → grain pitch.
pub struct GranularMapping {
    min_state: Vec<f64>,
    max_state: Vec<f64>,
    /// EMA decay for min/max tracking (0.002 ≈ 500-sample time constant at 120 Hz).
    /// Prevents normalization from becoming permanently stuck at old extremes when
    /// the attractor moves to a different region of phase space.
    alpha: f64,
}

impl GranularMapping {
    /// Creates a new `GranularMapping` with an empty min/max normalization window.
    ///
    /// The window is populated lazily on the first call to [`Sonification::map`].
    pub fn new() -> Self {
        Self {
            min_state: Vec::new(),
            max_state: Vec::new(),
            alpha: 0.002,
        }
    }
}

impl Default for GranularMapping {
    fn default() -> Self {
        Self::new()
    }
}

impl Sonification for GranularMapping {
    fn map(&mut self, state: &[f64], speed: f64, config: &SonificationConfig) -> AudioParams {
        // Initialize on first call or dimension change
        if self.min_state.len() != state.len() {
            self.min_state = state.to_vec();
            self.max_state = state.to_vec();
        }
        // EMA min/max tracking: hard-expand on new extremes, then slowly relax
        // the bounds back toward the current value.  This prevents the normalizer
        // from getting permanently anchored to a single outlier point while still
        // covering the full attractor range during initial warmup.
        for (i, &v) in state.iter().enumerate() {
            if v < self.min_state[i] {
                self.min_state[i] = v; // instant expansion on new minimum
            } else {
                self.min_state[i] += self.alpha * (v - self.min_state[i]); // slow relaxation
            }
            if v > self.max_state[i] {
                self.max_state[i] = v; // instant expansion on new maximum
            } else {
                self.max_state[i] += self.alpha * (v - self.max_state[i]); // slow relaxation
            }
        }

        let scale: Scale = config.scale.clone().into();
        let base = config.base_frequency as f32;
        let oct = config.octave_range as f32;

        // Normalize first dimension to get base pitch
        let t = if state.is_empty() {
            0.5
        } else {
            let range = (self.max_state[0] - self.min_state[0]).abs().max(1e-9);
            ((state[0] - self.min_state[0]) / range) as f32
        };

        // Grain density: proportional to speed, clamped to 5-200 grains/sec
        let grain_rate = (speed.abs() as f32 * 2.0).clamp(5.0, 200.0);

        // Frequency spread from second dimension
        let spread = if state.len() > 1 {
            let range = (self.max_state[1] - self.min_state[1]).abs().max(1e-9);
            ((state[1] - self.min_state[1]) / range) as f32
        } else {
            0.5
        };

        let chaos_level = (speed.abs() as f32 / 200.0).clamp(0.0, 1.0);

        let mut p = AudioParams {
            mode: SonifMode::Granular,
            grain_spawn_rate: grain_rate,
            grain_base_freq: quantize_to_scale(t, base, oct, scale),
            grain_freq_spread: spread * 2.0,
            gain: 0.4,
            filter_cutoff: 4000.0,
            filter_q: 0.4 + chaos_level * 2.5,
            ..Default::default()
        };

        // Higher-dimension voice distribution: each state dimension drives its
        // own voice frequency, enabling systems with many dimensions (Lorenz96,
        // Kuramoto) to use all available state variables musically.
        for i in 0..4.min(state.len()) {
            let norm_i = {
                let range = (self.max_state[i] - self.min_state[i]).abs().max(1e-9);
                ((state[i] - self.min_state[i]) / range) as f32
            };
            p.freqs[i] = quantize_to_scale(norm_i, base, oct, scale);
            p.amps[i] = 0.5 + 0.5 * norm_i;
            p.pans[i] = norm_i * 2.0 - 1.0;
        }

        p.chaos_level = chaos_level;
        p
    }
}

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

    fn default_config() -> SonificationConfig {
        SonificationConfig::default()
    }

    #[test]
    fn test_granular_output_finite() {
        let mut m = GranularMapping::new();
        let p = m.map(&[1.0, 2.0, 3.0], 10.0, &default_config());
        assert!(p.grain_base_freq.is_finite());
        assert!(p.grain_spawn_rate.is_finite());
        assert_eq!(p.mode, SonifMode::Granular);
    }

    #[test]
    fn test_granular_empty_state() {
        let mut m = GranularMapping::new();
        let p = m.map(&[], 0.0, &default_config());
        assert!(p.grain_base_freq.is_finite());
    }

    #[test]
    fn test_granular_spawn_rate_clamp() {
        let mut m = GranularMapping::new();
        let p = m.map(&[1.0], 0.0, &default_config());
        assert!(p.grain_spawn_rate >= 5.0 && p.grain_spawn_rate <= 200.0,
            "spawn_rate {} out of [5,200]", p.grain_spawn_rate);
    }

    #[test]
    fn test_granular_normalizer_adapts() {
        let mut m = GranularMapping::new();
        // Call many times — normalization window should expand to cover range
        let freqs: Vec<f32> = (-10..=10).map(|i| {
            let state = vec![i as f64, 0.0];
            m.map(&state, 5.0, &default_config()).grain_base_freq
        }).collect();
        // All frequencies should be finite
        assert!(freqs.iter().all(|f| f.is_finite()));
    }

    #[test]
    fn test_granular_high_speed_high_rate() {
        // Very high speed should push spawn_rate to the maximum (200)
        let mut m = GranularMapping::new();
        let p = m.map(&[1.0, 2.0], 200.0, &default_config());
        assert_eq!(p.grain_spawn_rate, 200.0,
            "speed=200 should max out spawn_rate at 200: {}", p.grain_spawn_rate);
    }

    #[test]
    fn test_granular_pans_spread() {
        // 4-dimensional state should produce varying pan values across voices
        let mut m = GranularMapping::new();
        // Warm up normalization so the range is established
        for i in -5..=5 {
            let v = i as f64;
            m.map(&[v, v * 2.0, v * 0.5, -v], 10.0, &default_config());
        }
        let p = m.map(&[3.0, -3.0, 1.5, -1.5], 10.0, &default_config());
        // With different normalized positions, pans should not all be identical
        let all_same = p.pans[0..4].windows(2).all(|w| (w[0] - w[1]).abs() < 0.01);
        assert!(!all_same, "voices should have different pan positions: {:?}", &p.pans[0..4]);
    }

    #[test]
    fn test_granular_chaos_level_clamped() {
        // chaos_level is speed/200 clamped to [0,1]
        let mut m_zero = GranularMapping::new();
        let mut m_max = GranularMapping::new();
        let p_zero = m_zero.map(&[1.0], 0.0, &default_config());
        let p_max = m_max.map(&[1.0], 10000.0, &default_config());
        assert_eq!(p_zero.chaos_level, 0.0,
            "zero speed should give chaos=0: {}", p_zero.chaos_level);
        assert_eq!(p_max.chaos_level, 1.0,
            "huge speed should clamp chaos=1: {}", p_max.chaos_level);
    }
}