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