bbx_dsp/blocks/modulators/
lfo.rs

1//! Low-frequency oscillator (LFO) block for parameter modulation.
2
3use bbx_core::random::XorShiftRng;
4
5#[cfg(feature = "simd")]
6use crate::sample::SIMD_LANES;
7#[cfg(feature = "simd")]
8use crate::waveform::generate_waveform_samples_simd;
9use crate::{
10    block::{Block, DEFAULT_MODULATOR_INPUT_COUNT, DEFAULT_MODULATOR_OUTPUT_COUNT},
11    context::DspContext,
12    parameter::{ModulationOutput, Parameter},
13    sample::Sample,
14    waveform::{Waveform, process_waveform_scalar},
15};
16
17/// A low-frequency oscillator for modulating block parameters.
18///
19/// Generates control signals (typically < 20 Hz) using standard waveforms.
20/// Output range is -depth to +depth, centered at zero.
21pub struct LfoBlock<S: Sample> {
22    /// LFO frequency in Hz (typically 0.01-20 Hz).
23    pub frequency: Parameter<S>,
24
25    /// Modulation depth (output amplitude).
26    pub depth: Parameter<S>,
27
28    phase: f64,
29    waveform: Waveform,
30    rng: XorShiftRng,
31}
32
33impl<S: Sample> LfoBlock<S> {
34    const MODULATION_OUTPUTS: &'static [ModulationOutput] = &[ModulationOutput {
35        name: "LFO",
36        min_value: -1.0,
37        max_value: 1.0,
38    }];
39
40    /// Create an `LfoBlock` with a given frequency, depth, waveform, and optional seed (used for noise waveforms).
41    pub fn new(frequency: f64, depth: f64, waveform: Waveform, seed: Option<u64>) -> Self {
42        Self {
43            frequency: Parameter::Constant(S::from_f64(frequency)),
44            depth: Parameter::Constant(S::from_f64(depth)),
45            phase: 0.0,
46            waveform,
47            rng: XorShiftRng::new(seed.unwrap_or_default()),
48        }
49    }
50}
51
52impl<S: Sample> Block<S> for LfoBlock<S> {
53    fn process(&mut self, _inputs: &[&[S]], outputs: &mut [&mut [S]], modulation_values: &[S], context: &DspContext) {
54        let frequency = self.frequency.get_value(modulation_values);
55        let depth = self.depth.get_value(modulation_values).to_f64();
56        let phase_increment = frequency.to_f64() / context.sample_rate * S::TAU.to_f64();
57
58        #[cfg(feature = "simd")]
59        {
60            use crate::waveform::DEFAULT_DUTY_CYCLE;
61
62            if !matches!(self.waveform, Waveform::Noise) {
63                let buffer_size = context.buffer_size;
64                let chunks = buffer_size / SIMD_LANES;
65                let remainder_start = chunks * SIMD_LANES;
66                let chunk_phase_step = phase_increment * SIMD_LANES as f64;
67                let depth_s = S::from_f64(depth);
68                let depth_vec = S::simd_splat(depth_s);
69
70                let base_phase = S::simd_splat(S::from_f64(self.phase));
71                let sample_inc_simd = S::simd_splat(S::from_f64(phase_increment));
72                let mut phases = base_phase + S::simd_lane_offsets() * sample_inc_simd;
73                let chunk_inc_simd = S::simd_splat(S::from_f64(chunk_phase_step));
74                let duty = S::from_f64(DEFAULT_DUTY_CYCLE);
75                let two_pi = S::simd_splat(S::TAU);
76                let inv_two_pi = S::simd_splat(S::INV_TAU);
77                let phase_inc_normalized = S::from_f64(phase_increment * S::INV_TAU.to_f64());
78                let tau = S::TAU.to_f64();
79                let inv_tau = 1.0 / tau;
80
81                for chunk_idx in 0..chunks {
82                    let phases_array = S::simd_to_array(phases);
83                    let phases_normalized: [S; SIMD_LANES] = [
84                        S::from_f64(phases_array[0].to_f64().rem_euclid(tau) * inv_tau),
85                        S::from_f64(phases_array[1].to_f64().rem_euclid(tau) * inv_tau),
86                        S::from_f64(phases_array[2].to_f64().rem_euclid(tau) * inv_tau),
87                        S::from_f64(phases_array[3].to_f64().rem_euclid(tau) * inv_tau),
88                    ];
89
90                    if let Some(samples) = generate_waveform_samples_simd::<S>(
91                        self.waveform,
92                        phases,
93                        phases_normalized,
94                        phase_inc_normalized,
95                        duty,
96                        two_pi,
97                        inv_two_pi,
98                    ) {
99                        let samples_vec = S::simd_from_slice(&samples);
100                        let scaled = samples_vec * depth_vec;
101                        let base = chunk_idx * SIMD_LANES;
102                        outputs[0][base..base + SIMD_LANES].copy_from_slice(&S::simd_to_array(scaled));
103                    }
104
105                    phases = phases + chunk_inc_simd;
106                }
107
108                self.phase += chunk_phase_step * chunks as f64;
109                self.phase = self.phase.rem_euclid(S::TAU.to_f64());
110
111                process_waveform_scalar(
112                    &mut outputs[0][remainder_start..],
113                    self.waveform,
114                    &mut self.phase,
115                    phase_increment,
116                    &mut self.rng,
117                    depth,
118                );
119            } else {
120                process_waveform_scalar(
121                    outputs[0],
122                    self.waveform,
123                    &mut self.phase,
124                    phase_increment,
125                    &mut self.rng,
126                    depth,
127                );
128            }
129        }
130
131        #[cfg(not(feature = "simd"))]
132        {
133            process_waveform_scalar(
134                outputs[0],
135                self.waveform,
136                &mut self.phase,
137                phase_increment,
138                &mut self.rng,
139                depth,
140            );
141        }
142    }
143
144    #[inline]
145    fn input_count(&self) -> usize {
146        DEFAULT_MODULATOR_INPUT_COUNT
147    }
148
149    #[inline]
150    fn output_count(&self) -> usize {
151        DEFAULT_MODULATOR_OUTPUT_COUNT
152    }
153
154    #[inline]
155    fn modulation_outputs(&self) -> &[ModulationOutput] {
156        Self::MODULATION_OUTPUTS
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use crate::channel::ChannelLayout;
164
165    fn test_context(buffer_size: usize, sample_rate: f64) -> DspContext {
166        DspContext {
167            sample_rate,
168            num_channels: 1,
169            buffer_size,
170            current_sample: 0,
171            channel_layout: ChannelLayout::Mono,
172        }
173    }
174
175    fn process_lfo<S: Sample>(lfo: &mut LfoBlock<S>, context: &DspContext) -> Vec<S> {
176        let inputs: [&[S]; 0] = [];
177        let mut output = vec![S::ZERO; context.buffer_size];
178        let mut outputs: [&mut [S]; 1] = [&mut output];
179        lfo.process(&inputs, &mut outputs, &[], context);
180        output
181    }
182
183    #[test]
184    fn test_lfo_input_output_counts_f32() {
185        let lfo = LfoBlock::<f32>::new(1.0, 1.0, Waveform::Sine, None);
186        assert_eq!(lfo.input_count(), DEFAULT_MODULATOR_INPUT_COUNT);
187        assert_eq!(lfo.output_count(), DEFAULT_MODULATOR_OUTPUT_COUNT);
188    }
189
190    #[test]
191    fn test_lfo_input_output_counts_f64() {
192        let lfo = LfoBlock::<f64>::new(1.0, 1.0, Waveform::Sine, None);
193        assert_eq!(lfo.input_count(), DEFAULT_MODULATOR_INPUT_COUNT);
194        assert_eq!(lfo.output_count(), DEFAULT_MODULATOR_OUTPUT_COUNT);
195    }
196
197    #[test]
198    fn test_lfo_modulation_output_f32() {
199        let lfo = LfoBlock::<f32>::new(1.0, 1.0, Waveform::Sine, None);
200        let outputs = lfo.modulation_outputs();
201        assert_eq!(outputs.len(), 1);
202        assert_eq!(outputs[0].name, "LFO");
203        assert!((outputs[0].min_value - (-1.0)).abs() < 1e-10);
204        assert!((outputs[0].max_value - 1.0).abs() < 1e-10);
205    }
206
207    #[test]
208    fn test_lfo_output_range_unity_depth_f32() {
209        let mut lfo = LfoBlock::<f32>::new(1.0, 1.0, Waveform::Sine, Some(42));
210        let context = test_context(512, 44100.0);
211
212        for _ in 0..10 {
213            let output = process_lfo(&mut lfo, &context);
214            for &sample in &output {
215                assert!(
216                    sample >= -1.1 && sample <= 1.1,
217                    "LFO with depth=1 should be in [-1, 1]: {}",
218                    sample
219                );
220            }
221        }
222    }
223
224    #[test]
225    fn test_lfo_output_range_unity_depth_f64() {
226        let mut lfo = LfoBlock::<f64>::new(1.0, 1.0, Waveform::Sine, Some(42));
227        let context = test_context(512, 44100.0);
228
229        for _ in 0..10 {
230            let output = process_lfo(&mut lfo, &context);
231            for &sample in &output {
232                assert!(
233                    sample >= -1.1 && sample <= 1.1,
234                    "LFO with depth=1 should be in [-1, 1]: {}",
235                    sample
236                );
237            }
238        }
239    }
240
241    #[test]
242    fn test_lfo_depth_scaling_f32() {
243        let depth = 0.5;
244        let mut lfo = LfoBlock::<f32>::new(1.0, depth, Waveform::Sine, Some(42));
245        let context = test_context(512, 44100.0);
246
247        for _ in 0..10 {
248            let output = process_lfo(&mut lfo, &context);
249            for &sample in &output {
250                assert!(
251                    sample >= -depth as f32 * 1.1 && sample <= depth as f32 * 1.1,
252                    "LFO with depth={} should be in [{}, {}]: {}",
253                    depth,
254                    -depth,
255                    depth,
256                    sample
257                );
258            }
259        }
260    }
261
262    #[test]
263    fn test_lfo_depth_scaling_f64() {
264        let depth = 0.5;
265        let mut lfo = LfoBlock::<f64>::new(1.0, depth, Waveform::Sine, Some(42));
266        let context = test_context(512, 44100.0);
267
268        for _ in 0..10 {
269            let output = process_lfo(&mut lfo, &context);
270            for &sample in &output {
271                assert!(
272                    sample >= -depth * 1.1 && sample <= depth * 1.1,
273                    "LFO with depth={} should be in [{}, {}]: {}",
274                    depth,
275                    -depth,
276                    depth,
277                    sample
278                );
279            }
280        }
281    }
282
283    #[test]
284    fn test_lfo_sine_produces_variation_f32() {
285        let mut lfo = LfoBlock::<f32>::new(5.0, 1.0, Waveform::Sine, Some(42));
286        let context = test_context(512, 44100.0);
287
288        let output = process_lfo(&mut lfo, &context);
289
290        let min = output.iter().fold(f32::MAX, |acc, &x| acc.min(x));
291        let max = output.iter().fold(f32::MIN, |acc, &x| acc.max(x));
292
293        assert!(
294            max - min > 0.1,
295            "LFO should produce variation: min={}, max={}",
296            min,
297            max
298        );
299    }
300
301    #[test]
302    fn test_lfo_sine_produces_variation_f64() {
303        let mut lfo = LfoBlock::<f64>::new(5.0, 1.0, Waveform::Sine, Some(42));
304        let context = test_context(512, 44100.0);
305
306        let output = process_lfo(&mut lfo, &context);
307
308        let min = output.iter().fold(f64::MAX, |acc, &x| acc.min(x));
309        let max = output.iter().fold(f64::MIN, |acc, &x| acc.max(x));
310
311        assert!(
312            max - min > 0.1,
313            "LFO should produce variation: min={}, max={}",
314            min,
315            max
316        );
317    }
318
319    #[test]
320    fn test_lfo_low_frequency_f32() {
321        let mut lfo = LfoBlock::<f32>::new(0.1, 1.0, Waveform::Sine, Some(42));
322        let context = test_context(4096, 44100.0);
323
324        let mut all_samples = Vec::new();
325        for _ in 0..50 {
326            let output = process_lfo(&mut lfo, &context);
327            all_samples.extend(output);
328        }
329
330        let has_positive = all_samples.iter().any(|&x| x > 0.1);
331        let has_negative = all_samples.iter().any(|&x| x < -0.1);
332
333        assert!(
334            has_positive || has_negative,
335            "Very low frequency LFO should still produce signal"
336        );
337    }
338
339    #[test]
340    fn test_lfo_high_frequency_f32() {
341        let mut lfo = LfoBlock::<f32>::new(20.0, 1.0, Waveform::Sine, Some(42));
342        let context = test_context(512, 44100.0);
343
344        let output = process_lfo(&mut lfo, &context);
345
346        let min = output.iter().fold(f32::MAX, |acc, &x| acc.min(x));
347        let max = output.iter().fold(f32::MIN, |acc, &x| acc.max(x));
348
349        assert!(max - min > 0.5, "High frequency LFO should oscillate within buffer");
350    }
351
352    #[test]
353    fn test_lfo_square_output_range_f32() {
354        let mut lfo = LfoBlock::<f32>::new(2.0, 1.0, Waveform::Square, Some(42));
355        let context = test_context(512, 44100.0);
356
357        for _ in 0..10 {
358            let output = process_lfo(&mut lfo, &context);
359            for &sample in &output {
360                assert!(
361                    sample >= -1.1 && sample <= 1.1,
362                    "Square LFO should be in [-1, 1]: {}",
363                    sample
364                );
365            }
366        }
367    }
368
369    #[test]
370    fn test_lfo_square_output_range_f64() {
371        let mut lfo = LfoBlock::<f64>::new(2.0, 1.0, Waveform::Square, Some(42));
372        let context = test_context(512, 44100.0);
373
374        for _ in 0..10 {
375            let output = process_lfo(&mut lfo, &context);
376            for &sample in &output {
377                assert!(
378                    sample >= -1.1 && sample <= 1.1,
379                    "Square LFO should be in [-1, 1]: {}",
380                    sample
381                );
382            }
383        }
384    }
385
386    #[test]
387    fn test_lfo_sawtooth_output_range_f32() {
388        let mut lfo = LfoBlock::<f32>::new(2.0, 1.0, Waveform::Sawtooth, Some(42));
389        let context = test_context(512, 44100.0);
390
391        for _ in 0..10 {
392            let output = process_lfo(&mut lfo, &context);
393            for &sample in &output {
394                assert!(
395                    sample >= -1.1 && sample <= 1.1,
396                    "Sawtooth LFO should be in [-1, 1]: {}",
397                    sample
398                );
399            }
400        }
401    }
402
403    #[test]
404    fn test_lfo_sawtooth_output_range_f64() {
405        let mut lfo = LfoBlock::<f64>::new(2.0, 1.0, Waveform::Sawtooth, Some(42));
406        let context = test_context(512, 44100.0);
407
408        for _ in 0..10 {
409            let output = process_lfo(&mut lfo, &context);
410            for &sample in &output {
411                assert!(
412                    sample >= -1.1 && sample <= 1.1,
413                    "Sawtooth LFO should be in [-1, 1]: {}",
414                    sample
415                );
416            }
417        }
418    }
419
420    #[test]
421    fn test_lfo_triangle_output_range_f32() {
422        let mut lfo = LfoBlock::<f32>::new(2.0, 1.0, Waveform::Triangle, Some(42));
423        let context = test_context(512, 44100.0);
424
425        for _ in 0..10 {
426            let output = process_lfo(&mut lfo, &context);
427            for &sample in &output {
428                assert!(
429                    sample >= -1.1 && sample <= 1.1,
430                    "Triangle LFO should be in [-1, 1]: {}",
431                    sample
432                );
433            }
434        }
435    }
436
437    #[test]
438    fn test_lfo_triangle_output_range_f64() {
439        let mut lfo = LfoBlock::<f64>::new(2.0, 1.0, Waveform::Triangle, Some(42));
440        let context = test_context(512, 44100.0);
441
442        for _ in 0..10 {
443            let output = process_lfo(&mut lfo, &context);
444            for &sample in &output {
445                assert!(
446                    sample >= -1.1 && sample <= 1.1,
447                    "Triangle LFO should be in [-1, 1]: {}",
448                    sample
449                );
450            }
451        }
452    }
453
454    #[test]
455    fn test_lfo_noise_output_range_f32() {
456        let mut lfo = LfoBlock::<f32>::new(1.0, 1.0, Waveform::Noise, Some(42));
457        let context = test_context(512, 44100.0);
458
459        for _ in 0..10 {
460            let output = process_lfo(&mut lfo, &context);
461            for &sample in &output {
462                assert!(
463                    sample >= -1.0 && sample <= 1.0,
464                    "Noise LFO should be in [-1, 1]: {}",
465                    sample
466                );
467            }
468        }
469    }
470
471    #[test]
472    fn test_lfo_noise_output_range_f64() {
473        let mut lfo = LfoBlock::<f64>::new(1.0, 1.0, Waveform::Noise, Some(42));
474        let context = test_context(512, 44100.0);
475
476        for _ in 0..10 {
477            let output = process_lfo(&mut lfo, &context);
478            for &sample in &output {
479                assert!(
480                    sample >= -1.0 && sample <= 1.0,
481                    "Noise LFO should be in [-1, 1]: {}",
482                    sample
483                );
484            }
485        }
486    }
487
488    #[test]
489    fn test_lfo_deterministic_with_seed_f32() {
490        let output1 = {
491            let mut lfo = LfoBlock::<f32>::new(5.0, 1.0, Waveform::Sine, Some(42));
492            let context = test_context(256, 44100.0);
493            process_lfo(&mut lfo, &context)
494        };
495
496        let output2 = {
497            let mut lfo = LfoBlock::<f32>::new(5.0, 1.0, Waveform::Sine, Some(42));
498            let context = test_context(256, 44100.0);
499            process_lfo(&mut lfo, &context)
500        };
501
502        for (a, b) in output1.iter().zip(output2.iter()) {
503            assert!((a - b).abs() < 1e-6, "Same seed should produce identical output");
504        }
505    }
506
507    #[test]
508    fn test_lfo_deterministic_with_seed_f64() {
509        let output1 = {
510            let mut lfo = LfoBlock::<f64>::new(5.0, 1.0, Waveform::Sine, Some(42));
511            let context = test_context(256, 44100.0);
512            process_lfo(&mut lfo, &context)
513        };
514
515        let output2 = {
516            let mut lfo = LfoBlock::<f64>::new(5.0, 1.0, Waveform::Sine, Some(42));
517            let context = test_context(256, 44100.0);
518            process_lfo(&mut lfo, &context)
519        };
520
521        for (a, b) in output1.iter().zip(output2.iter()) {
522            assert!((a - b).abs() < 1e-12, "Same seed should produce identical output");
523        }
524    }
525
526    #[test]
527    fn test_lfo_zero_depth_f32() {
528        let mut lfo = LfoBlock::<f32>::new(5.0, 0.0, Waveform::Sine, Some(42));
529        let context = test_context(512, 44100.0);
530
531        let output = process_lfo(&mut lfo, &context);
532
533        for &sample in &output {
534            assert!(sample.abs() < 1e-6, "Zero depth LFO should produce zero: {}", sample);
535        }
536    }
537
538    #[test]
539    fn test_lfo_zero_depth_f64() {
540        let mut lfo = LfoBlock::<f64>::new(5.0, 0.0, Waveform::Sine, Some(42));
541        let context = test_context(512, 44100.0);
542
543        let output = process_lfo(&mut lfo, &context);
544
545        for &sample in &output {
546            assert!(sample.abs() < 1e-12, "Zero depth LFO should produce zero: {}", sample);
547        }
548    }
549
550    #[test]
551    fn test_lfo_phase_continuity_f32() {
552        let mut lfo = LfoBlock::<f32>::new(5.0, 1.0, Waveform::Sine, Some(42));
553        let context = test_context(256, 44100.0);
554
555        let output1 = process_lfo(&mut lfo, &context);
556        let output2 = process_lfo(&mut lfo, &context);
557
558        let last = output1[255];
559        let first = output2[0];
560        let diff = (last - first).abs();
561
562        let samples_per_cycle = 44100.0 / 5.0;
563        let expected_diff_per_sample = 2.0 / samples_per_cycle;
564
565        assert!(
566            diff < expected_diff_per_sample * 10.0,
567            "Phase discontinuity detected: last={}, first={}, diff={}",
568            last,
569            first,
570            diff
571        );
572    }
573}