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 current = self.current.load();
220 let coeff = self.coeff.load();
221 let mut out = [0.0_f32; N];
222
223 match self.style {
224 SmoothingStyle::None => {
225 // Snap immediately; every output is `target`.
226 out.fill(target as f32);
227 current = target;
228 }
229 SmoothingStyle::Linear(_) => {
230 // Threshold matches `next()`'s per-step floor. Hoisted
231 // out of the loop because it depends only on `target`.
232 let threshold = (target.abs() * 1e-6).max(1e-8);
233 for slot in &mut out {
234 let diff = target - current;
235 if diff.abs() < threshold {
236 current = target;
237 } else {
238 let step = diff * coeff;
239 current = if step.abs() >= diff.abs() {
240 target
241 } else {
242 current + step
243 };
244 }
245 *slot = current as f32;
246 }
247 }
248 SmoothingStyle::Exponential(_) => {
249 // Standard one-pole exponential. `current` is a local
250 // (no atomic), so LLVM keeps it in a register and the
251 // body auto-vectorizes for large enough N.
252 for slot in &mut out {
253 current += coeff * (target - current);
254 *slot = current as f32;
255 }
256 }
257 }
258
259 self.current.store(current);
260 out
261 }
262}
263
264/// Pure coefficient calculation: smoothing style + sample rate →
265/// per-sample step coefficient. Lifted out of `Smoother` so
266/// `set_sample_rate` can compute the new coefficient against its
267/// local `sr` argument without re-loading any shared state - the
268/// audio thread then sees a single atomic publish of `coeff`
269/// instead of a two-step (`sample_rate`, `coeff`) write.
270fn compute_coeff(style: SmoothingStyle, sr: f64) -> f64 {
271 match style {
272 SmoothingStyle::None => 1.0,
273 SmoothingStyle::Linear(ms) => {
274 let samples = (ms / 1000.0) * sr;
275 if samples > 1.0 { 1.0 / samples } else { 1.0 }
276 }
277 SmoothingStyle::Exponential(ms) => {
278 let samples = (ms / 1000.0) * sr;
279 if samples > 0.0 {
280 1.0 - (-1.0 / samples).exp()
281 } else {
282 1.0
283 }
284 }
285 }
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291
292 #[test]
293 fn is_converged_none_always_true() {
294 let s = Smoother::new(SmoothingStyle::None);
295 assert!(s.is_converged(0.0));
296 assert!(s.is_converged(42.0));
297 assert!(s.is_converged(-1e6));
298 }
299
300 #[test]
301 fn is_converged_linear_after_snap() {
302 let s = Smoother::new(SmoothingStyle::Linear(5.0));
303 s.snap(2.5);
304 assert!(s.is_converged(2.5));
305 assert!(!s.is_converged(2.6));
306 }
307
308 #[test]
309 fn is_converged_exponential_at_target() {
310 let s = Smoother::new(SmoothingStyle::Exponential(5.0));
311 s.snap(1.0);
312 assert!(s.is_converged(1.0));
313 // Step partway toward 2.0: still smoothing.
314 let _ = s.next(2.0);
315 assert!(!s.is_converged(2.0));
316 }
317
318 #[test]
319 fn is_converged_threshold_scales_with_magnitude() {
320 // Target near zero: floor at 1e-8.
321 let s = Smoother::new(SmoothingStyle::Linear(5.0));
322 s.snap(0.0);
323 assert!(s.is_converged(1e-9));
324 assert!(!s.is_converged(1e-7));
325
326 // Large target: threshold scales by 1e-6.
327 s.snap(20_000.0);
328 assert!(s.is_converged(20_000.01));
329 assert!(!s.is_converged(20_001.0));
330 }
331
332 #[test]
333 fn next_after_matches_next_block_exponential() {
334 // The closed-form path for Exponential should land on the
335 // same value the step-by-step `next_block` produces (within
336 // f32 rounding).
337 const N: usize = 512;
338 let stepwise = Smoother::new(SmoothingStyle::Exponential(20.0));
339 stepwise.set_sample_rate(48_000.0);
340 stepwise.snap(0.0);
341 let block = stepwise.next_block::<N>(1.0);
342
343 let closed = Smoother::new(SmoothingStyle::Exponential(20.0));
344 closed.set_sample_rate(48_000.0);
345 closed.snap(0.0);
346 let after = closed.next_after(1.0, N);
347
348 let diff = (block[N - 1] - after).abs();
349 assert!(
350 diff < 1e-6,
351 "block last = {}, after = {}",
352 block[N - 1],
353 after
354 );
355 }
356
357 #[test]
358 fn next_after_matches_next_block_linear() {
359 const N: usize = 64;
360 let stepwise = Smoother::new(SmoothingStyle::Linear(5.0));
361 stepwise.set_sample_rate(48_000.0);
362 stepwise.snap(0.0);
363 let mut last = 0.0_f32;
364 for _ in 0..N {
365 last = stepwise.next(1.0);
366 }
367
368 let chunked = Smoother::new(SmoothingStyle::Linear(5.0));
369 chunked.set_sample_rate(48_000.0);
370 chunked.snap(0.0);
371 let after = chunked.next_after(1.0, N);
372
373 assert!(
374 (last - after).abs() < 1e-6,
375 "stepwise = {last}, after = {after}"
376 );
377 }
378
379 #[test]
380 #[allow(clippy::float_cmp)]
381 fn next_after_zero_samples_is_no_op() {
382 // n=0 must return current value and leave state untouched.
383 // Float equality is the right check here: we want bit-exact
384 // identity, not "close enough".
385 let s = Smoother::new(SmoothingStyle::Exponential(5.0));
386 s.set_sample_rate(48_000.0);
387 s.snap(0.25);
388 let before = s.current();
389 let v = s.next_after(0.99, 0);
390 assert_eq!(v, before);
391 assert_eq!(s.current(), before);
392 }
393
394 #[test]
395 #[allow(clippy::float_cmp)]
396 fn next_after_none_snaps_immediately() {
397 let s = Smoother::new(SmoothingStyle::None);
398 s.snap(0.0);
399 let v = s.next_after(0.7, 1024);
400 assert_eq!(v, 0.7);
401 assert_eq!(s.current(), 0.7);
402 }
403}