beamer_core/smoothing.rs
1//! Parameter smoothing for avoiding zipper noise during automation.
2//!
3//! This module provides [`Smoother`] for interpolating parameter values over time,
4//! and [`SmoothingStyle`] for selecting the interpolation algorithm.
5//!
6//! # Usage
7//!
8//! Smoothers are typically used via [`FloatParam::with_smoother()`](crate::FloatParam::with_smoother),
9//! but can also be used standalone for custom modulation.
10//!
11//! ```ignore
12//! // Via FloatParam (recommended)
13//! let gain = FloatParam::db("Gain", 0.0, -60.0..=12.0)
14//! .with_smoother(SmoothingStyle::Exponential(5.0)); // 5ms
15//!
16//! // Standalone usage
17//! let mut smoother = Smoother::new(SmoothingStyle::Linear(10.0));
18//! smoother.set_sample_rate(44100.0);
19//! smoother.reset(1.0);
20//! smoother.set_target(0.5);
21//! let value = smoother.next(); // Per-sample
22//! ```
23//!
24//! # Thread Safety
25//!
26//! `Smoother` requires `&mut self` for advancing state and is intended for
27//! single-threaded audio processing only. The parent `FloatParam` uses atomic
28//! storage for thread-safe parameter access from UI/host threads.
29
30/// Threshold for snapping to target value to avoid denormals and finish smoothing.
31const SNAP_THRESHOLD: f64 = 1e-8;
32
33/// Smoothing algorithm selection.
34///
35/// The `f32` parameter is the smoothing time in milliseconds.
36#[derive(Debug, Clone, Copy, PartialEq)]
37pub enum SmoothingStyle {
38 /// No smoothing - value changes instantly.
39 None,
40
41 /// Linear interpolation over specified milliseconds.
42 /// Reaches target exactly after the specified time.
43 /// Good for: general purpose, predictable behavior.
44 Linear(f32),
45
46 /// Exponential (one-pole IIR) smoothing.
47 /// Fast initial response, asymptotically approaches target.
48 /// Reaches ~63% of target in the specified time (time constant).
49 /// Good for: most musical parameters, can cross zero.
50 Exponential(f32),
51
52 /// Logarithmic smoothing for frequency and other positive-only values.
53 /// Slow start, accelerating curve.
54 /// CANNOT cross zero or handle negative values - use Exponential for dB parameters.
55 /// Good for: filter frequencies (Hz), other always-positive parameters.
56 Logarithmic(f32),
57}
58
59impl Default for SmoothingStyle {
60 fn default() -> Self {
61 Self::None
62 }
63}
64
65/// A parameter value smoother.
66///
67/// Can be used standalone for custom modulation, or integrated
68/// into [`FloatParam`](crate::FloatParam) via `.with_smoother()`.
69///
70/// # Thread Safety
71///
72/// `Smoother` is `Send` but not `Sync` - it requires `&mut self` for
73/// advancing state. This is intentional for audio thread usage.
74#[derive(Debug, Clone)]
75pub struct Smoother {
76 style: SmoothingStyle,
77 sample_rate: f32,
78
79 // Current state
80 current: f64,
81 target: f64,
82
83 // Precomputed coefficients (style-dependent)
84 coefficient: f64, // For exponential: pole coefficient
85 step_size: f64, // For linear: increment per sample
86 steps_remaining: u32, // For linear: samples until target reached
87}
88
89impl Smoother {
90 /// Create a new smoother with the given style.
91 ///
92 /// Sample rate must be set before use via [`set_sample_rate()`](Self::set_sample_rate).
93 pub fn new(style: SmoothingStyle) -> Self {
94 Self {
95 style,
96 sample_rate: 0.0,
97 current: 0.0,
98 target: 0.0,
99 coefficient: 0.0,
100 step_size: 0.0,
101 steps_remaining: 0,
102 }
103 }
104
105 /// Create a smoother with no smoothing (pass-through).
106 pub fn none() -> Self {
107 Self::new(SmoothingStyle::None)
108 }
109
110 /// Get the smoothing style.
111 pub fn style(&self) -> SmoothingStyle {
112 self.style
113 }
114
115 /// Set the sample rate.
116 ///
117 /// Call this from `AudioProcessor::setup()`. Recomputes coefficients
118 /// based on time constants.
119 pub fn set_sample_rate(&mut self, sample_rate: f64) {
120 self.sample_rate = sample_rate as f32;
121 self.recompute_coefficients();
122 }
123
124 /// Set a new target value.
125 ///
126 /// Call this when the parameter value changes (typically at start of process block).
127 pub fn set_target(&mut self, target: f64) {
128 if (self.target - target).abs() < 1e-10 {
129 return;
130 }
131 self.target = target;
132
133 match self.style {
134 SmoothingStyle::None => {
135 self.current = target;
136 }
137 SmoothingStyle::Linear(ms) => {
138 let samples = (ms * self.sample_rate / 1000.0) as u32;
139 self.steps_remaining = samples.max(1);
140 self.step_size = (target - self.current) / self.steps_remaining as f64;
141 }
142 SmoothingStyle::Exponential(_) | SmoothingStyle::Logarithmic(_) => {
143 // Coefficient already computed, just update target
144 }
145 }
146 }
147
148 /// Reset immediately to a value (no smoothing).
149 ///
150 /// Use when loading state or initializing to avoid ramps.
151 pub fn reset(&mut self, value: f64) {
152 self.current = value;
153 self.target = value;
154 self.steps_remaining = 0;
155 self.step_size = 0.0;
156 }
157
158 /// Get the next smoothed value (per-sample).
159 ///
160 /// Call this once per sample in the audio loop.
161 #[inline]
162 pub fn next(&mut self) -> f64 {
163 match self.style {
164 SmoothingStyle::None => self.target,
165 SmoothingStyle::Linear(_) => {
166 if self.steps_remaining > 0 {
167 self.current += self.step_size;
168 self.steps_remaining -= 1;
169 if self.steps_remaining == 0 {
170 self.current = self.target;
171 }
172 }
173 self.current
174 }
175 SmoothingStyle::Exponential(_) => {
176 // One-pole: y[n] = y[n-1] + coef * (target - y[n-1])
177 self.current += self.coefficient * (self.target - self.current);
178
179 // Snap when very close (avoid denormals, finish smoothing)
180 if (self.current - self.target).abs() < SNAP_THRESHOLD {
181 self.current = self.target;
182 }
183 self.current
184 }
185 SmoothingStyle::Logarithmic(_) => {
186 // Similar to exponential but in log domain
187 // Only works for positive values
188 if self.target > 0.0 && self.current > 0.0 {
189 let log_current = self.current.ln();
190 let log_target = self.target.ln();
191 let log_next = log_current + self.coefficient * (log_target - log_current);
192 self.current = log_next.exp();
193
194 if (self.current - self.target).abs() < SNAP_THRESHOLD {
195 self.current = self.target;
196 }
197 } else {
198 self.current = self.target;
199 }
200 self.current
201 }
202 }
203 }
204
205 /// Get current smoothed value without advancing.
206 #[inline]
207 pub fn current(&self) -> f64 {
208 match self.style {
209 SmoothingStyle::None => self.target,
210 _ => self.current,
211 }
212 }
213
214 /// Get the target value.
215 #[inline]
216 pub fn target(&self) -> f64 {
217 self.target
218 }
219
220 /// Skip forward by n samples (for block processing).
221 ///
222 /// This is equivalent to calling `next()` n times but may be optimized
223 /// for some smoothing styles.
224 pub fn skip(&mut self, samples: usize) {
225 match self.style {
226 SmoothingStyle::None => {}
227 SmoothingStyle::Linear(_) => {
228 let skip_count = (samples as u32).min(self.steps_remaining);
229 if skip_count > 0 {
230 self.current += self.step_size * skip_count as f64;
231 self.steps_remaining -= skip_count;
232 if self.steps_remaining == 0 {
233 self.current = self.target;
234 }
235 }
236 }
237 SmoothingStyle::Exponential(_) => {
238 // Closed-form solution: after n samples of one-pole filter
239 // current = target + (current - target) * (1 - coef)^n
240 let decay = (1.0 - self.coefficient).powi(samples as i32);
241 self.current = self.target + (self.current - self.target) * decay;
242
243 if (self.current - self.target).abs() < SNAP_THRESHOLD {
244 self.current = self.target;
245 }
246 }
247 SmoothingStyle::Logarithmic(_) => {
248 // Closed-form in log domain (only for positive values)
249 if self.target > 0.0 && self.current > 0.0 {
250 let log_current = self.current.ln();
251 let log_target = self.target.ln();
252 let decay = (1.0 - self.coefficient).powi(samples as i32);
253 let log_result = log_target + (log_current - log_target) * decay;
254 self.current = log_result.exp();
255
256 if (self.current - self.target).abs() < SNAP_THRESHOLD {
257 self.current = self.target;
258 }
259 } else {
260 self.current = self.target;
261 }
262 }
263 }
264 }
265
266 /// Fill a slice with smoothed values (f64).
267 pub fn fill(&mut self, buffer: &mut [f64]) {
268 for sample in buffer.iter_mut() {
269 *sample = self.next();
270 }
271 }
272
273 /// Fill a slice with smoothed values (f32).
274 pub fn fill_f32(&mut self, buffer: &mut [f32]) {
275 for sample in buffer.iter_mut() {
276 *sample = self.next() as f32;
277 }
278 }
279
280 /// Returns true if still smoothing toward target.
281 #[inline]
282 pub fn is_smoothing(&self) -> bool {
283 match self.style {
284 SmoothingStyle::None => false,
285 SmoothingStyle::Linear(_) => self.steps_remaining > 0,
286 SmoothingStyle::Exponential(_) | SmoothingStyle::Logarithmic(_) => {
287 (self.current - self.target).abs() > SNAP_THRESHOLD
288 }
289 }
290 }
291
292 fn recompute_coefficients(&mut self) {
293 if self.sample_rate <= 0.0 {
294 return;
295 }
296
297 match self.style {
298 SmoothingStyle::None => {}
299 SmoothingStyle::Linear(_) => {
300 // Coefficients computed per set_target()
301 }
302 SmoothingStyle::Exponential(ms) | SmoothingStyle::Logarithmic(ms) => {
303 // One-pole coefficient: reaches ~63% in `ms` milliseconds
304 // coef = 1 - e^(-1 / (tau * sr))
305 // where tau = ms / 1000
306 let tau = ms as f64 / 1000.0;
307 let samples_per_tau = tau * self.sample_rate as f64;
308 if samples_per_tau > 0.0 {
309 self.coefficient = 1.0 - (-1.0 / samples_per_tau).exp();
310 } else {
311 self.coefficient = 1.0; // Instant
312 }
313 }
314 }
315 }
316}
317
318impl Default for Smoother {
319 fn default() -> Self {
320 Self::none()
321 }
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327
328 #[test]
329 fn test_no_smoothing() {
330 let mut s = Smoother::new(SmoothingStyle::None);
331 s.set_sample_rate(44100.0);
332 s.reset(0.0);
333 s.set_target(1.0);
334 assert!((s.next() - 1.0).abs() < 1e-10);
335 assert!(!s.is_smoothing());
336 }
337
338 #[test]
339 fn test_linear_reaches_target() {
340 let mut s = Smoother::new(SmoothingStyle::Linear(10.0)); // 10ms
341 s.set_sample_rate(1000.0); // 1 sample per ms
342 s.reset(0.0);
343 s.set_target(1.0);
344
345 // Should take 10 samples to reach target
346 for _ in 0..10 {
347 s.next();
348 }
349 assert!((s.current() - 1.0).abs() < 1e-10);
350 assert!(!s.is_smoothing());
351 }
352
353 #[test]
354 fn test_exponential_approaches_target() {
355 let mut s = Smoother::new(SmoothingStyle::Exponential(5.0)); // 5ms time constant
356 s.set_sample_rate(44100.0);
357 s.reset(0.0);
358 s.set_target(1.0);
359
360 // After many samples, should be very close to target
361 for _ in 0..10000 {
362 s.next();
363 }
364 assert!((s.current() - 1.0).abs() < 1e-6);
365 }
366
367 #[test]
368 fn test_skip_linear() {
369 let mut s = Smoother::new(SmoothingStyle::Linear(10.0));
370 s.set_sample_rate(1000.0);
371 s.reset(0.0);
372 s.set_target(1.0);
373
374 s.skip(5);
375 assert!((s.current() - 0.5).abs() < 1e-10);
376
377 s.skip(5);
378 assert!((s.current() - 1.0).abs() < 1e-10);
379 }
380
381 #[test]
382 fn test_fill_f32() {
383 let mut s = Smoother::new(SmoothingStyle::Linear(10.0));
384 s.set_sample_rate(1000.0);
385 s.reset(0.0);
386 s.set_target(1.0);
387
388 let mut buffer = [0.0f32; 10];
389 s.fill_f32(&mut buffer);
390
391 // First value should be ~0.1, last should be 1.0
392 assert!(buffer[0] > 0.0);
393 assert!((buffer[9] - 1.0).abs() < 1e-5);
394 }
395}