#![allow(dead_code)]
#[derive(Debug, Clone)]
pub struct TapeSaturationConfig {
pub drive: f32,
pub mix: f32,
pub asymmetry: f32,
pub input_gain: f32,
pub output_gain: f32,
}
impl Default for TapeSaturationConfig {
fn default() -> Self {
Self {
drive: 0.5,
mix: 1.0,
asymmetry: 0.2,
input_gain: 1.0,
output_gain: 1.0,
}
}
}
pub struct TapeSaturation {
config: TapeSaturationConfig,
smooth_drive: f32,
smooth_coeff: f32,
sample_rate: f32,
}
impl TapeSaturation {
#[must_use]
pub fn new(drive: f32) -> Self {
let config = TapeSaturationConfig {
drive: drive.clamp(0.0, 1.0),
..TapeSaturationConfig::default()
};
Self::with_config(config, 48_000.0)
}
#[must_use]
pub fn with_config(config: TapeSaturationConfig, sample_rate: f32) -> Self {
let smooth_coeff = (-1.0_f32 / (0.010 * sample_rate.max(1.0))).exp();
let smooth_drive = config.drive;
Self {
config,
smooth_drive,
smooth_coeff,
sample_rate,
}
}
pub fn set_drive(&mut self, drive: f32) {
self.config.drive = drive.clamp(0.0, 1.0);
}
pub fn set_mix(&mut self, mix: f32) {
self.config.mix = mix.clamp(0.0, 1.0);
}
#[must_use]
pub fn drive(&self) -> f32 {
self.config.drive
}
#[must_use]
pub fn mix(&self) -> f32 {
self.config.mix
}
#[must_use]
#[inline]
pub fn saturate(x: f32, drive: f32, asymmetry: f32) -> f32 {
if drive < f32::EPSILON {
return x;
}
let k = 1.0 + drive * 8.0;
let driven = x * k;
let sat = driven.tanh();
let asym = sat + asymmetry * drive * 0.15 * sat * sat;
let norm = 1.0 + asymmetry * drive * 0.15;
asym / norm.max(f32::EPSILON)
}
pub fn process_sample(&mut self, input: f32) -> f32 {
self.smooth_drive =
self.smooth_drive * self.smooth_coeff + self.config.drive * (1.0 - self.smooth_coeff);
let x = input * self.config.input_gain;
let sat = Self::saturate(x, self.smooth_drive, self.config.asymmetry);
let wet = sat * self.config.output_gain;
let dry = input;
dry + self.config.mix * (wet - dry)
}
#[must_use]
pub fn process(&mut self, samples: &[f32]) -> Vec<f32> {
samples.iter().map(|&s| self.process_sample(s)).collect()
}
pub fn reset(&mut self) {
self.smooth_drive = self.config.drive;
}
pub fn set_sample_rate(&mut self, sample_rate: f32) {
self.sample_rate = sample_rate;
self.smooth_coeff = (-1.0_f32 / (0.010 * sample_rate.max(1.0))).exp();
}
}
impl crate::AudioEffect for TapeSaturation {
fn process_sample(&mut self, input: f32) -> f32 {
self.process_sample(input)
}
fn reset(&mut self) {
self.reset();
}
fn set_sample_rate(&mut self, sample_rate: f32) {
self.set_sample_rate(sample_rate);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_new_clamps_drive() {
let s = TapeSaturation::new(5.0);
assert!((s.drive() - 1.0).abs() < 1e-6);
let s2 = TapeSaturation::new(-0.3);
assert!((s2.drive() - 0.0).abs() < 1e-6);
}
#[test]
fn test_output_length_matches_input() {
let mut sat = TapeSaturation::new(0.5);
let input = vec![0.3_f32; 128];
let output = sat.process(&input);
assert_eq!(output.len(), 128);
}
#[test]
fn test_soft_clip_bounds_output() {
let mut sat = TapeSaturation::new(1.0);
let input: Vec<f32> = (-100..=100).map(|i| i as f32 * 0.1).collect();
let output = sat.process(&input);
for &s in &output {
assert!(s.is_finite(), "Output is not finite: {s}");
assert!(s.abs() <= 1.05, "Soft-clip exceeded 1.05: {s}");
}
}
#[test]
fn test_drive_zero_mix_one_is_identity() {
let mut sat = TapeSaturation::with_config(
TapeSaturationConfig {
drive: 0.0,
mix: 1.0,
asymmetry: 0.0,
input_gain: 1.0,
output_gain: 1.0,
},
48_000.0,
);
sat.smooth_drive = 0.0;
let input = vec![0.3_f32, -0.5, 0.7, 0.1, -0.9];
let output = sat.process(&input);
for (i, (&inp, &out)) in input.iter().zip(output.iter()).enumerate() {
assert!(
(inp - out).abs() < 1e-4,
"drive=0 should be identity at {i}: in={inp}, out={out}"
);
}
}
#[test]
fn test_mix_zero_passes_dry() {
let mut sat = TapeSaturation::with_config(
TapeSaturationConfig {
drive: 1.0,
mix: 0.0,
asymmetry: 0.0,
input_gain: 1.0,
output_gain: 1.0,
},
48_000.0,
);
let input = vec![0.4_f32, -0.3, 0.9];
let output = sat.process(&input);
for (i, (&inp, &out)) in input.iter().zip(output.iter()).enumerate() {
assert!(
(inp - out).abs() < 1e-5,
"mix=0 should pass dry signal at {i}: in={inp}, out={out}"
);
}
}
#[test]
fn test_saturate_static_function() {
assert!((TapeSaturation::saturate(0.0, 0.5, 0.2)).abs() < 1e-6);
let out = TapeSaturation::saturate(100.0, 1.0, 0.2);
assert!(
out.abs() <= 1.1,
"saturate(100, 1.0) should be ~1.0, got {out}"
);
}
#[test]
fn test_all_outputs_finite() {
let mut sat = TapeSaturation::new(0.8);
let input: Vec<f32> = (0..512).map(|i| (i as f32 * 0.05).sin()).collect();
let output = sat.process(&input);
for (i, &s) in output.iter().enumerate() {
assert!(s.is_finite(), "Sample {i} is not finite: {s}");
}
}
#[test]
fn test_reset_snaps_smoother() {
let mut sat = TapeSaturation::new(0.7);
sat.smooth_drive = 0.0;
sat.reset();
assert!(
(sat.smooth_drive - 0.7).abs() < 1e-6,
"reset should snap smooth_drive to target"
);
}
#[test]
fn test_set_mix_clamps() {
let mut sat = TapeSaturation::new(0.5);
sat.set_mix(5.0);
assert!((sat.mix() - 1.0).abs() < 1e-6);
sat.set_mix(-1.0);
assert!((sat.mix() - 0.0).abs() < 1e-6);
}
#[test]
fn test_audio_effect_trait() {
let mut sat = TapeSaturation::new(0.6);
let out = sat.process_sample(0.5);
assert!(out.is_finite());
}
#[test]
fn test_set_sample_rate() {
let mut sat = TapeSaturation::new(0.5);
sat.set_sample_rate(44_100.0);
assert!((sat.sample_rate - 44_100.0).abs() < 1.0);
}
}