Skip to main content

beamer_core/
smoothing.rs

1//! Parameter smoothing for avoiding zipper noise during automation.
2//!
3//! This module provides [`Smoother`] for interpolating parameter values over time,
4//! and [`SmoothingStyle`] for selecting the interpolation algorithm.
5//!
6//! # Usage
7//!
8//! Smoothers are typically used via [`FloatParameter::with_smoother()`](crate::FloatParameter::with_smoother),
9//! but can also be used standalone for custom modulation.
10//!
11//! ```ignore
12//! // Via FloatParameter (recommended)
13//! let gain = FloatParameter::db("Gain", 0.0, -60.0..=12.0)
14//!     .with_smoother(SmoothingStyle::Exponential(5.0));  // 5ms
15//!
16//! // Standalone usage
17//! let mut smoother = Smoother::new(SmoothingStyle::Linear(10.0));
18//! smoother.set_sample_rate(44100.0);
19//! smoother.reset(1.0);
20//! smoother.set_target(0.5);
21//! let value = smoother.tick();  // Per-sample
22//! ```
23//!
24//! # Thread Safety
25//!
26//! `Smoother` requires `&mut self` for advancing state and is intended for
27//! single-threaded audio processing only. The parent `FloatParameter` uses atomic
28//! storage for thread-safe parameter access from UI/host threads.
29
30/// Threshold for snapping to target value to avoid denormals and finish smoothing.
31const SNAP_THRESHOLD: f64 = 1e-8;
32
33/// Smoothing algorithm selection.
34///
35/// The `f64` parameter is the smoothing time in milliseconds.
36#[derive(Debug, Clone, Copy, PartialEq, Default)]
37pub enum SmoothingStyle {
38    /// No smoothing - value changes instantly.
39    #[default]
40    None,
41
42    /// Linear interpolation over specified milliseconds.
43    /// Reaches target exactly after the specified time.
44    /// Good for: general purpose, predictable behavior.
45    Linear(f64),
46
47    /// Exponential (one-pole IIR) smoothing.
48    /// Fast initial response, asymptotically approaches target.
49    /// Reaches ~63% of target in the specified time (time constant).
50    /// Good for: most musical parameters, can cross zero.
51    Exponential(f64),
52
53    /// Logarithmic smoothing for frequency and other positive-only values.
54    /// Slow start, accelerating curve.
55    /// CANNOT cross zero or handle negative values - use Exponential for dB parameters.
56    /// Good for: filter frequencies (Hz), other always-positive parameters.
57    Logarithmic(f64),
58}
59
60/// A parameter value smoother.
61///
62/// Can be used standalone for custom modulation, or integrated
63/// into [`FloatParameter`](crate::FloatParameter) via `.with_smoother()`.
64///
65/// # Thread Safety
66///
67/// `Smoother` is `Send` but not `Sync` - it requires `&mut self` for
68/// advancing state. This is intentional for audio thread usage.
69#[derive(Debug, Clone)]
70pub struct Smoother {
71    style: SmoothingStyle,
72    sample_rate: f64,
73
74    // Current state
75    current: f64,
76    target: f64,
77
78    // Precomputed coefficients (style-dependent)
79    coefficient: f64,     // For exponential: pole coefficient
80    step_size: f64,       // For linear: increment per sample
81    steps_remaining: u32, // For linear: samples until target reached
82}
83
84impl Smoother {
85    /// Create a new smoother with the given style.
86    ///
87    /// Sample rate must be set before use via [`set_sample_rate()`](Self::set_sample_rate).
88    pub fn new(style: SmoothingStyle) -> Self {
89        Self {
90            style,
91            sample_rate: 0.0,
92            current: 0.0,
93            target: 0.0,
94            coefficient: 0.0,
95            step_size: 0.0,
96            steps_remaining: 0,
97        }
98    }
99
100    /// Create a smoother with no smoothing (pass-through).
101    pub fn none() -> Self {
102        Self::new(SmoothingStyle::None)
103    }
104
105    /// Get the smoothing style.
106    pub fn style(&self) -> SmoothingStyle {
107        self.style
108    }
109
110    /// Set the sample rate.
111    ///
112    /// Call this from `Processor::setup()`. Recomputes coefficients
113    /// based on time constants.
114    pub fn set_sample_rate(&mut self, sample_rate: f64) {
115        self.sample_rate = sample_rate;
116        self.recompute_coefficients();
117    }
118
119    /// Set a new target value.
120    ///
121    /// Call this when the parameter value changes (typically at start of process block).
122    pub fn set_target(&mut self, target: f64) {
123        if (self.target - target).abs() < 1e-10 {
124            return;
125        }
126        self.target = target;
127
128        match self.style {
129            SmoothingStyle::None => {
130                self.current = target;
131            }
132            SmoothingStyle::Linear(ms) => {
133                let samples = (ms * self.sample_rate / 1000.0) as u32;
134                self.steps_remaining = samples.max(1);
135                self.step_size = (target - self.current) / self.steps_remaining as f64;
136            }
137            SmoothingStyle::Exponential(_) | SmoothingStyle::Logarithmic(_) => {
138                // Coefficient already computed, just update target
139            }
140        }
141    }
142
143    /// Reset immediately to a value (no smoothing).
144    ///
145    /// Use when loading state or initializing to avoid ramps.
146    pub fn reset(&mut self, value: f64) {
147        self.current = value;
148        self.target = value;
149        self.steps_remaining = 0;
150        self.step_size = 0.0;
151    }
152
153    /// Advance by one sample and return the smoothed value.
154    ///
155    /// Call this once per sample in the audio loop.
156    #[inline]
157    pub fn tick(&mut self) -> f64 {
158        match self.style {
159            SmoothingStyle::None => self.target,
160            SmoothingStyle::Linear(_) => {
161                if self.steps_remaining > 0 {
162                    self.current += self.step_size;
163                    self.steps_remaining -= 1;
164                    if self.steps_remaining == 0 {
165                        self.current = self.target;
166                    }
167                }
168                self.current
169            }
170            SmoothingStyle::Exponential(_) => {
171                // One-pole: y[n] = y[n-1] + coef * (target - y[n-1])
172                self.current += self.coefficient * (self.target - self.current);
173
174                // Snap when very close (avoid denormals, finish smoothing)
175                if (self.current - self.target).abs() < SNAP_THRESHOLD {
176                    self.current = self.target;
177                }
178                self.current
179            }
180            SmoothingStyle::Logarithmic(_) => {
181                // Similar to exponential but in log domain
182                // Only works for positive values
183                if self.target > 0.0 && self.current > 0.0 {
184                    let log_current = self.current.ln();
185                    let log_target = self.target.ln();
186                    let log_next = log_current + self.coefficient * (log_target - log_current);
187                    self.current = log_next.exp();
188
189                    if (self.current - self.target).abs() < SNAP_THRESHOLD {
190                        self.current = self.target;
191                    }
192                } else {
193                    self.current = self.target;
194                }
195                self.current
196            }
197        }
198    }
199
200    /// Get current smoothed value without advancing.
201    #[inline]
202    pub fn current(&self) -> f64 {
203        match self.style {
204            SmoothingStyle::None => self.target,
205            _ => self.current,
206        }
207    }
208
209    /// Get the target value.
210    #[inline]
211    pub fn target(&self) -> f64 {
212        self.target
213    }
214
215    /// Skip forward by n samples (for block processing).
216    ///
217    /// This is equivalent to calling `tick()` n times but may be optimized
218    /// for some smoothing styles.
219    pub fn skip(&mut self, samples: usize) {
220        match self.style {
221            SmoothingStyle::None => {}
222            SmoothingStyle::Linear(_) => {
223                let skip_count = (samples as u32).min(self.steps_remaining);
224                if skip_count > 0 {
225                    self.current += self.step_size * skip_count as f64;
226                    self.steps_remaining -= skip_count;
227                    if self.steps_remaining == 0 {
228                        self.current = self.target;
229                    }
230                }
231            }
232            SmoothingStyle::Exponential(_) => {
233                // Closed-form solution: after n samples of one-pole filter
234                // current = target + (current - target) * (1 - coef)^n
235                let decay = (1.0 - self.coefficient).powi(samples as i32);
236                self.current = self.target + (self.current - self.target) * decay;
237
238                if (self.current - self.target).abs() < SNAP_THRESHOLD {
239                    self.current = self.target;
240                }
241            }
242            SmoothingStyle::Logarithmic(_) => {
243                // Closed-form in log domain (only for positive values)
244                if self.target > 0.0 && self.current > 0.0 {
245                    let log_current = self.current.ln();
246                    let log_target = self.target.ln();
247                    let decay = (1.0 - self.coefficient).powi(samples as i32);
248                    let log_result = log_target + (log_current - log_target) * decay;
249                    self.current = log_result.exp();
250
251                    if (self.current - self.target).abs() < SNAP_THRESHOLD {
252                        self.current = self.target;
253                    }
254                } else {
255                    self.current = self.target;
256                }
257            }
258        }
259    }
260
261    /// Fill a slice with smoothed values (f64).
262    pub fn fill(&mut self, buffer: &mut [f64]) {
263        for sample in buffer.iter_mut() {
264            *sample = self.tick();
265        }
266    }
267
268    /// Fill a slice with smoothed values (f32).
269    pub fn fill_f32(&mut self, buffer: &mut [f32]) {
270        for sample in buffer.iter_mut() {
271            *sample = self.tick() as f32;
272        }
273    }
274
275    /// Returns true if still smoothing toward target.
276    #[inline]
277    pub fn is_smoothing(&self) -> bool {
278        match self.style {
279            SmoothingStyle::None => false,
280            SmoothingStyle::Linear(_) => self.steps_remaining > 0,
281            SmoothingStyle::Exponential(_) | SmoothingStyle::Logarithmic(_) => {
282                (self.current - self.target).abs() > SNAP_THRESHOLD
283            }
284        }
285    }
286
287    fn recompute_coefficients(&mut self) {
288        if self.sample_rate <= 0.0 {
289            return;
290        }
291
292        match self.style {
293            SmoothingStyle::None => {}
294            SmoothingStyle::Linear(_) => {
295                // Coefficients computed per set_target()
296            }
297            SmoothingStyle::Exponential(ms) | SmoothingStyle::Logarithmic(ms) => {
298                // One-pole coefficient: reaches ~63% in `ms` milliseconds
299                // coef = 1 - e^(-1 / (tau * sr))
300                // where tau = ms / 1000
301                let tau = ms / 1000.0;
302                let samples_per_tau = tau * self.sample_rate;
303                if samples_per_tau > 0.0 {
304                    self.coefficient = 1.0 - (-1.0 / samples_per_tau).exp();
305                } else {
306                    self.coefficient = 1.0; // Instant
307                }
308            }
309        }
310    }
311}
312
313impl Default for Smoother {
314    fn default() -> Self {
315        Self::none()
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    #[test]
324    fn test_no_smoothing() {
325        let mut s = Smoother::new(SmoothingStyle::None);
326        s.set_sample_rate(44100.0);
327        s.reset(0.0);
328        s.set_target(1.0);
329        assert!((s.tick() - 1.0).abs() < 1e-10);
330        assert!(!s.is_smoothing());
331    }
332
333    #[test]
334    fn test_linear_reaches_target() {
335        let mut s = Smoother::new(SmoothingStyle::Linear(10.0)); // 10ms
336        s.set_sample_rate(1000.0); // 1 sample per ms
337        s.reset(0.0);
338        s.set_target(1.0);
339
340        // Should take 10 samples to reach target
341        for _ in 0..10 {
342            s.tick();
343        }
344        assert!((s.current() - 1.0).abs() < 1e-10);
345        assert!(!s.is_smoothing());
346    }
347
348    #[test]
349    fn test_exponential_approaches_target() {
350        let mut s = Smoother::new(SmoothingStyle::Exponential(5.0)); // 5ms time constant
351        s.set_sample_rate(44100.0);
352        s.reset(0.0);
353        s.set_target(1.0);
354
355        // After many samples, should be very close to target
356        for _ in 0..10000 {
357            s.tick();
358        }
359        assert!((s.current() - 1.0).abs() < 1e-6);
360    }
361
362    #[test]
363    fn test_skip_linear() {
364        let mut s = Smoother::new(SmoothingStyle::Linear(10.0));
365        s.set_sample_rate(1000.0);
366        s.reset(0.0);
367        s.set_target(1.0);
368
369        s.skip(5);
370        assert!((s.current() - 0.5).abs() < 1e-10);
371
372        s.skip(5);
373        assert!((s.current() - 1.0).abs() < 1e-10);
374    }
375
376    #[test]
377    fn test_fill_f32() {
378        let mut s = Smoother::new(SmoothingStyle::Linear(10.0));
379        s.set_sample_rate(1000.0);
380        s.reset(0.0);
381        s.set_target(1.0);
382
383        let mut buffer = [0.0f32; 10];
384        s.fill_f32(&mut buffer);
385
386        // First value should be ~0.1, last should be 1.0
387        assert!(buffer[0] > 0.0);
388        assert!((buffer[9] - 1.0).abs() < 1e-5);
389    }
390}