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