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