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