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    /// Short-circuit for any advance that can't step normally. `None` means
140    /// proceed. Shared by `next` / `next_after` / `next_into` so every
141    /// advance path is NaN-safe, not just per-sample `next`. Returns
142    /// `Some(v)`:
143    /// - when `target` is non-finite: `v = current()`, and the accumulator
144    ///   is left untouched - the `Smoother` is public through the prelude,
145    ///   so an author can call `next()` with their own NaN/Inf, and letting
146    ///   it reach `current` would latch (or make the self-heal below
147    ///   re-latch it every sample);
148    /// - else when `current` is non-finite: `v = target` after snapping to
149    ///   it, self-healing a NaN/Inf that slipped in (e.g. a corrupt preset).
150    ///   It would otherwise latch forever, since `NaN + coeff * (target -
151    ///   NaN)` stays NaN and every arm's comparisons are false against NaN.
152    #[inline]
153    #[allow(clippy::cast_possible_truncation)]
154    fn advance_guard(&self, target: f64) -> Option<f32> {
155        if !target.is_finite() {
156            return Some(self.current());
157        }
158        if !self.current.load().is_finite() {
159            self.snap(target);
160            return Some(target as f32);
161        }
162        None
163    }
164
165    /// Get next smoothed value, advancing one sample.
166    // Smoothed param values stay in `[-1e10, 1e10]`; f32 precision
167    // is enough for the per-sample DSP path.
168    #[allow(clippy::cast_possible_truncation)]
169    #[inline]
170    pub fn next(&self, target: f64) -> f32 {
171        if let Some(v) = self.advance_guard(target) {
172            return v;
173        }
174        let current = self.current.load();
175        let coeff = self.coeff.load();
176
177        let new_current = match self.style {
178            SmoothingStyle::None => target,
179            SmoothingStyle::Linear(_) => {
180                // Scale the snap threshold to the value magnitude so
181                // very-small-range params don't snap prematurely and
182                // very-large-range params (e.g. 20 kHz cutoffs) don't
183                // burn cycles on differences they can't perceive.
184                // Floor at 1e-8 for targets near zero.
185                let threshold = (target.abs() * 1e-6).max(1e-8);
186                let step = self.arm_linear_ramp(target, current, coeff);
187                linear_advance(current, target, step, threshold)
188            }
189            SmoothingStyle::Exponential(_) => current + coeff * (target - current),
190            // One-pole exponential in the log domain: equivalent to
191            // `current *= (target / current)^coeff`. Undefined for a
192            // non-positive endpoint, so snap there.
193            SmoothingStyle::Logarithmic(_) => {
194                if current <= 0.0 || target <= 0.0 {
195                    target
196                } else {
197                    (current.ln() + coeff * (target.ln() - current.ln())).exp()
198                }
199            }
200        };
201
202        self.current.store(new_current);
203        new_current as f32
204    }
205
206    /// Current smoothed value without advancing.
207    // See `next` for why narrowing to f32 here is invisible.
208    #[allow(clippy::cast_possible_truncation)]
209    #[inline]
210    pub fn current(&self) -> f32 {
211        self.current.load() as f32
212    }
213
214    /// True when the smoother's internal state matches `target`
215    /// closely enough that further smoothing would be a no-op.
216    ///
217    /// `SmoothingStyle::None` always returns `true`. For `Linear`
218    /// / `Exponential`, the comparison uses the same snap threshold
219    /// `next()` applies: `(target.abs() * 1e-6).max(1e-8)`.
220    /// Exponential smoothing asymptotes but never lands exactly
221    /// on `target`; the threshold gates "close enough that any
222    /// further step is denormal-territory".
223    ///
224    /// `Logarithmic` is multiplicative, so it converges on a *ratio*:
225    /// the log-domain distance `|ln(current) - ln(target)|` against the
226    /// same `1e-6` relative tolerance (equivalent to the linear check
227    /// near convergence, but in the spirit of the log-domain step). It
228    /// falls back to the linear threshold for a non-positive endpoint,
229    /// which `next()` snaps rather than steps.
230    ///
231    /// Costs one atomic load. Plugin authors typically reach this
232    /// through [`crate::types::FloatParam::is_smoothing`] which
233    /// loads the target and inverts the answer.
234    #[inline]
235    #[must_use]
236    pub fn is_converged(&self, target: f64) -> bool {
237        let current = self.current.load();
238        let linear_converged = || {
239            let threshold = (target.abs() * 1e-6).max(1e-8);
240            (target - current).abs() < threshold
241        };
242        match self.style {
243            SmoothingStyle::None => true,
244            SmoothingStyle::Linear(_) | SmoothingStyle::Exponential(_) => linear_converged(),
245            SmoothingStyle::Logarithmic(_) => {
246                if current > 0.0 && target > 0.0 {
247                    (current.ln() - target.ln()).abs() < 1e-6
248                } else {
249                    linear_converged()
250                }
251            }
252        }
253    }
254
255    /// Advance the smoother by `n_samples` samples in one call,
256    /// returning only the final value. Use for **block-rate**
257    /// consumers (hard gates, mode switches, anything that needs a
258    /// single smoothed value per audio block) where the intermediate
259    /// envelope from [`Self::next_block`] is wasted work.
260    ///
261    /// One atomic load and one atomic store regardless of
262    /// `n_samples`. For `Exponential`, uses the closed-form
263    /// `current + (target - current) * (1 - (1 - coeff)^N)` (one
264    /// `powf` per call) instead of looping; for `Linear`, loops
265    /// because the snap-when-close-enough check breaks any clean
266    /// closed form.
267    ///
268    /// Semantics match `next` step-for-step: equivalent to calling
269    /// `next(target)` `n_samples` times and returning the last
270    /// result, but without paying per-sample atomic costs.
271    // Smoother state stays in `[-1e10, 1e10]`; the f32 narrowing
272    // matches `next` / `next_block`.
273    #[allow(clippy::cast_possible_truncation)]
274    #[allow(clippy::cast_precision_loss)]
275    #[inline]
276    pub fn next_after(&self, target: f64, n_samples: usize) -> f32 {
277        if n_samples == 0 {
278            return self.current.load() as f32;
279        }
280        if let Some(v) = self.advance_guard(target) {
281            return v;
282        }
283
284        let mut current = self.current.load();
285        let coeff = self.coeff.load();
286
287        match self.style {
288            SmoothingStyle::None => {
289                current = target;
290            }
291            SmoothingStyle::Linear(_) => {
292                // Same per-step math as `next_block`: a constant increment
293                // (armed once, since the target is fixed across the call)
294                // added each sample, snapping on the final step. Looped
295                // because the snap check wrecks any closed-form derivation.
296                let threshold = (target.abs() * 1e-6).max(1e-8);
297                let step = self.arm_linear_ramp(target, current, coeff);
298                for _ in 0..n_samples {
299                    current = linear_advance(current, target, step, threshold);
300                }
301            }
302            SmoothingStyle::Exponential(_) => {
303                // Closed form: N iterations of `current += coeff *
304                // (target - current)` converge to
305                // `target + (current - target) * (1 - coeff)^N`.
306                let decay = (1.0 - coeff).powf(n_samples as f64);
307                current = target + (current - target) * decay;
308            }
309            SmoothingStyle::Logarithmic(_) => {
310                if current <= 0.0 || target <= 0.0 {
311                    current = target;
312                } else {
313                    // Closed form of the log-domain one-pole, mirroring
314                    // the `Exponential` arm above in log space.
315                    let decay = (1.0 - coeff).powf(n_samples as f64);
316                    let log_target = target.ln();
317                    current = (log_target + (current.ln() - log_target) * decay).exp();
318                }
319            }
320        }
321
322        self.current.store(current);
323        current as f32
324    }
325
326    /// Advance the smoother by `N` samples in one call, returning the
327    /// intermediate per-sample values as a stack-allocated array.
328    ///
329    /// Issues exactly **one** atomic load and **one** atomic store
330    /// against `current`, regardless of `N`. The inner stepping runs
331    /// in a register-resident loop the optimizer can unroll and (for
332    /// `Exponential` / `None`) vectorize. Compare with [`Self::next`]
333    /// which costs one load + one store *per sample* and therefore
334    /// forces the compiler to keep `current` in memory across
335    /// iterations.
336    ///
337    /// Semantics match `next` step-for-step: the i-th element of the
338    /// returned array is what `next(target)` would have produced if
339    /// called for the i-th time in sequence.
340    // Smoother state stays in `[-1e10, 1e10]`; the f32 narrowing
341    // matches the per-sample `next()` contract.
342    #[allow(clippy::cast_possible_truncation)]
343    #[inline]
344    pub fn next_block<const N: usize>(&self, target: f64) -> [f32; N] {
345        let mut out = [0.0_f32; N];
346        self.next_into(target, &mut out);
347        out
348    }
349
350    /// Advance the smoother by `out.len()` samples in one call,
351    /// writing each intermediate value to `out`. Slice-based variant
352    /// of [`Self::next_block`] - same single-atomic-pair amortization,
353    /// runtime length. Use this when the chunk size depends on
354    /// `process()`'s actual block (the common case for plugins
355    /// chunking the host's buffer into a `MAX_BLOCK` ladder); the
356    /// const-generic `next_block::<N>` always advances by `N` even
357    /// when the caller only consumes a shorter prefix.
358    #[allow(clippy::cast_possible_truncation)]
359    #[inline]
360    pub fn next_into(&self, target: f64, out: &mut [f32]) {
361        if let Some(v) = self.advance_guard(target) {
362            out.fill(v);
363            return;
364        }
365        let mut current = self.current.load();
366        let coeff = self.coeff.load();
367
368        match self.style {
369            SmoothingStyle::None => {
370                // Snap immediately; every output is `target`.
371                out.fill(target as f32);
372                current = target;
373            }
374            SmoothingStyle::Linear(_) => {
375                // Threshold matches `next()`'s per-step floor. Armed once
376                // (target is fixed across the block), then a constant
377                // increment per sample - a straight line, not the geometric
378                // decay a re-derived `diff * coeff` would trace.
379                let threshold = (target.abs() * 1e-6).max(1e-8);
380                let step = self.arm_linear_ramp(target, current, coeff);
381                for slot in out.iter_mut() {
382                    current = linear_advance(current, target, step, threshold);
383                    *slot = current as f32;
384                }
385            }
386            SmoothingStyle::Exponential(_) => {
387                // Standard one-pole exponential. `current` is a local
388                // (no atomic), so LLVM keeps it in a register and the
389                // body auto-vectorizes for large enough slices.
390                for slot in out.iter_mut() {
391                    current += coeff * (target - current);
392                    *slot = current as f32;
393                }
394            }
395            SmoothingStyle::Logarithmic(_) => {
396                if current <= 0.0 || target <= 0.0 {
397                    out.fill(target as f32);
398                    current = target;
399                } else {
400                    // Step the one-pole in log space, exponentiating each
401                    // sample back to the linear value the DSP consumes.
402                    let log_target = target.ln();
403                    let mut log_current = current.ln();
404                    for slot in out.iter_mut() {
405                        log_current += coeff * (log_target - log_current);
406                        current = log_current.exp();
407                        *slot = current as f32;
408                    }
409                }
410            }
411        }
412
413        self.current.store(current);
414    }
415}
416
417/// One `Linear` step: add the constant `step` toward `target`, landing
418/// exactly on `target` on the final step - once the remaining distance no
419/// longer exceeds one step - or once within the convergence `threshold`.
420/// The overshoot clamp is what terminates the ramp on `target` instead of
421/// stepping past it, and (with a constant `step`) is the guard the geometric
422/// version could never trip.
423#[inline]
424fn linear_advance(current: f64, target: f64, step: f64, threshold: f64) -> f64 {
425    let diff = target - current;
426    if diff.abs() < threshold || step.abs() >= diff.abs() {
427        target
428    } else {
429        current + step
430    }
431}
432
433/// Pure coefficient calculation: smoothing style + sample rate →
434/// per-sample step coefficient. Lifted out of `Smoother` so
435/// `set_sample_rate` can compute the new coefficient against its
436/// local `sr` argument without re-loading any shared state - the
437/// audio thread then sees a single atomic publish of `coeff`
438/// instead of a two-step (`sample_rate`, `coeff`) write.
439fn compute_coeff(style: SmoothingStyle, sr: f64) -> f64 {
440    match style {
441        SmoothingStyle::None => 1.0,
442        SmoothingStyle::Linear(ms) => {
443            let samples = (ms / 1000.0) * sr;
444            if samples > 1.0 { 1.0 / samples } else { 1.0 }
445        }
446        // Same one-pole coefficient as `Exponential`; `Logarithmic`
447        // applies it in the log domain (see `next`).
448        SmoothingStyle::Exponential(ms) | SmoothingStyle::Logarithmic(ms) => {
449            let samples = (ms / 1000.0) * sr;
450            if samples > 0.0 {
451                1.0 - (-1.0 / samples).exp()
452            } else {
453                1.0
454            }
455        }
456    }
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462
463    #[test]
464    fn is_converged_none_always_true() {
465        let s = Smoother::new(SmoothingStyle::None);
466        assert!(s.is_converged(0.0));
467        assert!(s.is_converged(42.0));
468        assert!(s.is_converged(-1e6));
469    }
470
471    #[test]
472    fn is_converged_linear_after_snap() {
473        let s = Smoother::new(SmoothingStyle::Linear(5.0));
474        s.snap(2.5);
475        assert!(s.is_converged(2.5));
476        assert!(!s.is_converged(2.6));
477    }
478
479    #[test]
480    fn is_converged_exponential_at_target() {
481        let s = Smoother::new(SmoothingStyle::Exponential(5.0));
482        s.snap(1.0);
483        assert!(s.is_converged(1.0));
484        // Step partway toward 2.0: still smoothing.
485        let _ = s.next(2.0);
486        assert!(!s.is_converged(2.0));
487    }
488
489    #[test]
490    fn is_converged_threshold_scales_with_magnitude() {
491        // Target near zero: floor at 1e-8.
492        let s = Smoother::new(SmoothingStyle::Linear(5.0));
493        s.snap(0.0);
494        assert!(s.is_converged(1e-9));
495        assert!(!s.is_converged(1e-7));
496
497        // Large target: threshold scales by 1e-6.
498        s.snap(20_000.0);
499        assert!(s.is_converged(20_000.01));
500        assert!(!s.is_converged(20_001.0));
501    }
502
503    #[test]
504    fn next_after_matches_next_block_exponential() {
505        // The closed-form path for Exponential should land on the
506        // same value the step-by-step `next_block` produces (within
507        // f32 rounding).
508        const N: usize = 512;
509        let stepwise = Smoother::new(SmoothingStyle::Exponential(20.0));
510        stepwise.set_sample_rate(48_000.0);
511        stepwise.snap(0.0);
512        let block = stepwise.next_block::<N>(1.0);
513
514        let closed = Smoother::new(SmoothingStyle::Exponential(20.0));
515        closed.set_sample_rate(48_000.0);
516        closed.snap(0.0);
517        let after = closed.next_after(1.0, N);
518
519        let diff = (block[N - 1] - after).abs();
520        assert!(
521            diff < 1e-6,
522            "block last = {}, after = {}",
523            block[N - 1],
524            after
525        );
526    }
527
528    #[test]
529    fn next_into_matches_next_block_prefix() {
530        // `next_into(&mut [_; n])` must produce the same per-sample
531        // sequence as `next_block::<N>` for `i < n`, and must advance
532        // the smoother by exactly `n` steps. Regression guard for the
533        // bug that motivated `next_into`: callers chunking the host
534        // buffer into a `MAX_BLOCK`-sized ladder were calling
535        // `next_block::<MAX_BLOCK>` and consuming only `n` samples,
536        // which silently advanced the smoother by `MAX_BLOCK` and
537        // stepped the value at the next block boundary.
538        const FULL: usize = 64;
539        const PARTIAL: usize = 17;
540
541        let reference = Smoother::new(SmoothingStyle::Exponential(20.0));
542        reference.set_sample_rate(48_000.0);
543        reference.snap(0.0);
544        let block = reference.next_block::<FULL>(1.0);
545
546        let mut buf = [0.0_f32; FULL];
547        let partial = Smoother::new(SmoothingStyle::Exponential(20.0));
548        partial.set_sample_rate(48_000.0);
549        partial.snap(0.0);
550        partial.next_into(1.0, &mut buf[..PARTIAL]);
551
552        for i in 0..PARTIAL {
553            let diff = (buf[i] - block[i]).abs();
554            assert!(diff < 1e-6, "i={i}, into={}, block={}", buf[i], block[i]);
555        }
556
557        // Next sample from `partial` must equal `block[PARTIAL]` —
558        // i.e. the smoother is positioned at sample PARTIAL, not at
559        // sample FULL.
560        let next = partial.next(1.0);
561        let diff = (next - block[PARTIAL]).abs();
562        assert!(diff < 1e-6, "next={next}, expected={}", block[PARTIAL]);
563    }
564
565    #[test]
566    fn next_after_matches_next_block_linear() {
567        const N: usize = 64;
568        let stepwise = Smoother::new(SmoothingStyle::Linear(5.0));
569        stepwise.set_sample_rate(48_000.0);
570        stepwise.snap(0.0);
571        let mut last = 0.0_f32;
572        for _ in 0..N {
573            last = stepwise.next(1.0);
574        }
575
576        let chunked = Smoother::new(SmoothingStyle::Linear(5.0));
577        chunked.set_sample_rate(48_000.0);
578        chunked.snap(0.0);
579        let after = chunked.next_after(1.0, N);
580
581        assert!(
582            (last - after).abs() < 1e-6,
583            "stepwise = {last}, after = {after}"
584        );
585    }
586
587    #[test]
588    fn linear_ramp_is_straight_and_settles_on_time() {
589        // 10 ms at 48 kHz = 480 samples. A true linear ramp 0 -> 1 traces
590        // a straight line (constant per-sample delta), passes 0.5 at the
591        // half-way sample, and lands on the target at ~480 samples - not
592        // the geometric one-pole the shrinking `diff * coeff` used to
593        // produce (midpoint ~0.63, and settling ~14x later).
594        let s = Smoother::new(SmoothingStyle::Linear(10.0));
595        s.set_sample_rate(48_000.0);
596        s.snap(0.0);
597
598        let vals: Vec<f64> = (0..480).map(|_| f64::from(s.next(1.0))).collect();
599
600        // Midpoint is linear (~0.5), decisively not the exponential ~0.63.
601        let mid = vals[239];
602        assert!(
603            (mid - 0.5).abs() < 0.01,
604            "midpoint {mid} should be ~0.5 (linear), not ~0.63 (exponential)"
605        );
606
607        // Every consecutive delta is the same constant ~1/480.
608        let expected = 1.0 / 480.0;
609        for w in vals.windows(2) {
610            let d = w[1] - w[0];
611            assert!(
612                (d - expected).abs() < 1e-4,
613                "step {d} not constant ~{expected}"
614            );
615        }
616
617        // Reaches the target by the declared time, and stays.
618        assert!(
619            (vals[479] - 1.0).abs() < 1e-3,
620            "should reach target by ~480 samples, got {}",
621            vals[479]
622        );
623    }
624
625    #[test]
626    fn linear_ramp_stays_straight_across_blocks() {
627        // Regression guard for the constant-step cache: two 100-sample
628        // blocks of a 480-sample ramp must continue the same straight
629        // line, not re-arm a fresh 480-sample ramp from each block's
630        // start value (which would bend the slope at the boundary and
631        // stretch the total time).
632        let s = Smoother::new(SmoothingStyle::Linear(10.0));
633        s.set_sample_rate(48_000.0);
634        s.snap(0.0);
635
636        let mut b1 = [0.0_f32; 100];
637        let mut b2 = [0.0_f32; 100];
638        s.next_into(1.0, &mut b1);
639        s.next_into(1.0, &mut b2);
640
641        // After 200 samples the value is ~200/480, i.e. the ramp kept
642        // going rather than restarting.
643        let after_200 = f64::from(b2[99]);
644        assert!(
645            (after_200 - 200.0 / 480.0).abs() < 1e-3,
646            "after 200 samples got {after_200}, expected {}",
647            200.0 / 480.0
648        );
649
650        // Slope is unchanged across the block boundary.
651        let last_b1 = f64::from(b1[99] - b1[98]);
652        let first_b2 = f64::from(b2[0] - b1[99]);
653        assert!(
654            (last_b1 - first_b2).abs() < 1e-4,
655            "slope changed at block boundary: {last_b1} vs {first_b2}"
656        );
657    }
658
659    #[test]
660    #[allow(clippy::float_cmp)]
661    fn next_after_zero_samples_is_no_op() {
662        // n=0 must return current value and leave state untouched.
663        // Float equality is the right check here: we want bit-exact
664        // identity, not "close enough".
665        let s = Smoother::new(SmoothingStyle::Exponential(5.0));
666        s.set_sample_rate(48_000.0);
667        s.snap(0.25);
668        let before = s.current();
669        let v = s.next_after(0.99, 0);
670        assert_eq!(v, before);
671        assert_eq!(s.current(), before);
672    }
673
674    #[test]
675    fn logarithmic_converges_multiplicatively() {
676        let s = Smoother::new(SmoothingStyle::Logarithmic(5.0));
677        s.set_sample_rate(48_000.0);
678        s.snap(100.0);
679        // Ramp toward 1 kHz; the value stays positive the whole way and
680        // converges to the target.
681        let mut last = 0.0_f32;
682        for _ in 0..4096 {
683            last = s.next(1000.0);
684            assert!(last > 0.0, "log smoothing must stay positive, got {last}");
685        }
686        assert!((last - 1000.0).abs() < 1.0, "did not converge: {last}");
687    }
688
689    #[test]
690    #[allow(clippy::float_cmp)]
691    fn logarithmic_snaps_on_nonpositive_endpoint() {
692        // A log ramp can't touch or cross zero, so a non-positive current
693        // or target snaps straight to the target.
694        let s = Smoother::new(SmoothingStyle::Logarithmic(5.0));
695        s.snap(-1.0);
696        assert_eq!(s.next(2.0), 2.0);
697        s.snap(1.0);
698        assert_eq!(s.next(0.0), 0.0);
699    }
700
701    #[test]
702    fn next_after_matches_next_block_logarithmic() {
703        const N: usize = 512;
704        let stepwise = Smoother::new(SmoothingStyle::Logarithmic(20.0));
705        stepwise.set_sample_rate(48_000.0);
706        stepwise.snap(100.0);
707        let block = stepwise.next_block::<N>(2000.0);
708
709        let closed = Smoother::new(SmoothingStyle::Logarithmic(20.0));
710        closed.set_sample_rate(48_000.0);
711        closed.snap(100.0);
712        let after = closed.next_after(2000.0, N);
713
714        assert!(
715            (block[N - 1] - after).abs() < 1.0,
716            "block last = {}, after = {after}",
717            block[N - 1]
718        );
719    }
720
721    #[test]
722    #[allow(clippy::float_cmp)]
723    fn next_after_none_snaps_immediately() {
724        let s = Smoother::new(SmoothingStyle::None);
725        s.snap(0.0);
726        let v = s.next_after(0.7, 1024);
727        assert_eq!(v, 0.7);
728        assert_eq!(s.current(), 0.7);
729    }
730
731    #[test]
732    fn next_self_heals_from_non_finite_current() {
733        // A NaN accumulator would latch forever without the recovery guard.
734        let s = Smoother::new(SmoothingStyle::Exponential(5.0));
735        s.snap(f64::NAN);
736        let first = s.next(0.5);
737        assert!(first.is_finite(), "recovers to a finite value");
738        // And keeps converging normally afterward.
739        for _ in 0..64 {
740            assert!(s.next(0.5).is_finite());
741        }
742        assert!((s.current() - 0.5).abs() < 1e-3);
743    }
744
745    /// The block-rate paths (`next_into`, `next_after`, and `next_block`
746    /// via `next_into`) share the same self-heal as `next` - a NaN in the
747    /// accumulator can't fill a whole block with NaN.
748    #[test]
749    fn block_paths_self_heal_from_non_finite_current() {
750        for style in [
751            SmoothingStyle::Exponential(5.0),
752            SmoothingStyle::Linear(5.0),
753            SmoothingStyle::Logarithmic(5.0),
754        ] {
755            let s = Smoother::new(style);
756            s.snap(f64::NAN);
757            let mut out = [0.0f32; 16];
758            s.next_into(0.5, &mut out);
759            assert!(out.iter().all(|v| v.is_finite()), "next_into: {style:?}");
760
761            let s = Smoother::new(style);
762            s.snap(f64::INFINITY);
763            assert!(s.next_after(0.5, 32).is_finite(), "next_after: {style:?}");
764        }
765    }
766
767    /// A non-finite *target* (an author calling the prelude-exported
768    /// `Smoother` with their own NaN) must not poison a healthy
769    /// accumulator: bail to the last good value, leave `current` finite.
770    #[test]
771    #[allow(clippy::float_cmp)]
772    fn non_finite_target_bails_without_poisoning() {
773        let s = Smoother::new(SmoothingStyle::Exponential(5.0));
774        s.snap(0.5);
775
776        assert_eq!(s.next(f64::NAN), 0.5, "next keeps the last value");
777        assert!(s.current().is_finite());
778
779        let mut out = [1.0f32; 8];
780        s.next_into(f64::NAN, &mut out);
781        assert!(out.iter().all(|&v| v == 0.5), "next_into fills last value");
782        assert!(s.current().is_finite());
783
784        assert_eq!(s.next_after(f64::INFINITY, 64), 0.5);
785        assert!(s.current().is_finite(), "accumulator stays finite");
786    }
787}