Skip to main content

truce_params/
smooth.rs

1use crate::types::AtomicF64;
2
3/// Smoothing style for a parameter.
4#[derive(Clone, Copy, Debug)]
5pub enum SmoothingStyle {
6    None,
7    Linear(f64),
8    Exponential(f64),
9}
10
11/// Per-parameter smoother. All methods take `&self` for interior
12/// mutability, enabling use through `Arc<Params>`.
13///
14/// **Threading.** The audio thread is the sole writer of `current`
15/// (via `next` / `snap`) and the sole reader of `coeff`. The
16/// editor / main thread is the sole writer of `sample_rate` and
17/// `coeff` via [`Self::set_sample_rate`], which computes the new
18/// coefficient locally from the supplied `sr` before storing -
19/// so a concurrent audio block sees either the old (`sample_rate`,
20/// `coeff`) pair or the new one, never a mid-update split. The
21/// stored `sample_rate` field is informational; it isn't read in
22/// the audio path, only by future writers as a freshness check.
23pub struct Smoother {
24    style: SmoothingStyle,
25    current: AtomicF64,
26    coeff: AtomicF64,
27    sample_rate: AtomicF64,
28}
29
30impl Smoother {
31    #[must_use]
32    pub fn new(style: SmoothingStyle) -> Self {
33        // Pre-compute the coefficient against a placeholder sample
34        // rate so unit tests that exercise `FloatParam` / `Smoother`
35        // directly (without calling `set_sample_rate` first) still
36        // produce non-zero output. The host re-runs this when it
37        // calls `set_sample_rate(sr)` at activate time.
38        let coeff = compute_coeff(style, 44100.0);
39        Self {
40            style,
41            current: AtomicF64::new(0.0),
42            coeff: AtomicF64::new(coeff),
43            sample_rate: AtomicF64::new(44100.0),
44        }
45    }
46
47    pub fn set_sample_rate(&self, sr: f64) {
48        // Compute coeff from the local `sr` (not from a re-loaded
49        // `self.sample_rate`) so the (sample_rate, coeff) pair the
50        // audio thread observes via `coeff` is always self-consistent -
51        // even if a second `set_sample_rate` from a different thread
52        // races. Order: stash the informational sample_rate first,
53        // then publish the audio-visible coeff last.
54        let new_coeff = compute_coeff(self.style, sr);
55        self.sample_rate.store(sr);
56        self.coeff.store(new_coeff);
57    }
58
59    /// Snap to a value immediately (used on reset/init).
60    pub fn snap(&self, value: f64) {
61        self.current.store(value);
62    }
63
64    /// Get next smoothed value, advancing one sample.
65    // Smoothed param values stay in `[-1e10, 1e10]`; f32 precision
66    // is enough for the per-sample DSP path.
67    #[allow(clippy::cast_possible_truncation)]
68    #[inline]
69    pub fn next(&self, target: f64) -> f32 {
70        let current = self.current.load();
71        let coeff = self.coeff.load();
72
73        let new_current = match self.style {
74            SmoothingStyle::None => target,
75            SmoothingStyle::Linear(_) => {
76                let diff = target - current;
77                // Scale the snap threshold to the value magnitude so
78                // very-small-range params don't snap prematurely and
79                // very-large-range params (e.g. 20 kHz cutoffs) don't
80                // burn cycles on differences they can't perceive.
81                // Floor at 1e-8 for targets near zero.
82                let threshold = (target.abs() * 1e-6).max(1e-8);
83                if diff.abs() < threshold {
84                    target
85                } else {
86                    let step = diff * coeff;
87                    if step.abs() >= diff.abs() {
88                        target
89                    } else {
90                        current + step
91                    }
92                }
93            }
94            SmoothingStyle::Exponential(_) => current + coeff * (target - current),
95        };
96
97        self.current.store(new_current);
98        new_current as f32
99    }
100
101    /// Current smoothed value without advancing.
102    // See `next` for why narrowing to f32 here is invisible.
103    #[allow(clippy::cast_possible_truncation)]
104    #[inline]
105    pub fn current(&self) -> f32 {
106        self.current.load() as f32
107    }
108
109    /// True when the smoother's internal state matches `target`
110    /// closely enough that further smoothing would be a no-op.
111    ///
112    /// `SmoothingStyle::None` always returns `true`. For `Linear`
113    /// / `Exponential`, the comparison uses the same snap threshold
114    /// `next()` applies: `(target.abs() * 1e-6).max(1e-8)`.
115    /// Exponential smoothing asymptotes but never lands exactly
116    /// on `target`; the threshold gates "close enough that any
117    /// further step is denormal-territory".
118    ///
119    /// Costs one atomic load. Plugin authors typically reach this
120    /// through [`crate::types::FloatParam::is_smoothing`] which
121    /// loads the target and inverts the answer.
122    #[inline]
123    #[must_use]
124    pub fn is_converged(&self, target: f64) -> bool {
125        match self.style {
126            SmoothingStyle::None => true,
127            SmoothingStyle::Linear(_) | SmoothingStyle::Exponential(_) => {
128                let current = self.current.load();
129                let threshold = (target.abs() * 1e-6).max(1e-8);
130                (target - current).abs() < threshold
131            }
132        }
133    }
134
135    /// Advance the smoother by `n_samples` samples in one call,
136    /// returning only the final value. Use for **block-rate**
137    /// consumers (hard gates, mode switches, anything that needs a
138    /// single smoothed value per audio block) where the intermediate
139    /// envelope from [`Self::next_block`] is wasted work.
140    ///
141    /// One atomic load and one atomic store regardless of
142    /// `n_samples`. For `Exponential`, uses the closed-form
143    /// `current + (target - current) * (1 - (1 - coeff)^N)` (one
144    /// `powf` per call) instead of looping; for `Linear`, loops
145    /// because the snap-when-close-enough check breaks any clean
146    /// closed form.
147    ///
148    /// Semantics match `next` step-for-step: equivalent to calling
149    /// `next(target)` `n_samples` times and returning the last
150    /// result, but without paying per-sample atomic costs.
151    // Smoother state stays in `[-1e10, 1e10]`; the f32 narrowing
152    // matches `next` / `next_block`.
153    #[allow(clippy::cast_possible_truncation)]
154    #[allow(clippy::cast_precision_loss)]
155    #[inline]
156    pub fn next_after(&self, target: f64, n_samples: usize) -> f32 {
157        if n_samples == 0 {
158            return self.current.load() as f32;
159        }
160
161        let mut current = self.current.load();
162        let coeff = self.coeff.load();
163
164        match self.style {
165            SmoothingStyle::None => {
166                current = target;
167            }
168            SmoothingStyle::Linear(_) => {
169                // Same per-step math as `next_block`, including the
170                // snap-when-close-enough check. Looped because the
171                // snap branch wrecks any closed-form derivation.
172                let threshold = (target.abs() * 1e-6).max(1e-8);
173                for _ in 0..n_samples {
174                    let diff = target - current;
175                    if diff.abs() < threshold {
176                        current = target;
177                        break;
178                    }
179                    let step = diff * coeff;
180                    current = if step.abs() >= diff.abs() {
181                        target
182                    } else {
183                        current + step
184                    };
185                }
186            }
187            SmoothingStyle::Exponential(_) => {
188                // Closed form: N iterations of `current += coeff *
189                // (target - current)` converge to
190                // `target + (current - target) * (1 - coeff)^N`.
191                let decay = (1.0 - coeff).powf(n_samples as f64);
192                current = target + (current - target) * decay;
193            }
194        }
195
196        self.current.store(current);
197        current as f32
198    }
199
200    /// Advance the smoother by `N` samples in one call, returning the
201    /// intermediate per-sample values as a stack-allocated array.
202    ///
203    /// Issues exactly **one** atomic load and **one** atomic store
204    /// against `current`, regardless of `N`. The inner stepping runs
205    /// in a register-resident loop the optimizer can unroll and (for
206    /// `Exponential` / `None`) vectorize. Compare with [`Self::next`]
207    /// which costs one load + one store *per sample* and therefore
208    /// forces the compiler to keep `current` in memory across
209    /// iterations.
210    ///
211    /// Semantics match `next` step-for-step: the i-th element of the
212    /// returned array is what `next(target)` would have produced if
213    /// called for the i-th time in sequence.
214    // Smoother state stays in `[-1e10, 1e10]`; the f32 narrowing
215    // matches the per-sample `next()` contract.
216    #[allow(clippy::cast_possible_truncation)]
217    #[inline]
218    pub fn next_block<const N: usize>(&self, target: f64) -> [f32; N] {
219        let mut current = self.current.load();
220        let coeff = self.coeff.load();
221        let mut out = [0.0_f32; N];
222
223        match self.style {
224            SmoothingStyle::None => {
225                // Snap immediately; every output is `target`.
226                out.fill(target as f32);
227                current = target;
228            }
229            SmoothingStyle::Linear(_) => {
230                // Threshold matches `next()`'s per-step floor. Hoisted
231                // out of the loop because it depends only on `target`.
232                let threshold = (target.abs() * 1e-6).max(1e-8);
233                for slot in &mut out {
234                    let diff = target - current;
235                    if diff.abs() < threshold {
236                        current = target;
237                    } else {
238                        let step = diff * coeff;
239                        current = if step.abs() >= diff.abs() {
240                            target
241                        } else {
242                            current + step
243                        };
244                    }
245                    *slot = current as f32;
246                }
247            }
248            SmoothingStyle::Exponential(_) => {
249                // Standard one-pole exponential. `current` is a local
250                // (no atomic), so LLVM keeps it in a register and the
251                // body auto-vectorizes for large enough N.
252                for slot in &mut out {
253                    current += coeff * (target - current);
254                    *slot = current as f32;
255                }
256            }
257        }
258
259        self.current.store(current);
260        out
261    }
262}
263
264/// Pure coefficient calculation: smoothing style + sample rate →
265/// per-sample step coefficient. Lifted out of `Smoother` so
266/// `set_sample_rate` can compute the new coefficient against its
267/// local `sr` argument without re-loading any shared state - the
268/// audio thread then sees a single atomic publish of `coeff`
269/// instead of a two-step (`sample_rate`, `coeff`) write.
270fn compute_coeff(style: SmoothingStyle, sr: f64) -> f64 {
271    match style {
272        SmoothingStyle::None => 1.0,
273        SmoothingStyle::Linear(ms) => {
274            let samples = (ms / 1000.0) * sr;
275            if samples > 1.0 { 1.0 / samples } else { 1.0 }
276        }
277        SmoothingStyle::Exponential(ms) => {
278            let samples = (ms / 1000.0) * sr;
279            if samples > 0.0 {
280                1.0 - (-1.0 / samples).exp()
281            } else {
282                1.0
283            }
284        }
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    #[test]
293    fn is_converged_none_always_true() {
294        let s = Smoother::new(SmoothingStyle::None);
295        assert!(s.is_converged(0.0));
296        assert!(s.is_converged(42.0));
297        assert!(s.is_converged(-1e6));
298    }
299
300    #[test]
301    fn is_converged_linear_after_snap() {
302        let s = Smoother::new(SmoothingStyle::Linear(5.0));
303        s.snap(2.5);
304        assert!(s.is_converged(2.5));
305        assert!(!s.is_converged(2.6));
306    }
307
308    #[test]
309    fn is_converged_exponential_at_target() {
310        let s = Smoother::new(SmoothingStyle::Exponential(5.0));
311        s.snap(1.0);
312        assert!(s.is_converged(1.0));
313        // Step partway toward 2.0: still smoothing.
314        let _ = s.next(2.0);
315        assert!(!s.is_converged(2.0));
316    }
317
318    #[test]
319    fn is_converged_threshold_scales_with_magnitude() {
320        // Target near zero: floor at 1e-8.
321        let s = Smoother::new(SmoothingStyle::Linear(5.0));
322        s.snap(0.0);
323        assert!(s.is_converged(1e-9));
324        assert!(!s.is_converged(1e-7));
325
326        // Large target: threshold scales by 1e-6.
327        s.snap(20_000.0);
328        assert!(s.is_converged(20_000.01));
329        assert!(!s.is_converged(20_001.0));
330    }
331
332    #[test]
333    fn next_after_matches_next_block_exponential() {
334        // The closed-form path for Exponential should land on the
335        // same value the step-by-step `next_block` produces (within
336        // f32 rounding).
337        const N: usize = 512;
338        let stepwise = Smoother::new(SmoothingStyle::Exponential(20.0));
339        stepwise.set_sample_rate(48_000.0);
340        stepwise.snap(0.0);
341        let block = stepwise.next_block::<N>(1.0);
342
343        let closed = Smoother::new(SmoothingStyle::Exponential(20.0));
344        closed.set_sample_rate(48_000.0);
345        closed.snap(0.0);
346        let after = closed.next_after(1.0, N);
347
348        let diff = (block[N - 1] - after).abs();
349        assert!(
350            diff < 1e-6,
351            "block last = {}, after = {}",
352            block[N - 1],
353            after
354        );
355    }
356
357    #[test]
358    fn next_after_matches_next_block_linear() {
359        const N: usize = 64;
360        let stepwise = Smoother::new(SmoothingStyle::Linear(5.0));
361        stepwise.set_sample_rate(48_000.0);
362        stepwise.snap(0.0);
363        let mut last = 0.0_f32;
364        for _ in 0..N {
365            last = stepwise.next(1.0);
366        }
367
368        let chunked = Smoother::new(SmoothingStyle::Linear(5.0));
369        chunked.set_sample_rate(48_000.0);
370        chunked.snap(0.0);
371        let after = chunked.next_after(1.0, N);
372
373        assert!(
374            (last - after).abs() < 1e-6,
375            "stepwise = {last}, after = {after}"
376        );
377    }
378
379    #[test]
380    #[allow(clippy::float_cmp)]
381    fn next_after_zero_samples_is_no_op() {
382        // n=0 must return current value and leave state untouched.
383        // Float equality is the right check here: we want bit-exact
384        // identity, not "close enough".
385        let s = Smoother::new(SmoothingStyle::Exponential(5.0));
386        s.set_sample_rate(48_000.0);
387        s.snap(0.25);
388        let before = s.current();
389        let v = s.next_after(0.99, 0);
390        assert_eq!(v, before);
391        assert_eq!(s.current(), before);
392    }
393
394    #[test]
395    #[allow(clippy::float_cmp)]
396    fn next_after_none_snaps_immediately() {
397        let s = Smoother::new(SmoothingStyle::None);
398        s.snap(0.0);
399        let v = s.next_after(0.7, 1024);
400        assert_eq!(v, 0.7);
401        assert_eq!(s.current(), 0.7);
402    }
403}