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}