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