synth_utils/
lfo.rs

1//! # Low Frequency Oscillator
2//!
3//! ## Acronyms used:
4//!
5//! - `LFO`: Low Frequency Oscillator
6//! - `LUT`: Look Up Table
7//! - `DDS`: Direct Digital Synthesis
8//!
9//! LFOs are a standard component of most analog synthesizers. They are used to
10//! modulate various parameters such as loudness, timbre, or pitch.
11//!
12//! This LFO has a variety of common waveforms available.
13//!
14//! Since this oscillator is intended as a low frequency control source, no
15//! attempts at antialiasing are made. The harmonically rich waveforms (saw, square)
16//! will alias even well below nyquist/2. Since there is no reconstruction
17//! filter built in even the sine output will alias when the frequency is high.
18//!
19//! This is not objectionable when the frequency of the LFO is much lower than
20//! audio frequencies and it is used to modulate parameters like filter cutoff
21//! or provide VCO vibrato, which is the typical use case of this module.
22//! Further, the user may wish to create crazy sci-fi effects by intentionally
23//! setting the frequency high enough to cause audible aliasing, I don't judge.
24
25use crate::{lookup_tables, phase_accumulator::PhaseAccumulator, utils::*};
26
27/// A Low Frequency Oscillator is represented here
28#[derive(Debug, Clone, Copy, PartialEq)]
29pub struct Lfo {
30    phase_accumulator: PhaseAccumulator<TOT_NUM_ACCUM_BITS, NUM_LUT_INDEX_BITS>,
31}
32
33impl Lfo {
34    /// `Lfo::new(sr)` is a new LFO with sample rate `sr`
35    pub fn new(sample_rate_hz: f32) -> Self {
36        Self {
37            phase_accumulator: PhaseAccumulator::new(sample_rate_hz),
38        }
39    }
40
41    /// `lfo.tick()` advances the LFO by 1 tick, must be called at the sample rate
42    pub fn tick(&mut self) {
43        self.phase_accumulator.tick()
44    }
45
46    /// `lfo.set_frequency(f)` sets the frequency of the LFO to `f`
47    pub fn set_frequency(&mut self, freq: f32) {
48        self.phase_accumulator.set_frequency(freq)
49    }
50
51    /// `lfo.reset()` sets the oscillator into the start position`
52    pub fn reset(&mut self) {
53        self.phase_accumulator.reset()
54    }
55
56    /// `lfo.set_phase()` sets the oscillator into a certain phase. A complete cycle (2pi radians) is represented
57    /// with the 0.0-1.0 interval. Any negative or positive input is accepted and will be normalized to the
58    /// 0.0-1.0 range internally.
59    pub fn set_phase(&mut self, phase: f32) {
60        self.phase_accumulator.set_phase(phase)
61    }
62
63    /// `lfo.get(ws)` is the current value of the given waveshape in `[-1.0, +1.0]`
64    pub fn get(&self, waveshape: Waveshape) -> f32 {
65        match waveshape {
66            Waveshape::Sine => {
67                let lut_idx = self.phase_accumulator.index();
68                let next_lut_idx = (lut_idx + 1) % (lookup_tables::SINE_LUT_SIZE - 1);
69                let y0 = lookup_tables::SINE_TABLE[lut_idx];
70                let y1 = lookup_tables::SINE_TABLE[next_lut_idx];
71                linear_interp(y0, y1, self.phase_accumulator.fraction())
72            }
73            Waveshape::Triangle => {
74                // convert the phase accum ramp into a triangle in-phase with the sine
75                let raw_ramp = self.phase_accumulator.ramp() * 4.0;
76                if raw_ramp < 1.0_f32 {
77                    // starting at zero and ramping up towards positive 1
78                    raw_ramp
79                } else if raw_ramp < 3.0_f32 {
80                    // ramping down through zero towards negative 1
81                    2.0_f32 - raw_ramp
82                } else {
83                    // ramping back up towards zero
84                    raw_ramp - 4.0_f32
85                }
86            }
87            Waveshape::UpSaw => (self.phase_accumulator.ramp() * 2.0_f32) - 1.0_f32,
88            Waveshape::DownSaw => -self.get(Waveshape::UpSaw),
89            Waveshape::Square => {
90                if self.phase_accumulator.ramp() < 0.5 {
91                    1.0
92                } else {
93                    -1.0
94                }
95            }
96        }
97    }
98}
99
100/// LFO waveshapes are represented here
101///
102/// All waveshapes are simultaneously available
103#[derive(Clone, Debug, Copy, PartialEq)]
104pub enum Waveshape {
105    Sine,
106    Triangle,
107    UpSaw,
108    DownSaw,
109    Square,
110}
111
112/// The total number of bits to use for the phase accumulator
113///
114/// Must be in `[1..32]`
115const TOT_NUM_ACCUM_BITS: u32 = 24;
116
117/// The number of index bits, depends on the lookup tables used
118///
119/// Note that the lookup table size MUST be a power of 2
120const NUM_LUT_INDEX_BITS: u32 = ilog_2(lookup_tables::SINE_LUT_SIZE);
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn sqr_starts_high_and_then_goes_low() {
128        let mut lfo = Lfo::new(1_000.0_f32);
129        lfo.set_frequency(1.0);
130
131        assert_eq!(lfo.get(Waveshape::Square), 1.0);
132
133        // tick halfway through 1 cycle
134        for _ in 0..500 {
135            lfo.tick();
136        }
137        assert_eq!(lfo.get(Waveshape::Square), 1.0);
138
139        // one mode tick makes it flop the low half of the cycle
140        lfo.tick();
141        assert_eq!(lfo.get(Waveshape::Square), -1.0);
142    }
143
144    #[test]
145    fn triangle_goes_up_then_down_then_back_up() {
146        let epsilon = 0.0001;
147
148        let mut lfo = Lfo::new(1_000.0_f32);
149        lfo.set_frequency(1.0);
150
151        assert_eq!(lfo.get(Waveshape::Triangle), 0.0);
152
153        // tick 1/4 through 1 cycle, just hit the positive peak
154        for _ in 0..250 {
155            lfo.tick();
156        }
157        assert!(is_almost(lfo.get(Waveshape::Triangle), 1.0, epsilon));
158
159        // tick to the halfway point, back to zero
160        for _ in 0..250 {
161            lfo.tick();
162        }
163        assert!(is_almost(lfo.get(Waveshape::Triangle), 0.0, epsilon));
164
165        // another quarter cycle puts us at the lowest point
166        for _ in 0..250 {
167            lfo.tick();
168        }
169        assert!(is_almost(lfo.get(Waveshape::Triangle), -1.0, epsilon));
170    }
171
172    #[test]
173    fn check_a_few_sine_points() {
174        let epsilon = 0.001;
175
176        let mut lfo = Lfo::new(10_000.0_f32);
177        lfo.set_frequency(1.0);
178
179        // tick 1/10 through 1 cycle
180        for _ in 0..1_000 {
181            lfo.tick();
182        }
183
184        assert!(is_almost(
185            lfo.get(Waveshape::Sine),
186            f32::sin(core::f32::consts::PI / 5.),
187            epsilon
188        ));
189
190        // tick to about 45 degrees, but we won't hit it exactly
191        for _ in 0..250 {
192            lfo.tick();
193        }
194        assert!(
195            (1. / 2.) < lfo.get(Waveshape::Sine) && lfo.get(Waveshape::Sine) < (f32::sqrt(3.) / 2.)
196        );
197
198        // tick a bit past 330 degrees
199        for _ in 0..7915 {
200            lfo.tick();
201        }
202        assert!((-1. / 2.) < lfo.get(Waveshape::Sine) && lfo.get(Waveshape::Sine) < 0.);
203    }
204
205    #[test]
206    fn up_saw_is_monotonic_rising() {
207        let mut lfo = Lfo::new(100.0_f32);
208        lfo.set_frequency(1.0);
209
210        let mut last_val = -1.1;
211
212        for _ in 0..100 {
213            lfo.tick();
214            assert!(last_val < lfo.get(Waveshape::UpSaw));
215            last_val = lfo.get(Waveshape::UpSaw);
216        }
217
218        // one more tick rolls it over
219        lfo.tick();
220        assert!(lfo.get(Waveshape::UpSaw) < last_val);
221    }
222
223    #[test]
224    fn down_saw_is_just_negated_up_saw() {
225        let mut lfo = Lfo::new(100.0_f32);
226        lfo.set_frequency(1.0);
227
228        for _ in 0..100 {
229            lfo.tick();
230            assert_eq!(lfo.get(Waveshape::UpSaw), -lfo.get(Waveshape::DownSaw));
231        }
232    }
233
234    #[test]
235    fn set_phase_values() {
236        let mut lfo = Lfo::new(100.0_f32);
237        lfo.set_frequency(1.0);
238
239        let epsilon = 0.001;
240
241        // zero means start position
242        lfo.set_phase(0.0);
243        assert!(is_almost(lfo.get(Waveshape::Triangle), 0.0, epsilon));
244
245        // anything .0 also means start position
246        lfo.set_phase(-2.0);
247        assert!(is_almost(lfo.get(Waveshape::Sine), 0.0, epsilon));
248
249        // doing a quarter cycle with tick()
250        for _ in 0..25 {
251            lfo.tick();
252        }
253        // gets sine to the top
254        assert!(is_almost(lfo.get(Waveshape::Triangle), 1.0, epsilon));
255        // and saw to in the middle of the negative part
256        assert!(is_almost(lfo.get(Waveshape::UpSaw), -0.5, epsilon));
257    }
258}