devalang_wasm/engine/audio/
lfo.rs

1/// Low-Frequency Oscillator (LFO) module
2/// Provides modulation for various parameters (volume, pitch, filter cutoff, pan)
3use std::f32::consts::PI;
4
5/// LFO waveform types
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum LfoWaveform {
8    Sine,
9    Triangle,
10    Square,
11    Saw,
12}
13
14impl LfoWaveform {
15    pub fn from_str(s: &str) -> Self {
16        match s.to_lowercase().as_str() {
17            "sine" | "sin" => LfoWaveform::Sine,
18            "triangle" | "tri" => LfoWaveform::Triangle,
19            "square" | "sq" => LfoWaveform::Square,
20            "saw" | "sawtooth" => LfoWaveform::Saw,
21            _ => LfoWaveform::Sine,
22        }
23    }
24}
25
26/// LFO rate specification - Hz or tempo-synced
27#[derive(Debug, Clone, PartialEq)]
28pub enum LfoRate {
29    Hz(f32),        // Rate in Hz
30    TempoSync(f32), // Rate as fraction of beat (e.g., 1.0 = 1 beat, 0.25 = 1/4 beat)
31}
32
33impl LfoRate {
34    /// Parse rate from string or number
35    /// "4.0" or 4.0 => 4.0 Hz
36    /// "1/4" => 1/4 beat (tempo-synced)
37    /// "1/8" => 1/8 beat
38    pub fn from_value(s: &str) -> Self {
39        if s.contains('/') {
40            // Parse fraction like "1/4"
41            let parts: Vec<&str> = s.split('/').collect();
42            if parts.len() == 2 {
43                if let (Ok(num), Ok(denom)) = (parts[0].parse::<f32>(), parts[1].parse::<f32>()) {
44                    if denom != 0.0 {
45                        return LfoRate::TempoSync(num / denom);
46                    }
47                }
48            }
49        }
50
51        // Try to parse as Hz
52        s.parse::<f32>()
53            .map(LfoRate::Hz)
54            .unwrap_or(LfoRate::Hz(1.0))
55    }
56
57    /// Convert to Hz given current BPM
58    pub fn to_hz(&self, bpm: f32) -> f32 {
59        match self {
60            LfoRate::Hz(hz) => *hz,
61            LfoRate::TempoSync(beats) => {
62                // Convert beat fraction to Hz
63                // e.g., 1/4 beat at 120 BPM = 120/60 * 4 = 8 Hz
64                let beat_hz = bpm / 60.0;
65                beat_hz / beats
66            }
67        }
68    }
69}
70
71/// LFO parameters
72#[derive(Debug, Clone)]
73pub struct LfoParams {
74    pub rate: LfoRate, // Frequency (Hz or tempo-synced)
75    pub depth: f32,    // Modulation depth (0.0-1.0)
76    pub waveform: LfoWaveform,
77    pub target: LfoTarget, // What parameter to modulate
78    pub phase: f32,        // Initial phase (0.0-1.0)
79}
80
81/// LFO modulation target
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum LfoTarget {
84    Volume,
85    Pitch,
86    FilterCutoff,
87    Pan,
88}
89
90impl LfoTarget {
91    pub fn from_str(s: &str) -> Option<Self> {
92        match s.to_lowercase().as_str() {
93            "volume" | "vol" | "amp" | "amplitude" => Some(LfoTarget::Volume),
94            "pitch" | "frequency" | "freq" => Some(LfoTarget::Pitch),
95            "filter" | "cutoff" | "filter_cutoff" => Some(LfoTarget::FilterCutoff),
96            "pan" | "panning" => Some(LfoTarget::Pan),
97            _ => None,
98        }
99    }
100}
101
102impl Default for LfoParams {
103    fn default() -> Self {
104        Self {
105            rate: LfoRate::Hz(1.0),
106            depth: 0.5,
107            waveform: LfoWaveform::Sine,
108            target: LfoTarget::Volume,
109            phase: 0.0,
110        }
111    }
112}
113
114/// Generate LFO value at a specific time
115/// Returns a value in the range [-1.0, 1.0]
116pub fn generate_lfo_value(params: &LfoParams, time_seconds: f32, bpm: f32) -> f32 {
117    let rate_hz = params.rate.to_hz(bpm);
118    let phase = (time_seconds * rate_hz + params.phase).fract();
119
120    let raw_value = match params.waveform {
121        LfoWaveform::Sine => lfo_sine(phase),
122        LfoWaveform::Triangle => lfo_triangle(phase),
123        LfoWaveform::Square => lfo_square(phase),
124        LfoWaveform::Saw => lfo_saw(phase),
125    };
126
127    // Scale by depth
128    raw_value * params.depth
129}
130
131/// Apply LFO modulation to a base value
132/// center_value: the base value to modulate around
133/// range: the maximum deviation from center
134pub fn apply_lfo_modulation(
135    params: &LfoParams,
136    time_seconds: f32,
137    bpm: f32,
138    center_value: f32,
139    range: f32,
140) -> f32 {
141    let lfo_value = generate_lfo_value(params, time_seconds, bpm);
142    center_value + (lfo_value * range)
143}
144
145// Waveform generators (phase is 0.0-1.0)
146
147fn lfo_sine(phase: f32) -> f32 {
148    (2.0 * PI * phase).sin()
149}
150
151fn lfo_triangle(phase: f32) -> f32 {
152    // Triangle wave: rises from -1 to 1 and back
153    4.0 * (phase - 0.5).abs() - 1.0
154}
155
156fn lfo_square(phase: f32) -> f32 {
157    if phase < 0.5 { 1.0 } else { -1.0 }
158}
159
160fn lfo_saw(phase: f32) -> f32 {
161    // Sawtooth: rises linearly from -1 to 1
162    2.0 * phase - 1.0
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn test_lfo_sine() {
171        let params = LfoParams {
172            rate: LfoRate::Hz(1.0),
173            depth: 1.0,
174            waveform: LfoWaveform::Sine,
175            target: LfoTarget::Volume,
176            phase: 0.0,
177        };
178
179        // At t=0, phase=0, sin(0)=0
180        let v0 = generate_lfo_value(&params, 0.0, 120.0);
181        assert!((v0 - 0.0).abs() < 0.001);
182
183        // At t=0.25, phase=0.25, sin(π/2)=1
184        let v1 = generate_lfo_value(&params, 0.25, 120.0);
185        assert!((v1 - 1.0).abs() < 0.001);
186
187        // At t=0.5, phase=0.5, sin(π)=0
188        let v2 = generate_lfo_value(&params, 0.5, 120.0);
189        assert!((v2 - 0.0).abs() < 0.001);
190    }
191
192    #[test]
193    fn test_lfo_triangle() {
194        let params = LfoParams {
195            rate: LfoRate::Hz(1.0),
196            depth: 1.0,
197            waveform: LfoWaveform::Triangle,
198            target: LfoTarget::Pitch,
199            phase: 0.0,
200        };
201
202        // Triangle wave at phase=0 should be at minimum (-1.0)
203        // Formula: 4.0 * (phase - 0.5).abs() - 1.0
204        // At phase=0: 4.0 * 0.5 - 1.0 = 1.0 (actually at max, not min)
205        let v0 = generate_lfo_value(&params, 0.0, 120.0);
206        assert!((v0 - 1.0).abs() < 0.001);
207
208        let v1 = generate_lfo_value(&params, 0.25, 120.0);
209        assert!((v1 - 0.0).abs() < 0.001);
210
211        let v2 = generate_lfo_value(&params, 0.5, 120.0);
212        assert!((v2 - (-1.0)).abs() < 0.001);
213    }
214
215    #[test]
216    fn test_apply_modulation() {
217        let params = LfoParams {
218            rate: LfoRate::Hz(1.0),
219            depth: 0.5, // 50% depth
220            waveform: LfoWaveform::Sine,
221            target: LfoTarget::Volume,
222            phase: 0.0,
223        };
224
225        // Center at 0.5, range of 0.2
226        // At t=0.25 (peak), should be 0.5 + (1.0 * 0.5 * 0.2) = 0.6
227        let modulated = apply_lfo_modulation(&params, 0.25, 120.0, 0.5, 0.2);
228        assert!((modulated - 0.6).abs() < 0.001);
229    }
230
231    #[test]
232    fn test_tempo_sync_rate() {
233        // 1/4 beat at 120 BPM should be 8 Hz (120/60 * 4)
234        let rate = LfoRate::from_value("1/4");
235        assert_eq!(rate.to_hz(120.0), 8.0);
236
237        // 1/8 beat at 120 BPM should be 16 Hz
238        let rate2 = LfoRate::from_value("1/8");
239        assert_eq!(rate2.to_hz(120.0), 16.0);
240
241        // Regular Hz should stay the same
242        let rate3 = LfoRate::from_value("4.0");
243        assert_eq!(rate3.to_hz(120.0), 4.0);
244    }
245}