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}