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