devalang_wasm/engine/audio/effects/processors/
lfo.rs

1use crate::engine::audio::effects::processors::super_trait::EffectProcessor;
2use crate::engine::audio::lfo::{LfoParams, LfoTarget, generate_lfo_value};
3
4#[derive(Debug, Clone)]
5pub struct LfoProcessor {
6    pub params: LfoParams,
7    pub bpm: f32,
8    sample_offset: usize,
9    // For pitch modulation (resampling)
10    read_pos: f32,
11    semitone_range: f32,
12
13    // For cutoff modulation (simple one-pole filter)
14    base_cutoff: f32,
15    cutoff_range: f32,
16    prev_l: f32,
17    prev_r: f32,
18}
19
20impl LfoProcessor {
21    pub fn new(
22        params: LfoParams,
23        bpm: f32,
24        semitone_range: f32,
25        base_cutoff: f32,
26        cutoff_range: f32,
27    ) -> Self {
28        Self {
29            params,
30            bpm,
31            sample_offset: 0,
32            read_pos: 0.0,
33            semitone_range,
34            base_cutoff,
35            cutoff_range,
36            prev_l: 0.0,
37            prev_r: 0.0,
38        }
39    }
40}
41
42impl Default for LfoProcessor {
43    fn default() -> Self {
44        Self::new(LfoParams::default(), 120.0, 2.0, 1000.0, 1000.0)
45    }
46}
47
48/// Catmull-Rom cubic interpolation for four samples
49fn cubic_catmull_rom(y0: f32, y1: f32, y2: f32, y3: f32, t: f32) -> f32 {
50    // y0 = previous sample (base-1)
51    // y1 = sample at base
52    // y2 = sample at base+1
53    // y3 = sample at base+2
54    // t in [0,1]
55    let t2 = t * t;
56    let t3 = t2 * t;
57
58    0.5 * ((2.0 * y1)
59        + (-y0 + y2) * t
60        + (2.0 * y0 - 5.0 * y1 + 4.0 * y2 - y3) * t2
61        + (-y0 + 3.0 * y1 - 3.0 * y2 + y3) * t3)
62}
63
64impl EffectProcessor for LfoProcessor {
65    fn process(&mut self, samples: &mut [f32], sample_rate: u32) {
66        let sr = sample_rate as f32;
67
68        // stereo interleaved
69        let frames = samples.len() / 2;
70
71        // Make a snapshot of the source for resampling when doing pitch modulation
72        let src = samples.to_vec();
73
74        for frame in 0..frames {
75            let idx = frame * 2;
76            let time_seconds = (self.sample_offset + frame) as f32 / sr;
77
78            let lfo_val = generate_lfo_value(&self.params, time_seconds, self.bpm); // -depth..depth
79
80            match self.params.target {
81                LfoTarget::Volume => {
82                    let mod_amp = 1.0 + lfo_val; // depth already applied
83                    samples[idx] *= mod_amp;
84                    if idx + 1 < samples.len() {
85                        samples[idx + 1] *= mod_amp;
86                    }
87                }
88                LfoTarget::Pan => {
89                    let pan = lfo_val.clamp(-1.0, 1.0);
90                    let left = (1.0 - pan) * 0.5;
91                    let right = (1.0 + pan) * 0.5;
92                    samples[idx] *= left;
93                    if idx + 1 < samples.len() {
94                        samples[idx + 1] *= right;
95                    }
96                }
97                LfoTarget::Pitch => {
98                    // semitone offset range controlled by semitone_range field
99                    let semitone = lfo_val * self.semitone_range; // e.g., ±2 semitones
100                    let speed = 2.0_f32.powf(semitone / 12.0);
101
102                    // read from src at read_pos (in frames)
103                    let pos = self.read_pos;
104                    let base = pos.floor() as usize;
105                    let frac = pos - base as f32;
106
107                    let get_sample = |frame_idx: usize, ch: usize| -> f32 {
108                        if frame_idx >= frames {
109                            return 0.0;
110                        }
111                        src.get(frame_idx * 2 + ch).copied().unwrap_or(0.0)
112                    };
113
114                    // Left
115                    // Cubic interpolation (Catmull-Rom) using four samples: y0,y1,y2,y3
116                    let y_m1 = get_sample(base.wrapping_sub(1), 0);
117                    let y0 = get_sample(base, 0);
118                    let y1 = get_sample(base + 1, 0);
119                    let y2 = get_sample(base + 2, 0);
120                    let out_l = cubic_catmull_rom(y_m1, y0, y1, y2, frac);
121
122                    // Right
123                    let y_m1r = get_sample(base.wrapping_sub(1), 1);
124                    let y0r = get_sample(base, 1);
125                    let y1r = get_sample(base + 1, 1);
126                    let y2r = get_sample(base + 2, 1);
127                    let out_r = cubic_catmull_rom(y_m1r, y0r, y1r, y2r, frac);
128
129                    samples[idx] = out_l;
130                    if idx + 1 < samples.len() {
131                        samples[idx + 1] = out_r;
132                    }
133
134                    self.read_pos += speed;
135                }
136                LfoTarget::FilterCutoff => {
137                    // compute dynamic cutoff
138                    let cutoff = (self.base_cutoff + lfo_val * self.cutoff_range).max(20.0);
139                    // one-pole smoothing coefficient (approx)
140                    let a = 1.0 - (-2.0 * std::f32::consts::PI * cutoff / sr).exp();
141
142                    let in_l = samples[idx];
143                    let in_r = if idx + 1 < samples.len() {
144                        samples[idx + 1]
145                    } else {
146                        0.0
147                    };
148
149                    let out_l = a * in_l + (1.0 - a) * self.prev_l;
150                    let out_r = a * in_r + (1.0 - a) * self.prev_r;
151
152                    samples[idx] = out_l;
153                    if idx + 1 < samples.len() {
154                        samples[idx + 1] = out_r;
155                    }
156
157                    self.prev_l = out_l;
158                    self.prev_r = out_r;
159                }
160            }
161        }
162
163        self.sample_offset = self.sample_offset.wrapping_add(frames);
164
165        // Show example output first-frame after processing
166        // Calculate max absolute difference between input snapshot and output to see if processing changed samples
167        let mut max_diff = 0.0f32;
168        let len = samples.len().min(src.len());
169        for i in 0..len {
170            let diff = (samples[i] - src[i]).abs();
171            if diff > max_diff {
172                max_diff = diff;
173            }
174        }
175    }
176
177    fn reset(&mut self) {
178        self.sample_offset = 0;
179    }
180
181    fn name(&self) -> &str {
182        "LFO"
183    }
184}