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