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