use crate::types::AtomicF64;
#[derive(Clone, Copy, Debug)]
pub enum SmoothingStyle {
None,
Linear(f64),
Exponential(f64),
Logarithmic(f64),
}
pub struct Smoother {
style: SmoothingStyle,
current: AtomicF64,
coeff: AtomicF64,
sample_rate: AtomicF64,
ramp_step: AtomicF64,
ramp_target: 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),
ramp_step: AtomicF64::new(0.0),
ramp_target: AtomicF64::new(f64::NAN),
}
}
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);
self.ramp_target.store(f64::NAN);
}
pub fn snap(&self, value: f64) {
self.current.store(value);
self.ramp_target.store(f64::NAN);
}
#[inline]
fn arm_linear_ramp(&self, target: f64, current: f64, coeff: f64) -> f64 {
#[allow(clippy::float_cmp)]
if self.ramp_target.load() == target {
self.ramp_step.load()
} else {
let step = (target - current) * coeff;
self.ramp_step.store(step);
self.ramp_target.store(target);
step
}
}
#[inline]
#[allow(clippy::cast_possible_truncation)]
fn advance_guard(&self, target: f64) -> Option<f32> {
if !target.is_finite() {
return Some(self.current());
}
if !self.current.load().is_finite() {
self.snap(target);
return Some(target as f32);
}
None
}
#[allow(clippy::cast_possible_truncation)]
#[inline]
pub fn next(&self, target: f64) -> f32 {
if let Some(v) = self.advance_guard(target) {
return v;
}
let current = self.current.load();
let coeff = self.coeff.load();
let new_current = match self.style {
SmoothingStyle::None => target,
SmoothingStyle::Linear(_) => {
let threshold = (target.abs() * 1e-6).max(1e-8);
let step = self.arm_linear_ramp(target, current, coeff);
linear_advance(current, target, step, threshold)
}
SmoothingStyle::Exponential(_) => current + coeff * (target - current),
SmoothingStyle::Logarithmic(_) => {
if current <= 0.0 || target <= 0.0 {
target
} else {
(current.ln() + coeff * (target.ln() - current.ln())).exp()
}
}
};
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 {
let current = self.current.load();
let linear_converged = || {
let threshold = (target.abs() * 1e-6).max(1e-8);
(target - current).abs() < threshold
};
match self.style {
SmoothingStyle::None => true,
SmoothingStyle::Linear(_) | SmoothingStyle::Exponential(_) => linear_converged(),
SmoothingStyle::Logarithmic(_) => {
if current > 0.0 && target > 0.0 {
(current.ln() - target.ln()).abs() < 1e-6
} else {
linear_converged()
}
}
}
}
#[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;
}
if let Some(v) = self.advance_guard(target) {
return v;
}
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);
let step = self.arm_linear_ramp(target, current, coeff);
for _ in 0..n_samples {
current = linear_advance(current, target, step, threshold);
}
}
SmoothingStyle::Exponential(_) => {
let decay = (1.0 - coeff).powf(n_samples as f64);
current = target + (current - target) * decay;
}
SmoothingStyle::Logarithmic(_) => {
if current <= 0.0 || target <= 0.0 {
current = target;
} else {
let decay = (1.0 - coeff).powf(n_samples as f64);
let log_target = target.ln();
current = (log_target + (current.ln() - log_target) * decay).exp();
}
}
}
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 out = [0.0_f32; N];
self.next_into(target, &mut out);
out
}
#[allow(clippy::cast_possible_truncation)]
#[inline]
pub fn next_into(&self, target: f64, out: &mut [f32]) {
if let Some(v) = self.advance_guard(target) {
out.fill(v);
return;
}
let mut current = self.current.load();
let coeff = self.coeff.load();
match self.style {
SmoothingStyle::None => {
out.fill(target as f32);
current = target;
}
SmoothingStyle::Linear(_) => {
let threshold = (target.abs() * 1e-6).max(1e-8);
let step = self.arm_linear_ramp(target, current, coeff);
for slot in out.iter_mut() {
current = linear_advance(current, target, step, threshold);
*slot = current as f32;
}
}
SmoothingStyle::Exponential(_) => {
for slot in out.iter_mut() {
current += coeff * (target - current);
*slot = current as f32;
}
}
SmoothingStyle::Logarithmic(_) => {
if current <= 0.0 || target <= 0.0 {
out.fill(target as f32);
current = target;
} else {
let log_target = target.ln();
let mut log_current = current.ln();
for slot in out.iter_mut() {
log_current += coeff * (log_target - log_current);
current = log_current.exp();
*slot = current as f32;
}
}
}
}
self.current.store(current);
}
}
#[inline]
fn linear_advance(current: f64, target: f64, step: f64, threshold: f64) -> f64 {
let diff = target - current;
if diff.abs() < threshold || step.abs() >= diff.abs() {
target
} else {
current + step
}
}
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) | SmoothingStyle::Logarithmic(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_into_matches_next_block_prefix() {
const FULL: usize = 64;
const PARTIAL: usize = 17;
let reference = Smoother::new(SmoothingStyle::Exponential(20.0));
reference.set_sample_rate(48_000.0);
reference.snap(0.0);
let block = reference.next_block::<FULL>(1.0);
let mut buf = [0.0_f32; FULL];
let partial = Smoother::new(SmoothingStyle::Exponential(20.0));
partial.set_sample_rate(48_000.0);
partial.snap(0.0);
partial.next_into(1.0, &mut buf[..PARTIAL]);
for i in 0..PARTIAL {
let diff = (buf[i] - block[i]).abs();
assert!(diff < 1e-6, "i={i}, into={}, block={}", buf[i], block[i]);
}
let next = partial.next(1.0);
let diff = (next - block[PARTIAL]).abs();
assert!(diff < 1e-6, "next={next}, expected={}", block[PARTIAL]);
}
#[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]
fn linear_ramp_is_straight_and_settles_on_time() {
let s = Smoother::new(SmoothingStyle::Linear(10.0));
s.set_sample_rate(48_000.0);
s.snap(0.0);
let vals: Vec<f64> = (0..480).map(|_| f64::from(s.next(1.0))).collect();
let mid = vals[239];
assert!(
(mid - 0.5).abs() < 0.01,
"midpoint {mid} should be ~0.5 (linear), not ~0.63 (exponential)"
);
let expected = 1.0 / 480.0;
for w in vals.windows(2) {
let d = w[1] - w[0];
assert!(
(d - expected).abs() < 1e-4,
"step {d} not constant ~{expected}"
);
}
assert!(
(vals[479] - 1.0).abs() < 1e-3,
"should reach target by ~480 samples, got {}",
vals[479]
);
}
#[test]
fn linear_ramp_stays_straight_across_blocks() {
let s = Smoother::new(SmoothingStyle::Linear(10.0));
s.set_sample_rate(48_000.0);
s.snap(0.0);
let mut b1 = [0.0_f32; 100];
let mut b2 = [0.0_f32; 100];
s.next_into(1.0, &mut b1);
s.next_into(1.0, &mut b2);
let after_200 = f64::from(b2[99]);
assert!(
(after_200 - 200.0 / 480.0).abs() < 1e-3,
"after 200 samples got {after_200}, expected {}",
200.0 / 480.0
);
let last_b1 = f64::from(b1[99] - b1[98]);
let first_b2 = f64::from(b2[0] - b1[99]);
assert!(
(last_b1 - first_b2).abs() < 1e-4,
"slope changed at block boundary: {last_b1} vs {first_b2}"
);
}
#[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]
fn logarithmic_converges_multiplicatively() {
let s = Smoother::new(SmoothingStyle::Logarithmic(5.0));
s.set_sample_rate(48_000.0);
s.snap(100.0);
let mut last = 0.0_f32;
for _ in 0..4096 {
last = s.next(1000.0);
assert!(last > 0.0, "log smoothing must stay positive, got {last}");
}
assert!((last - 1000.0).abs() < 1.0, "did not converge: {last}");
}
#[test]
#[allow(clippy::float_cmp)]
fn logarithmic_snaps_on_nonpositive_endpoint() {
let s = Smoother::new(SmoothingStyle::Logarithmic(5.0));
s.snap(-1.0);
assert_eq!(s.next(2.0), 2.0);
s.snap(1.0);
assert_eq!(s.next(0.0), 0.0);
}
#[test]
fn next_after_matches_next_block_logarithmic() {
const N: usize = 512;
let stepwise = Smoother::new(SmoothingStyle::Logarithmic(20.0));
stepwise.set_sample_rate(48_000.0);
stepwise.snap(100.0);
let block = stepwise.next_block::<N>(2000.0);
let closed = Smoother::new(SmoothingStyle::Logarithmic(20.0));
closed.set_sample_rate(48_000.0);
closed.snap(100.0);
let after = closed.next_after(2000.0, N);
assert!(
(block[N - 1] - after).abs() < 1.0,
"block last = {}, after = {after}",
block[N - 1]
);
}
#[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);
}
#[test]
fn next_self_heals_from_non_finite_current() {
let s = Smoother::new(SmoothingStyle::Exponential(5.0));
s.snap(f64::NAN);
let first = s.next(0.5);
assert!(first.is_finite(), "recovers to a finite value");
for _ in 0..64 {
assert!(s.next(0.5).is_finite());
}
assert!((s.current() - 0.5).abs() < 1e-3);
}
#[test]
fn block_paths_self_heal_from_non_finite_current() {
for style in [
SmoothingStyle::Exponential(5.0),
SmoothingStyle::Linear(5.0),
SmoothingStyle::Logarithmic(5.0),
] {
let s = Smoother::new(style);
s.snap(f64::NAN);
let mut out = [0.0f32; 16];
s.next_into(0.5, &mut out);
assert!(out.iter().all(|v| v.is_finite()), "next_into: {style:?}");
let s = Smoother::new(style);
s.snap(f64::INFINITY);
assert!(s.next_after(0.5, 32).is_finite(), "next_after: {style:?}");
}
}
#[test]
#[allow(clippy::float_cmp)]
fn non_finite_target_bails_without_poisoning() {
let s = Smoother::new(SmoothingStyle::Exponential(5.0));
s.snap(0.5);
assert_eq!(s.next(f64::NAN), 0.5, "next keeps the last value");
assert!(s.current().is_finite());
let mut out = [1.0f32; 8];
s.next_into(f64::NAN, &mut out);
assert!(out.iter().all(|&v| v == 0.5), "next_into fills last value");
assert!(s.current().is_finite());
assert_eq!(s.next_after(f64::INFINITY, 64), 0.5);
assert!(s.current().is_finite(), "accumulator stays finite");
}
}