bbx_dsp/
smoothing.rs

1//! Parameter smoothing utilities for click-free parameter changes.
2//!
3//! Provides [`SmoothedValue`] for interpolating between parameter values over time,
4//! avoiding audible clicks when parameters change abruptly. Supports both
5//! [`Linear`] and [`Multiplicative`] (exponential) smoothing strategies.
6
7use std::marker::PhantomData;
8
9use bbx_core::flush_denormal_f64;
10
11use crate::sample::Sample;
12
13const INV_1000: f64 = 1.0 / 1000.0;
14
15/// Minimum ramp length in milliseconds to prevent instant value jumps (clicks).
16/// 0.01ms is fast enough to sound near-instantaneous while still providing
17/// a brief transition to avoid discontinuities.
18const MIN_RAMP_LENGTH_MS: f64 = 0.01;
19
20/// Check if two Sample values are approximately equal.
21#[inline]
22fn is_approximately_equal<S: Sample>(a: S, b: S) -> bool {
23    let a_f64 = a.to_f64();
24    let b_f64 = b.to_f64();
25    let epsilon = S::EPSILON.to_f64() * a_f64.abs().max(b_f64.abs()).max(1.0);
26    (a_f64 - b_f64).abs() < epsilon
27}
28
29/// Marker type for linear smoothing.
30///
31/// Uses additive interpolation: `current + increment`.
32/// Best for parameters with linear perception (e.g., pan position).
33#[derive(Debug, Clone, Copy, Default)]
34pub struct Linear;
35
36/// Marker type for multiplicative (exponential) smoothing.
37///
38/// Uses exponential interpolation: `current * e^increment`.
39/// Best for parameters with logarithmic perception (e.g., gain, frequency).
40#[derive(Debug, Clone, Copy, Default)]
41pub struct Multiplicative;
42
43/// Trait defining smoothing behavior for different interpolation strategies.
44pub trait SmoothingStrategy: Clone + Default {
45    /// Calculate the increment value for smoothing.
46    /// Returns f64 for precision in smoothing calculations.
47    fn update_increment<S: Sample>(current: S, target: S, num_samples: f64) -> f64;
48
49    /// Apply the increment to the current value.
50    fn apply_increment<S: Sample>(current: S, increment: f64) -> S;
51
52    /// Apply the increment multiple times (for skip).
53    fn apply_increment_n<S: Sample>(current: S, increment: f64, n: i32) -> S;
54
55    /// Default initial value for this smoothing type.
56    fn default_value<S: Sample>() -> S;
57}
58
59impl SmoothingStrategy for Linear {
60    #[inline]
61    fn update_increment<S: Sample>(current: S, target: S, num_samples: f64) -> f64 {
62        (target.to_f64() - current.to_f64()) / num_samples
63    }
64
65    #[inline]
66    fn apply_increment<S: Sample>(current: S, increment: f64) -> S {
67        S::from_f64(current.to_f64() + increment)
68    }
69
70    #[inline]
71    fn apply_increment_n<S: Sample>(current: S, increment: f64, n: i32) -> S {
72        S::from_f64(current.to_f64() + increment * n as f64)
73    }
74
75    #[inline]
76    fn default_value<S: Sample>() -> S {
77        S::ZERO
78    }
79}
80
81impl SmoothingStrategy for Multiplicative {
82    #[inline]
83    fn update_increment<S: Sample>(current: S, target: S, num_samples: f64) -> f64 {
84        let current_f64 = current.to_f64();
85        let target_f64 = target.to_f64();
86        if current_f64 > 0.0 && target_f64 > 0.0 {
87            (target_f64 / current_f64).ln() / num_samples
88        } else {
89            0.0
90        }
91    }
92
93    #[inline]
94    fn apply_increment<S: Sample>(current: S, increment: f64) -> S {
95        S::from_f64(current.to_f64() * increment.exp())
96    }
97
98    #[inline]
99    fn apply_increment_n<S: Sample>(current: S, increment: f64, n: i32) -> S {
100        S::from_f64(current.to_f64() * (increment * n as f64).exp())
101    }
102
103    #[inline]
104    fn default_value<S: Sample>() -> S {
105        S::ONE
106    }
107}
108
109/// A value that smoothly transitions to a target over time.
110///
111/// Uses the specified `SmoothingStrategy` to interpolate between values,
112/// avoiding clicks and pops when parameters change.
113///
114/// Generic over `S: Sample` for the value type and `T: SmoothingStrategy`
115/// for the interpolation method.
116#[derive(Debug, Clone)]
117pub struct SmoothedValue<S: Sample, T: SmoothingStrategy> {
118    sample_rate: f64,
119    ramp_length_millis: f64,
120    current_value: S,
121    target_value: S,
122    increment: f64, // Keep as f64 for precision
123    _marker: PhantomData<T>,
124}
125
126impl<S: Sample, T: SmoothingStrategy> SmoothedValue<S, T> {
127    /// Create a new `SmoothedValue` with the given initial value.
128    ///
129    /// Uses default sample rate (44100.0) and ramp length (50ms).
130    pub fn new(initial_value: S) -> Self {
131        Self {
132            sample_rate: 44100.0,
133            ramp_length_millis: 50.0,
134            current_value: initial_value,
135            target_value: initial_value,
136            increment: 0.0,
137            _marker: PhantomData,
138        }
139    }
140
141    /// Reset the sample rate and ramp length.
142    ///
143    /// This recalculates the increment based on the new timing parameters.
144    /// A minimum ramp length of 0.01ms is enforced to prevent clicks.
145    pub fn reset(&mut self, sample_rate: f64, ramp_length_millis: f64) {
146        if sample_rate > 0.0 && ramp_length_millis >= 0.0 {
147            self.sample_rate = sample_rate;
148            self.ramp_length_millis = ramp_length_millis.max(MIN_RAMP_LENGTH_MS);
149            self.update_increment();
150        }
151    }
152
153    /// Set a new target value to smooth towards.
154    #[inline]
155    pub fn set_target_value(&mut self, value: S) {
156        self.target_value = value;
157        self.update_increment();
158    }
159
160    /// Get the next smoothed value (call once per sample).
161    #[inline]
162    pub fn get_next_value(&mut self) -> S {
163        if is_approximately_equal(self.current_value, self.target_value) {
164            self.current_value = self.target_value;
165            return self.current_value;
166        }
167
168        self.current_value = T::apply_increment::<S>(self.current_value, self.increment);
169
170        // Flush denormals to prevent CPU slowdown
171        let current_f64 = flush_denormal_f64(self.current_value.to_f64());
172        self.current_value = S::from_f64(current_f64);
173
174        // Prevent overshoot
175        let target_f64 = self.target_value.to_f64();
176        if (self.increment > 0.0 && current_f64 > target_f64) || (self.increment < 0.0 && current_f64 < target_f64) {
177            self.current_value = self.target_value;
178        }
179
180        self.current_value
181    }
182
183    /// Skip ahead by the specified number of samples.
184    #[inline]
185    pub fn skip(&mut self, num_samples: i32) {
186        if is_approximately_equal(self.current_value, self.target_value) {
187            self.current_value = self.target_value;
188            return;
189        }
190
191        let new_value = T::apply_increment_n::<S>(self.current_value, self.increment, num_samples);
192
193        // Flush denormals to prevent CPU slowdown
194        let new_f64 = flush_denormal_f64(new_value.to_f64());
195        let target_f64 = self.target_value.to_f64();
196
197        // Prevent overshoot
198        if (self.increment > 0.0 && new_f64 > target_f64) || (self.increment < 0.0 && new_f64 < target_f64) {
199            self.current_value = self.target_value;
200        } else {
201            self.current_value = S::from_f64(new_f64);
202        }
203    }
204
205    /// Get the current value without advancing.
206    #[inline]
207    pub fn current(&self) -> S {
208        self.current_value
209    }
210
211    /// Get the target value.
212    #[inline]
213    pub fn target(&self) -> S {
214        self.target_value
215    }
216
217    /// Check if the value is still smoothing towards the target.
218    #[inline]
219    pub fn is_smoothing(&self) -> bool {
220        !is_approximately_equal(self.current_value, self.target_value)
221    }
222
223    /// Immediately set both current and target to a value (no smoothing).
224    #[inline]
225    pub fn set_immediate(&mut self, value: S) {
226        self.current_value = value;
227        self.target_value = value;
228        self.increment = 0.0;
229    }
230
231    /// Recalculate the increment based on current timing parameters.
232    #[inline]
233    fn update_increment(&mut self) {
234        // Enforce minimum ramp length to prevent clicks
235        let ramp_ms = self.ramp_length_millis.max(MIN_RAMP_LENGTH_MS);
236        let num_samples = ramp_ms * self.sample_rate * INV_1000;
237        self.increment = T::update_increment::<S>(self.current_value, self.target_value, num_samples);
238    }
239}
240
241impl<S: Sample, T: SmoothingStrategy> Default for SmoothedValue<S, T> {
242    fn default() -> Self {
243        Self::new(T::default_value::<S>())
244    }
245}
246
247/// Linear smoothed value - uses additive interpolation.
248pub type LinearSmoothedValue<S> = SmoothedValue<S, Linear>;
249
250/// Multiplicative smoothed value - uses exponential interpolation.
251/// Better for parameters like gain where equal ratios should feel equal.
252pub type MultiplicativeSmoothedValue<S> = SmoothedValue<S, Multiplicative>;
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    #[test]
259    fn test_linear_immediate_value() {
260        let mut sv = LinearSmoothedValue::<f32>::new(1.0);
261        assert_eq!(sv.current(), 1.0);
262        assert_eq!(sv.get_next_value(), 1.0);
263        assert!(!sv.is_smoothing());
264    }
265
266    #[test]
267    fn test_linear_smoothing_f32() {
268        let mut sv = LinearSmoothedValue::<f32>::new(0.0);
269        // Set up for exactly 4 samples of smoothing
270        sv.reset(1000.0, 4.0); // 1000 Hz, 4ms = 4 samples
271        sv.set_target_value(1.0);
272
273        assert!(sv.is_smoothing());
274        // Each step should advance by 0.25
275        let v1 = sv.get_next_value();
276        assert!((v1 - 0.25).abs() < 0.01, "Expected ~0.25, got {}", v1);
277
278        let v2 = sv.get_next_value();
279        assert!((v2 - 0.5).abs() < 0.01, "Expected ~0.5, got {}", v2);
280
281        let v3 = sv.get_next_value();
282        assert!((v3 - 0.75).abs() < 0.01, "Expected ~0.75, got {}", v3);
283
284        let v4 = sv.get_next_value();
285        assert!((v4 - 1.0).abs() < 0.01, "Expected ~1.0, got {}", v4);
286    }
287
288    #[test]
289    fn test_linear_smoothing_f64() {
290        let mut sv = LinearSmoothedValue::<f64>::new(0.0);
291        sv.reset(1000.0, 4.0);
292        sv.set_target_value(1.0);
293
294        assert!(sv.is_smoothing());
295        let v1 = sv.get_next_value();
296        assert!((v1 - 0.25).abs() < 0.01, "Expected ~0.25, got {}", v1);
297
298        let v2 = sv.get_next_value();
299        assert!((v2 - 0.5).abs() < 0.01, "Expected ~0.5, got {}", v2);
300
301        let v3 = sv.get_next_value();
302        assert!((v3 - 0.75).abs() < 0.01, "Expected ~0.75, got {}", v3);
303
304        let v4 = sv.get_next_value();
305        assert!((v4 - 1.0).abs() < 0.01, "Expected ~1.0, got {}", v4);
306    }
307
308    #[test]
309    fn test_zero_ramp_length() {
310        // With minimum ramp enforcement (0.01ms), zero ramp is treated as 0.01ms
311        // At 44100 Hz, that's about 0.44 samples, so it reaches target quickly
312        let mut sv = LinearSmoothedValue::<f32>::new(0.0);
313        sv.reset(44100.0, 0.0);
314        sv.set_target_value(1.0);
315
316        // Should still be smoothing (minimum ramp applied)
317        assert!(sv.is_smoothing());
318
319        // But should reach target after a few samples
320        for _ in 0..5 {
321            sv.get_next_value();
322        }
323        assert_eq!(sv.current(), 1.0);
324        assert!(!sv.is_smoothing());
325    }
326
327    #[test]
328    fn test_skip() {
329        let mut sv = LinearSmoothedValue::<f32>::new(0.0);
330        sv.reset(1000.0, 10.0); // 10 samples
331        sv.set_target_value(1.0);
332
333        sv.skip(5);
334        assert!((sv.current() - 0.5).abs() < 0.01);
335
336        sv.skip(10); // Overshoot protection
337        assert_eq!(sv.current(), 1.0);
338    }
339
340    #[test]
341    fn test_retarget_during_smoothing() {
342        let mut sv = LinearSmoothedValue::<f32>::new(0.0);
343        sv.reset(1000.0, 4.0);
344        sv.set_target_value(1.0);
345
346        sv.get_next_value(); // 0.25
347        sv.get_next_value(); // 0.5
348
349        // Retarget back to 0 while at 0.5
350        sv.set_target_value(0.0);
351        // Should now smooth from ~0.5 to 0.0 over 4 samples
352        assert!(sv.is_smoothing());
353    }
354
355    #[test]
356    fn test_multiplicative_default() {
357        let sv = MultiplicativeSmoothedValue::<f32>::default();
358        assert_eq!(sv.current(), 1.0);
359    }
360
361    #[test]
362    fn test_multiplicative_smoothing() {
363        let mut sv = MultiplicativeSmoothedValue::<f32>::new(1.0);
364        sv.reset(1000.0, 10.0);
365        sv.set_target_value(2.0);
366
367        assert!(sv.is_smoothing());
368
369        // Should exponentially approach 2.0
370        let mut prev = sv.current();
371        for _ in 0..10 {
372            let curr = sv.get_next_value();
373            assert!(curr > prev, "Value should increase");
374            prev = curr;
375        }
376    }
377
378    #[test]
379    fn test_set_immediate() {
380        let mut sv = LinearSmoothedValue::<f32>::new(0.0);
381        sv.reset(44100.0, 50.0);
382        sv.set_target_value(1.0);
383        assert!(sv.is_smoothing());
384
385        sv.set_immediate(0.5);
386        assert_eq!(sv.current(), 0.5);
387        assert_eq!(sv.target(), 0.5);
388        assert!(!sv.is_smoothing());
389    }
390}