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 out = [0.0_f32; N];
220        self.next_into(target, &mut out);
221        out
222    }
223
224    /// Advance the smoother by `out.len()` samples in one call,
225    /// writing each intermediate value to `out`. Slice-based variant
226    /// of [`Self::next_block`] - same single-atomic-pair amortization,
227    /// runtime length. Use this when the chunk size depends on
228    /// `process()`'s actual block (the common case for plugins
229    /// chunking the host's buffer into a `MAX_BLOCK` ladder); the
230    /// const-generic `next_block::<N>` always advances by `N` even
231    /// when the caller only consumes a shorter prefix.
232    #[allow(clippy::cast_possible_truncation)]
233    #[inline]
234    pub fn next_into(&self, target: f64, out: &mut [f32]) {
235        let mut current = self.current.load();
236        let coeff = self.coeff.load();
237
238        match self.style {
239            SmoothingStyle::None => {
240                // Snap immediately; every output is `target`.
241                out.fill(target as f32);
242                current = target;
243            }
244            SmoothingStyle::Linear(_) => {
245                // Threshold matches `next()`'s per-step floor. Hoisted
246                // out of the loop because it depends only on `target`.
247                let threshold = (target.abs() * 1e-6).max(1e-8);
248                for slot in out.iter_mut() {
249                    let diff = target - current;
250                    if diff.abs() < threshold {
251                        current = target;
252                    } else {
253                        let step = diff * coeff;
254                        current = if step.abs() >= diff.abs() {
255                            target
256                        } else {
257                            current + step
258                        };
259                    }
260                    *slot = current as f32;
261                }
262            }
263            SmoothingStyle::Exponential(_) => {
264                // Standard one-pole exponential. `current` is a local
265                // (no atomic), so LLVM keeps it in a register and the
266                // body auto-vectorizes for large enough slices.
267                for slot in out.iter_mut() {
268                    current += coeff * (target - current);
269                    *slot = current as f32;
270                }
271            }
272        }
273
274        self.current.store(current);
275    }
276}
277
278/// Pure coefficient calculation: smoothing style + sample rate →
279/// per-sample step coefficient. Lifted out of `Smoother` so
280/// `set_sample_rate` can compute the new coefficient against its
281/// local `sr` argument without re-loading any shared state - the
282/// audio thread then sees a single atomic publish of `coeff`
283/// instead of a two-step (`sample_rate`, `coeff`) write.
284fn compute_coeff(style: SmoothingStyle, sr: f64) -> f64 {
285    match style {
286        SmoothingStyle::None => 1.0,
287        SmoothingStyle::Linear(ms) => {
288            let samples = (ms / 1000.0) * sr;
289            if samples > 1.0 { 1.0 / samples } else { 1.0 }
290        }
291        SmoothingStyle::Exponential(ms) => {
292            let samples = (ms / 1000.0) * sr;
293            if samples > 0.0 {
294                1.0 - (-1.0 / samples).exp()
295            } else {
296                1.0
297            }
298        }
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[test]
307    fn is_converged_none_always_true() {
308        let s = Smoother::new(SmoothingStyle::None);
309        assert!(s.is_converged(0.0));
310        assert!(s.is_converged(42.0));
311        assert!(s.is_converged(-1e6));
312    }
313
314    #[test]
315    fn is_converged_linear_after_snap() {
316        let s = Smoother::new(SmoothingStyle::Linear(5.0));
317        s.snap(2.5);
318        assert!(s.is_converged(2.5));
319        assert!(!s.is_converged(2.6));
320    }
321
322    #[test]
323    fn is_converged_exponential_at_target() {
324        let s = Smoother::new(SmoothingStyle::Exponential(5.0));
325        s.snap(1.0);
326        assert!(s.is_converged(1.0));
327        // Step partway toward 2.0: still smoothing.
328        let _ = s.next(2.0);
329        assert!(!s.is_converged(2.0));
330    }
331
332    #[test]
333    fn is_converged_threshold_scales_with_magnitude() {
334        // Target near zero: floor at 1e-8.
335        let s = Smoother::new(SmoothingStyle::Linear(5.0));
336        s.snap(0.0);
337        assert!(s.is_converged(1e-9));
338        assert!(!s.is_converged(1e-7));
339
340        // Large target: threshold scales by 1e-6.
341        s.snap(20_000.0);
342        assert!(s.is_converged(20_000.01));
343        assert!(!s.is_converged(20_001.0));
344    }
345
346    #[test]
347    fn next_after_matches_next_block_exponential() {
348        // The closed-form path for Exponential should land on the
349        // same value the step-by-step `next_block` produces (within
350        // f32 rounding).
351        const N: usize = 512;
352        let stepwise = Smoother::new(SmoothingStyle::Exponential(20.0));
353        stepwise.set_sample_rate(48_000.0);
354        stepwise.snap(0.0);
355        let block = stepwise.next_block::<N>(1.0);
356
357        let closed = Smoother::new(SmoothingStyle::Exponential(20.0));
358        closed.set_sample_rate(48_000.0);
359        closed.snap(0.0);
360        let after = closed.next_after(1.0, N);
361
362        let diff = (block[N - 1] - after).abs();
363        assert!(
364            diff < 1e-6,
365            "block last = {}, after = {}",
366            block[N - 1],
367            after
368        );
369    }
370
371    #[test]
372    fn next_into_matches_next_block_prefix() {
373        // `next_into(&mut [_; n])` must produce the same per-sample
374        // sequence as `next_block::<N>` for `i < n`, and must advance
375        // the smoother by exactly `n` steps. Regression guard for the
376        // bug that motivated `next_into`: callers chunking the host
377        // buffer into a `MAX_BLOCK`-sized ladder were calling
378        // `next_block::<MAX_BLOCK>` and consuming only `n` samples,
379        // which silently advanced the smoother by `MAX_BLOCK` and
380        // stepped the value at the next block boundary.
381        const FULL: usize = 64;
382        const PARTIAL: usize = 17;
383
384        let reference = Smoother::new(SmoothingStyle::Exponential(20.0));
385        reference.set_sample_rate(48_000.0);
386        reference.snap(0.0);
387        let block = reference.next_block::<FULL>(1.0);
388
389        let mut buf = [0.0_f32; FULL];
390        let partial = Smoother::new(SmoothingStyle::Exponential(20.0));
391        partial.set_sample_rate(48_000.0);
392        partial.snap(0.0);
393        partial.next_into(1.0, &mut buf[..PARTIAL]);
394
395        for i in 0..PARTIAL {
396            let diff = (buf[i] - block[i]).abs();
397            assert!(diff < 1e-6, "i={i}, into={}, block={}", buf[i], block[i]);
398        }
399
400        // Next sample from `partial` must equal `block[PARTIAL]` —
401        // i.e. the smoother is positioned at sample PARTIAL, not at
402        // sample FULL.
403        let next = partial.next(1.0);
404        let diff = (next - block[PARTIAL]).abs();
405        assert!(diff < 1e-6, "next={next}, expected={}", block[PARTIAL]);
406    }
407
408    #[test]
409    fn next_after_matches_next_block_linear() {
410        const N: usize = 64;
411        let stepwise = Smoother::new(SmoothingStyle::Linear(5.0));
412        stepwise.set_sample_rate(48_000.0);
413        stepwise.snap(0.0);
414        let mut last = 0.0_f32;
415        for _ in 0..N {
416            last = stepwise.next(1.0);
417        }
418
419        let chunked = Smoother::new(SmoothingStyle::Linear(5.0));
420        chunked.set_sample_rate(48_000.0);
421        chunked.snap(0.0);
422        let after = chunked.next_after(1.0, N);
423
424        assert!(
425            (last - after).abs() < 1e-6,
426            "stepwise = {last}, after = {after}"
427        );
428    }
429
430    #[test]
431    #[allow(clippy::float_cmp)]
432    fn next_after_zero_samples_is_no_op() {
433        // n=0 must return current value and leave state untouched.
434        // Float equality is the right check here: we want bit-exact
435        // identity, not "close enough".
436        let s = Smoother::new(SmoothingStyle::Exponential(5.0));
437        s.set_sample_rate(48_000.0);
438        s.snap(0.25);
439        let before = s.current();
440        let v = s.next_after(0.99, 0);
441        assert_eq!(v, before);
442        assert_eq!(s.current(), before);
443    }
444
445    #[test]
446    #[allow(clippy::float_cmp)]
447    fn next_after_none_snaps_immediately() {
448        let s = Smoother::new(SmoothingStyle::None);
449        s.snap(0.0);
450        let v = s.next_after(0.7, 1024);
451        assert_eq!(v, 0.7);
452        assert_eq!(s.current(), 0.7);
453    }
454}