use crate::types::AtomicF64;
#[derive(Clone, Copy, Debug)]
pub enum SmoothingStyle {
None,
Linear(f64),
Exponential(f64),
}
pub struct Smoother {
style: SmoothingStyle,
current: AtomicF64,
coeff: AtomicF64,
sample_rate: AtomicF64,
}
impl Smoother {
#[must_use]
pub fn new(style: SmoothingStyle) -> Self {
let coeff = compute_coeff(style, 44100.0);
Self {
style,
current: AtomicF64::new(0.0),
coeff: AtomicF64::new(coeff),
sample_rate: AtomicF64::new(44100.0),
}
}
pub fn set_sample_rate(&self, sr: f64) {
let new_coeff = compute_coeff(self.style, sr);
self.sample_rate.store(sr);
self.coeff.store(new_coeff);
}
pub fn snap(&self, value: f64) {
self.current.store(value);
}
#[allow(clippy::cast_possible_truncation)]
#[inline]
pub fn next(&self, target: f64) -> f32 {
let current = self.current.load();
let coeff = self.coeff.load();
let new_current = match self.style {
SmoothingStyle::None => target,
SmoothingStyle::Linear(_) => {
let diff = target - current;
let threshold = (target.abs() * 1e-6).max(1e-8);
if diff.abs() < threshold {
target
} else {
let step = diff * coeff;
if step.abs() >= diff.abs() {
target
} else {
current + step
}
}
}
SmoothingStyle::Exponential(_) => current + coeff * (target - current),
};
self.current.store(new_current);
new_current as f32
}
#[allow(clippy::cast_possible_truncation)]
#[inline]
pub fn current(&self) -> f32 {
self.current.load() as f32
}
#[inline]
#[must_use]
pub fn is_converged(&self, target: f64) -> bool {
match self.style {
SmoothingStyle::None => true,
SmoothingStyle::Linear(_) | SmoothingStyle::Exponential(_) => {
let current = self.current.load();
let threshold = (target.abs() * 1e-6).max(1e-8);
(target - current).abs() < threshold
}
}
}
#[allow(clippy::cast_possible_truncation)]
#[allow(clippy::cast_precision_loss)]
#[inline]
pub fn next_after(&self, target: f64, n_samples: usize) -> f32 {
if n_samples == 0 {
return self.current.load() as f32;
}
let mut current = self.current.load();
let coeff = self.coeff.load();
match self.style {
SmoothingStyle::None => {
current = target;
}
SmoothingStyle::Linear(_) => {
let threshold = (target.abs() * 1e-6).max(1e-8);
for _ in 0..n_samples {
let diff = target - current;
if diff.abs() < threshold {
current = target;
break;
}
let step = diff * coeff;
current = if step.abs() >= diff.abs() {
target
} else {
current + step
};
}
}
SmoothingStyle::Exponential(_) => {
let decay = (1.0 - coeff).powf(n_samples as f64);
current = target + (current - target) * decay;
}
}
self.current.store(current);
current as f32
}
#[allow(clippy::cast_possible_truncation)]
#[inline]
pub fn next_block<const N: usize>(&self, target: f64) -> [f32; N] {
let mut current = self.current.load();
let coeff = self.coeff.load();
let mut out = [0.0_f32; N];
match self.style {
SmoothingStyle::None => {
out.fill(target as f32);
current = target;
}
SmoothingStyle::Linear(_) => {
let threshold = (target.abs() * 1e-6).max(1e-8);
for slot in &mut out {
let diff = target - current;
if diff.abs() < threshold {
current = target;
} else {
let step = diff * coeff;
current = if step.abs() >= diff.abs() {
target
} else {
current + step
};
}
*slot = current as f32;
}
}
SmoothingStyle::Exponential(_) => {
for slot in &mut out {
current += coeff * (target - current);
*slot = current as f32;
}
}
}
self.current.store(current);
out
}
}
fn compute_coeff(style: SmoothingStyle, sr: f64) -> f64 {
match style {
SmoothingStyle::None => 1.0,
SmoothingStyle::Linear(ms) => {
let samples = (ms / 1000.0) * sr;
if samples > 1.0 { 1.0 / samples } else { 1.0 }
}
SmoothingStyle::Exponential(ms) => {
let samples = (ms / 1000.0) * sr;
if samples > 0.0 {
1.0 - (-1.0 / samples).exp()
} else {
1.0
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_converged_none_always_true() {
let s = Smoother::new(SmoothingStyle::None);
assert!(s.is_converged(0.0));
assert!(s.is_converged(42.0));
assert!(s.is_converged(-1e6));
}
#[test]
fn is_converged_linear_after_snap() {
let s = Smoother::new(SmoothingStyle::Linear(5.0));
s.snap(2.5);
assert!(s.is_converged(2.5));
assert!(!s.is_converged(2.6));
}
#[test]
fn is_converged_exponential_at_target() {
let s = Smoother::new(SmoothingStyle::Exponential(5.0));
s.snap(1.0);
assert!(s.is_converged(1.0));
let _ = s.next(2.0);
assert!(!s.is_converged(2.0));
}
#[test]
fn is_converged_threshold_scales_with_magnitude() {
let s = Smoother::new(SmoothingStyle::Linear(5.0));
s.snap(0.0);
assert!(s.is_converged(1e-9));
assert!(!s.is_converged(1e-7));
s.snap(20_000.0);
assert!(s.is_converged(20_000.01));
assert!(!s.is_converged(20_001.0));
}
#[test]
fn next_after_matches_next_block_exponential() {
const N: usize = 512;
let stepwise = Smoother::new(SmoothingStyle::Exponential(20.0));
stepwise.set_sample_rate(48_000.0);
stepwise.snap(0.0);
let block = stepwise.next_block::<N>(1.0);
let closed = Smoother::new(SmoothingStyle::Exponential(20.0));
closed.set_sample_rate(48_000.0);
closed.snap(0.0);
let after = closed.next_after(1.0, N);
let diff = (block[N - 1] - after).abs();
assert!(
diff < 1e-6,
"block last = {}, after = {}",
block[N - 1],
after
);
}
#[test]
fn next_after_matches_next_block_linear() {
const N: usize = 64;
let stepwise = Smoother::new(SmoothingStyle::Linear(5.0));
stepwise.set_sample_rate(48_000.0);
stepwise.snap(0.0);
let mut last = 0.0_f32;
for _ in 0..N {
last = stepwise.next(1.0);
}
let chunked = Smoother::new(SmoothingStyle::Linear(5.0));
chunked.set_sample_rate(48_000.0);
chunked.snap(0.0);
let after = chunked.next_after(1.0, N);
assert!(
(last - after).abs() < 1e-6,
"stepwise = {last}, after = {after}"
);
}
#[test]
#[allow(clippy::float_cmp)]
fn next_after_zero_samples_is_no_op() {
let s = Smoother::new(SmoothingStyle::Exponential(5.0));
s.set_sample_rate(48_000.0);
s.snap(0.25);
let before = s.current();
let v = s.next_after(0.99, 0);
assert_eq!(v, before);
assert_eq!(s.current(), before);
}
#[test]
#[allow(clippy::float_cmp)]
fn next_after_none_snaps_immediately() {
let s = Smoother::new(SmoothingStyle::None);
s.snap(0.0);
let v = s.next_after(0.7, 1024);
assert_eq!(v, 0.7);
assert_eq!(s.current(), 0.7);
}
}