Skip to main content

rill_patchbay/automaton/
lfo.rs

1//! LFO (Low Frequency Oscillator) automata for periodic modulation.
2//!
3//! Supports various waveform shapes and synchronisation modes.
4
5use crate::control::{Automaton, Range, Time};
6use std::f64::consts::PI;
7
8/// LFO waveform shape.
9#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
10#[derive(Debug, Clone, Copy, PartialEq)]
11pub enum LfoWaveform {
12    /// Smooth sinusoidal wave.
13    Sine,
14    /// Triangular wave.
15    Triangle,
16    /// Rising sawtooth wave.
17    Saw,
18    /// Falling sawtooth wave.
19    ReverseSaw,
20    /// Square wave.
21    Square,
22    /// Pulse wave with configurable duty cycle.
23    Pulse(f64),
24    /// Random value held for the duration of one period.
25    SampleAndHold,
26    /// Smooth random walk (continuous noise).
27    RandomWalk,
28}
29
30impl LfoWaveform {
31    /// Return the human-readable name of this waveform.
32    pub fn name(&self) -> &'static str {
33        match self {
34            LfoWaveform::Sine => "Sine",
35            LfoWaveform::Triangle => "Triangle",
36            LfoWaveform::Saw => "Saw",
37            LfoWaveform::ReverseSaw => "Reverse Saw",
38            LfoWaveform::Square => "Square",
39            LfoWaveform::Pulse(_) => "Pulse",
40            LfoWaveform::SampleAndHold => "S&H",
41            LfoWaveform::RandomWalk => "Random Walk",
42        }
43    }
44
45    /// Evaluate the waveform at a given phase (0.0 – 1.0).
46    ///
47    /// `pulse_width` overrides the built-in width for `Pulse` waveforms.
48    pub fn evaluate(&self, phase: f64, pulse_width: Option<f64>) -> f64 {
49        match self {
50            LfoWaveform::Sine => (phase * 2.0 * PI).sin(),
51
52            LfoWaveform::Triangle => {
53                if phase < 0.25 {
54                    4.0 * phase
55                } else if phase < 0.75 {
56                    2.0 - 4.0 * phase
57                } else {
58                    4.0 * phase - 4.0
59                }
60            }
61
62            LfoWaveform::Saw => 2.0 * phase - 1.0,
63
64            LfoWaveform::ReverseSaw => 1.0 - 2.0 * phase,
65
66            LfoWaveform::Square => {
67                if phase < 0.5 {
68                    1.0
69                } else {
70                    -1.0
71                }
72            }
73
74            LfoWaveform::Pulse(width) => {
75                let w = pulse_width.unwrap_or(*width);
76                if phase < w {
77                    1.0
78                } else {
79                    -1.0
80                }
81            }
82
83            LfoWaveform::SampleAndHold => {
84                phase
85            }
86
87            LfoWaveform::RandomWalk => {
88                phase
89            }
90        }
91    }
92}
93
94/// Runtime state of an LFO automaton.
95///
96/// Tracks the current phase, output value, random state, and timing.
97#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
98#[derive(Debug, Clone)]
99pub struct LfoState {
100    /// Current phase in radians.
101    pub phase: f64,
102    /// Current output value.
103    pub value: f64,
104    /// Samples remaining in hold phase (for sample-and-hold / stepped waveforms).
105    pub hold_counter: usize,
106    /// Internal RNG state for randomised waveforms.
107    pub rng_state: u64,
108    /// Timestamp of the last update (seconds).
109    pub last_time: f64,
110}
111
112/// An LFO automaton that generates periodic modulation signals.
113///
114/// Supports multiple waveform shapes, configurable frequency, amplitude,
115/// offset, pulse width, and random-walk rate.
116#[derive(Debug, Clone)]
117pub struct LfoAutomaton {
118    name: String,
119    frequency: f64,
120    amplitude: f64,
121    offset: f64,
122    waveform: LfoWaveform,
123    range: Range,
124    pulse_width: f64,
125    walk_rate: f64,
126}
127
128impl LfoAutomaton {
129    /// Create a new LFO automaton.
130    pub fn new(
131        name: &str,
132        frequency: f64,
133        amplitude: f64,
134        offset: f64,
135        waveform: LfoWaveform,
136    ) -> Self {
137        Self {
138            name: name.to_string(),
139            frequency: frequency.max(0.001),
140            amplitude,
141            offset,
142            waveform,
143            range: Range::bipolar(),
144            pulse_width: 0.5,
145            walk_rate: 0.1,
146        }
147    }
148
149    /// Set the output range.
150    pub fn with_range(mut self, range: Range) -> Self {
151        self.range = range;
152        self
153    }
154
155    /// Set the pulse width for the `Pulse` waveform (0.01 – 0.99).
156    pub fn with_pulse_width(mut self, width: f64) -> Self {
157        self.pulse_width = width.clamp(0.01, 0.99);
158        self
159    }
160
161    /// Set the random-walk step rate.
162    pub fn with_walk_rate(mut self, rate: f64) -> Self {
163        self.walk_rate = rate.max(0.0);
164        self
165    }
166
167    /// Simple xorshift PRNG returning a value in [-1, 1].
168    fn random(&self, state: &mut u64) -> f64 {
169        let mut x = *state;
170        x ^= x << 13;
171        x ^= x >> 7;
172        x ^= x << 17;
173        *state = x;
174        (x as f64 / u64::MAX as f64) * 2.0 - 1.0
175    }
176
177    /// Advance the random-walk state by a time step.
178    fn update_random_walk(&self, state: &mut LfoState, dt: f64) {
179        let step = (self.random(&mut state.rng_state) - 0.5) * self.walk_rate * dt * 100.0;
180        state.value = (state.value + step).clamp(-1.0, 1.0);
181    }
182}
183
184/// Control action for an LFO automaton.
185#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
186#[derive(Debug, Clone, Default)]
187pub enum LfoAction {
188    #[default]
189    /// Continue normal operation.
190    None,
191    /// Reset the phase to zero.
192    Reset,
193}
194
195impl Automaton for LfoAutomaton {
196    type State = LfoState;
197    type Action = LfoAction;
198
199    fn step(
200        &self,
201        time: Time,
202        action: &Self::Action,
203        state: &Self::State,
204    ) -> (Self::State, Option<f64>) {
205        let mut new_state = state.clone();
206
207        if let LfoAction::Reset = action {
208            new_state.phase = 0.0;
209            new_state.last_time = time;
210        }
211
212        let dt = time - new_state.last_time;
213
214        new_state.phase += self.frequency * dt;
215        if new_state.phase >= 1.0 {
216            new_state.phase -= 1.0;
217            if let LfoWaveform::SampleAndHold = self.waveform {
218                new_state.value = self.random(&mut new_state.rng_state);
219            }
220        }
221        new_state.last_time = time;
222
223        if let LfoWaveform::RandomWalk = self.waveform {
224            self.update_random_walk(&mut new_state, dt);
225        }
226
227        let raw_value = match self.waveform {
228            LfoWaveform::SampleAndHold => new_state.value,
229            LfoWaveform::RandomWalk => new_state.value,
230            _ => self
231                .waveform
232                .evaluate(new_state.phase, Some(self.pulse_width)),
233        };
234
235        let value = raw_value * self.amplitude + self.offset;
236        let clamped = self.range.clamp(value);
237
238        (new_state, Some(clamped))
239    }
240
241    fn initial_state(&self) -> Self::State {
242        LfoState {
243            phase: 0.0,
244            value: 0.0,
245            hold_counter: 0,
246            rng_state: 123456789,
247            last_time: 0.0,
248        }
249    }
250
251    fn name(&self) -> &str {
252        &self.name
253    }
254
255    fn extract_value(&self, state: &Self::State) -> f64 {
256        let raw = match self.waveform {
257            LfoWaveform::SampleAndHold => state.value,
258            LfoWaveform::RandomWalk => state.value,
259            _ => self.waveform.evaluate(state.phase, Some(self.pulse_width)),
260        };
261        self.range.clamp(raw * self.amplitude + self.offset)
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268    use float_cmp::approx_eq;
269
270    #[test]
271    fn test_sine_lfo() {
272        let lfo = LfoAutomaton::new("Sine", 1.0, 1.0, 0.0, LfoWaveform::Sine);
273        let state = lfo.initial_state();
274
275        let (new_state, value) = lfo.step(0.0, &LfoAction::None, &state);
276        assert!(approx_eq!(f64, value.unwrap(), 0.0, epsilon = 0.01));
277
278        let (_, value) = lfo.step(0.25, &LfoAction::None, &new_state);
279        assert!(approx_eq!(f64, value.unwrap(), 1.0, epsilon = 0.01));
280    }
281
282    #[test]
283    fn test_reset_action() {
284        let lfo = LfoAutomaton::new("Test", 1.0, 1.0, 0.0, LfoWaveform::Sine);
285        let mut state = lfo.initial_state();
286        state.phase = 0.5;
287
288        let (new_state, _) = lfo.step(1.0, &LfoAction::Reset, &state);
289        assert!(approx_eq!(f64, new_state.phase, 0.0, epsilon = 0.01));
290    }
291}