use std::f32::consts::PI;
use crate::AudioEffect;
#[derive(Debug, Clone, PartialEq)]
pub struct TapeSatConfig {
pub drive: f32,
pub bias: f32,
pub thickness: f32,
pub coercivity: f32,
pub reversibility: f32,
pub output_gain: f32,
pub mix: f32,
pub oversampling: u8,
}
impl Default for TapeSatConfig {
fn default() -> Self {
Self {
drive: 0.6,
bias: 0.08,
thickness: 0.02,
coercivity: 0.15,
reversibility: 0.05,
output_gain: 0.9,
mix: 1.0,
oversampling: 2,
}
}
}
impl TapeSatConfig {
#[must_use]
pub fn studio() -> Self {
Self {
drive: 0.4,
bias: 0.1,
thickness: 0.01,
coercivity: 0.08,
reversibility: 0.1,
output_gain: 1.0,
mix: 1.0,
oversampling: 2,
}
}
#[must_use]
pub fn cassette() -> Self {
Self {
drive: 0.75,
bias: 0.06,
thickness: 0.04,
coercivity: 0.25,
reversibility: 0.03,
output_gain: 0.85,
mix: 1.0,
oversampling: 2,
}
}
#[must_use]
pub fn hot_reel() -> Self {
Self {
drive: 0.9,
bias: 0.05,
thickness: 0.08,
coercivity: 0.35,
reversibility: 0.02,
output_gain: 0.7,
mix: 1.0,
oversampling: 4,
}
}
}
#[derive(Debug, Clone)]
struct JaState {
m: f32,
h_prev: f32,
delta: f32,
}
impl JaState {
fn new() -> Self {
Self {
m: 0.0,
h_prev: 0.0,
delta: 1.0,
}
}
fn reset(&mut self) {
self.m = 0.0;
self.h_prev = 0.0;
self.delta = 1.0;
}
fn advance(&mut self, h: f32, ms: f32, a: f32, alpha: f32, k: f32, c: f32) -> f32 {
let dh = h - self.h_prev;
if dh.abs() > 1e-10 {
self.delta = dh.signum();
}
let he = h + alpha * self.m;
let man = if he.abs() < 1e-7 {
0.0
} else {
ms * (langevin(he / a))
};
let denom = k * self.delta - alpha * (man - self.m);
let dm_dh = if denom.abs() < 1e-12 {
0.0
} else {
(1.0 - c) * (man - self.m) / denom
};
let dm = dm_dh * dh;
let m_new = self.m + dm + c * (man - self.m) * dh.abs();
self.m = m_new.clamp(-ms * 1.05, ms * 1.05);
self.h_prev = h;
self.m
}
}
#[inline]
fn langevin(x: f32) -> f32 {
if x.abs() < 1e-4 {
return x / 3.0; }
if x > 30.0 {
return 1.0 - 1.0 / x;
}
if x < -30.0 {
return -1.0 - 1.0 / x;
}
let e2x = (2.0 * x).exp(); let coth = if (e2x - 1.0).abs() < 1e-10 {
x / 3.0 + 1.0 / x
} else {
(e2x + 1.0) / (e2x - 1.0)
};
coth - 1.0 / x
}
#[derive(Debug, Clone)]
struct LpFilter {
b0: f32,
b1: f32,
b2: f32,
a1: f32,
a2: f32,
x1: f32,
x2: f32,
y1: f32,
y2: f32,
}
impl LpFilter {
fn new(cutoff_hz: f32, sample_rate: f32) -> Self {
let w0 = 2.0 * PI * cutoff_hz / sample_rate;
let cos_w0 = w0.cos();
let sin_w0 = w0.sin();
let alpha = sin_w0 / (2.0 * (2.0f32).sqrt());
let b0 = (1.0 - cos_w0) / 2.0;
let b1 = 1.0 - cos_w0;
let b2 = (1.0 - cos_w0) / 2.0;
let a0 = 1.0 + alpha;
let a1 = -2.0 * cos_w0;
let a2 = 1.0 - alpha;
Self {
b0: b0 / a0,
b1: b1 / a0,
b2: b2 / a0,
a1: a1 / a0,
a2: a2 / a0,
x1: 0.0,
x2: 0.0,
y1: 0.0,
y2: 0.0,
}
}
fn process(&mut self, x: f32) -> f32 {
let y = self.b0 * x + self.b1 * self.x1 + self.b2 * self.x2
- self.a1 * self.y1
- self.a2 * self.y2;
self.x2 = self.x1;
self.x1 = x;
self.y2 = self.y1;
self.y1 = y;
y
}
fn reset(&mut self) {
self.x1 = 0.0;
self.x2 = 0.0;
self.y1 = 0.0;
self.y2 = 0.0;
}
}
#[derive(Debug)]
pub struct TapeSaturator {
config: TapeSatConfig,
sample_rate: f32,
ja: [JaState; 2],
pre_gain: f32,
aa_up: [LpFilter; 2],
aa_down: [LpFilter; 2],
os_factor: usize,
}
impl TapeSaturator {
#[must_use]
pub fn new(config: TapeSatConfig, sample_rate: f32) -> Self {
let os_factor = Self::validated_os(config.oversampling);
let os_rate = sample_rate * os_factor as f32;
let cutoff = sample_rate * 0.45;
let aa_up = [
LpFilter::new(cutoff, os_rate),
LpFilter::new(cutoff, os_rate),
];
let aa_down = [
LpFilter::new(cutoff, os_rate),
LpFilter::new(cutoff, os_rate),
];
let pre_gain = Self::compute_pre_gain(config.drive);
Self {
config,
sample_rate,
ja: [JaState::new(), JaState::new()],
pre_gain,
aa_up,
aa_down,
os_factor,
}
}
fn validated_os(os: u8) -> usize {
match os {
1 => 1,
2 | 3 => 2,
4..=7 => 4,
_ => 8,
}
}
fn compute_pre_gain(drive: f32) -> f32 {
let d = drive.clamp(0.0, 1.0);
0.5 * (16.0f32).powf(d)
}
fn ja_a(bias: f32) -> f32 {
(bias.clamp(1e-4, 1.0) * 0.5).max(0.01)
}
fn ja_alpha(thickness: f32) -> f32 {
thickness.clamp(0.0, 0.499)
}
fn ja_k(coercivity: f32) -> f32 {
coercivity.clamp(0.001, 1.0)
}
fn process_ja(&mut self, h: f32, channel: usize) -> f32 {
let ms = 1.0; let a = Self::ja_a(self.config.bias);
let alpha = Self::ja_alpha(self.config.thickness);
let k = Self::ja_k(self.config.coercivity);
let c = self.config.reversibility.clamp(0.0, 1.0);
self.ja[channel].advance(h, ms, a, alpha, k, c)
}
fn process_channel(&mut self, input: f32, channel: usize) -> f32 {
let h_base = input * self.pre_gain;
let os = self.os_factor;
let m = if os <= 1 {
self.process_ja(h_base, channel)
} else {
let mut last = 0.0f32;
for i in 0..os {
let stuffed = if i == 0 {
h_base * os as f32 } else {
0.0
};
let upsampled = self.aa_up[channel].process(stuffed);
last = self.process_ja(upsampled, channel);
last = self.aa_down[channel].process(last);
}
last
};
let wet = m * self.config.output_gain;
wet * self.config.mix + input * (1.0 - self.config.mix)
}
pub fn set_drive(&mut self, drive: f32) {
self.config.drive = drive.clamp(0.0, 1.0);
self.pre_gain = Self::compute_pre_gain(self.config.drive);
}
#[must_use]
pub fn drive(&self) -> f32 {
self.config.drive
}
pub fn set_bias(&mut self, bias: f32) {
self.config.bias = bias.clamp(1e-4, 1.0);
}
#[must_use]
pub fn bias(&self) -> f32 {
self.config.bias
}
pub fn set_thickness(&mut self, thickness: f32) {
self.config.thickness = thickness.clamp(0.0, 0.499);
}
#[must_use]
pub fn thickness(&self) -> f32 {
self.config.thickness
}
pub fn set_coercivity(&mut self, coercivity: f32) {
self.config.coercivity = coercivity.clamp(0.001, 1.0);
}
#[must_use]
pub fn coercivity(&self) -> f32 {
self.config.coercivity
}
pub fn set_reversibility(&mut self, c: f32) {
self.config.reversibility = c.clamp(0.0, 1.0);
}
#[must_use]
pub fn reversibility(&self) -> f32 {
self.config.reversibility
}
pub fn set_output_gain(&mut self, gain: f32) {
self.config.output_gain = gain.clamp(0.0, 4.0);
}
#[must_use]
pub fn output_gain(&self) -> f32 {
self.config.output_gain
}
pub fn set_mix(&mut self, mix: f32) {
self.config.mix = mix.clamp(0.0, 1.0);
}
#[must_use]
pub fn mix(&self) -> f32 {
self.config.mix
}
#[must_use]
pub fn sample_rate(&self) -> f32 {
self.sample_rate
}
}
impl AudioEffect for TapeSaturator {
fn process_sample(&mut self, input: f32) -> f32 {
self.process_channel(input, 0)
}
fn process_sample_stereo(&mut self, left: f32, right: f32) -> (f32, f32) {
let l = self.process_channel(left, 0);
let r = self.process_channel(right, 1);
(l, r)
}
fn reset(&mut self) {
self.ja[0].reset();
self.ja[1].reset();
self.aa_up[0].reset();
self.aa_up[1].reset();
self.aa_down[0].reset();
self.aa_down[1].reset();
}
fn set_sample_rate(&mut self, sample_rate: f32) {
self.sample_rate = sample_rate;
let os_factor = Self::validated_os(self.config.oversampling);
let os_rate = sample_rate * os_factor as f32;
let cutoff = sample_rate * 0.45;
self.aa_up = [
LpFilter::new(cutoff, os_rate),
LpFilter::new(cutoff, os_rate),
];
self.aa_down = [
LpFilter::new(cutoff, os_rate),
LpFilter::new(cutoff, os_rate),
];
self.os_factor = os_factor;
}
fn set_wet_dry(&mut self, wet: f32) {
self.set_mix(wet);
}
fn wet_dry(&self) -> f32 {
self.config.mix
}
}
#[cfg(test)]
mod tests {
use super::*;
const SR: f32 = 48_000.0;
fn default_tape() -> TapeSaturator {
TapeSaturator::new(TapeSatConfig::default(), SR)
}
#[test]
fn test_langevin_near_zero() {
let y = langevin(1e-8);
assert!(y.is_finite(), "Langevin(0) must be finite");
assert!(y.abs() < 0.01);
}
#[test]
fn test_langevin_large_positive() {
let y = langevin(100.0);
assert!(y.is_finite());
assert!((y - 1.0).abs() < 0.01, "langevin(large) should approach 1");
}
#[test]
fn test_langevin_large_negative() {
let pos = langevin(5.0);
let neg = langevin(-5.0);
assert!((pos + neg).abs() < 1e-5, "Langevin should be odd");
}
#[test]
fn test_langevin_small_positive() {
let x = 0.01f32;
let approx = x / 3.0;
let exact = langevin(x);
assert!((exact - approx).abs() < 1e-4);
}
#[test]
fn test_ja_state_zero_input_stays_near_zero() {
let mut state = JaState::new();
let m = state.advance(0.0, 1.0, 0.1, 0.02, 0.1, 0.05);
assert!(m.abs() < 0.01, "zero input → near-zero magnetisation");
}
#[test]
fn test_ja_state_positive_drive_positive_output() {
let mut state = JaState::new();
let mut last = 0.0f32;
for i in 0..50 {
let h = i as f32 * 0.02;
last = state.advance(h, 1.0, 0.08, 0.02, 0.15, 0.05);
}
assert!(last > 0.0, "positive drive → positive magnetisation");
}
#[test]
fn test_ja_state_bounded_magnetisation() {
let mut state = JaState::new();
for i in 0..200 {
let h = (i as f32 - 100.0) * 0.5;
let m = state.advance(h, 1.0, 0.08, 0.02, 0.15, 0.05);
assert!(m.abs() <= 1.1, "M should stay within ±1.05·Ms: {m}");
}
}
#[test]
fn test_ja_state_reset() {
let mut state = JaState::new();
for i in 0..100 {
state.advance(i as f32 * 0.01, 1.0, 0.08, 0.02, 0.15, 0.05);
}
state.reset();
assert_eq!(state.m, 0.0);
assert_eq!(state.h_prev, 0.0);
}
#[test]
fn test_default_config_fields() {
let cfg = TapeSatConfig::default();
assert!((cfg.drive - 0.6).abs() < 1e-5);
assert_eq!(cfg.oversampling, 2);
assert!((cfg.mix - 1.0).abs() < 1e-5);
}
#[test]
fn test_presets_are_finite() {
for cfg in [
TapeSatConfig::studio(),
TapeSatConfig::cassette(),
TapeSatConfig::hot_reel(),
] {
let mut tape = TapeSaturator::new(cfg, SR);
for i in 0..20 {
let x = (i as f32 - 10.0) * 0.1;
let y = tape.process_sample(x);
assert!(y.is_finite(), "preset output NaN at {x}");
}
}
}
#[test]
fn test_silence_input_gives_near_silence() {
let mut tape = default_tape();
let mut buf = vec![0.0f32; 512];
tape.process(&mut buf);
for &s in &buf {
assert!(s.abs() < 0.01, "silence should stay near-silent: {s}");
}
}
#[test]
fn test_all_outputs_finite() {
let mut tape = default_tape();
for i in -20..=20 {
let x = i as f32 * 0.05;
let y = tape.process_sample(x);
assert!(y.is_finite(), "output must be finite for input {x}: {y}");
}
}
#[test]
fn test_output_bounded_at_full_drive() {
let config = TapeSatConfig {
drive: 1.0,
output_gain: 1.0,
..TapeSatConfig::default()
};
let mut tape = TapeSaturator::new(config, SR);
for i in -50..=50 {
let x = i as f32 * 0.04;
let y = tape.process_sample(x);
assert!(
y.is_finite() && y.abs() < 5.0,
"large input should stay bounded: {y}"
);
}
}
#[test]
fn test_dry_passthrough_at_zero_mix() {
let config = TapeSatConfig {
mix: 0.0,
..TapeSatConfig::default()
};
let mut tape = TapeSaturator::new(config, SR);
for _ in 0..100 {
tape.process_sample(0.5);
}
tape.reset();
let y = tape.process_sample(0.5);
assert!((y - 0.5).abs() < 1e-5, "zero mix should pass through: {y}");
}
#[test]
fn test_hysteresis_memory_effect() {
let mut tape = default_tape();
let ascending: Vec<f32> = (0..50)
.map(|i| {
let x = i as f32 * 0.02;
tape.process_sample(x)
})
.collect();
let descending: Vec<f32> = (0..50)
.rev()
.map(|i| {
let x = i as f32 * 0.02;
tape.process_sample(x)
})
.collect();
let diffs: f32 = ascending
.iter()
.zip(descending.iter())
.map(|(a, d)| (a - d).abs())
.sum();
assert!(
diffs > 0.01,
"hysteresis must produce different up/down sweeps (total diff={diffs})"
);
}
#[test]
fn test_stereo_both_channels_processed() {
let mut tape = default_tape();
let mut left = vec![0.5f32; 64];
let mut right = vec![-0.5f32; 64];
tape.process_stereo(&mut left, &mut right);
assert!(left.iter().all(|s| s.is_finite()));
assert!(right.iter().all(|s| s.is_finite()));
let different = left
.iter()
.zip(right.iter())
.any(|(l, r)| (l - r).abs() > 1e-5);
assert!(
different,
"stereo channels should produce different outputs"
);
}
#[test]
fn test_reset_clears_state() {
let mut tape = default_tape();
for _ in 0..200 {
tape.process_sample(0.9);
}
tape.reset();
let y = tape.process_sample(0.0);
assert!(y.abs() < 0.01, "after reset, zero input → near zero: {y}");
}
#[test]
fn test_set_drive_clamped() {
let mut tape = default_tape();
tape.set_drive(2.0);
assert!((tape.drive() - 1.0).abs() < 1e-5);
tape.set_drive(-1.0);
assert!(tape.drive().abs() < 1e-5);
}
#[test]
fn test_set_mix_clamped() {
let mut tape = default_tape();
tape.set_mix(-0.5);
assert!(tape.mix().abs() < 1e-5);
tape.set_mix(1.5);
assert!((tape.mix() - 1.0).abs() < 1e-5);
}
#[test]
fn test_set_bias_clamped() {
let mut tape = default_tape();
tape.set_bias(0.0);
assert!(tape.bias() > 0.0); tape.set_bias(2.0);
assert!((tape.bias() - 1.0).abs() < 1e-5);
}
#[test]
fn test_set_thickness_clamped() {
let mut tape = default_tape();
tape.set_thickness(-0.1);
assert!(tape.thickness().abs() < 1e-5);
tape.set_thickness(1.0);
assert!(tape.thickness() < 0.5);
}
#[test]
fn test_set_coercivity_clamped() {
let mut tape = default_tape();
tape.set_coercivity(-1.0);
assert!(tape.coercivity() > 0.0);
tape.set_coercivity(2.0);
assert!((tape.coercivity() - 1.0).abs() < 1e-5);
}
#[test]
fn test_set_reversibility_clamped() {
let mut tape = default_tape();
tape.set_reversibility(-0.5);
assert!(tape.reversibility().abs() < 1e-5);
tape.set_reversibility(1.5);
assert!((tape.reversibility() - 1.0).abs() < 1e-5);
}
#[test]
fn test_set_output_gain() {
let mut tape = default_tape();
tape.set_output_gain(1.5);
assert!((tape.output_gain() - 1.5).abs() < 1e-5);
}
#[test]
fn test_wet_dry_trait_methods() {
let mut tape = default_tape();
tape.set_wet_dry(0.3);
assert!((tape.wet_dry() - 0.3).abs() < 1e-5);
}
#[test]
fn test_set_sample_rate() {
let mut tape = default_tape();
tape.set_sample_rate(96_000.0);
assert!((tape.sample_rate() - 96_000.0).abs() < 1e-3);
}
#[test]
fn test_oversampling_no_os() {
let config = TapeSatConfig {
oversampling: 1,
..TapeSatConfig::default()
};
let mut tape = TapeSaturator::new(config, SR);
for i in -10..=10 {
let y = tape.process_sample(i as f32 * 0.1);
assert!(y.is_finite());
}
}
#[test]
fn test_oversampling_4x() {
let config = TapeSatConfig {
oversampling: 4,
..TapeSatConfig::default()
};
let mut tape = TapeSaturator::new(config, SR);
for i in -10..=10 {
let y = tape.process_sample(i as f32 * 0.1);
assert!(y.is_finite());
}
}
#[test]
fn test_even_harmonic_asymmetry() {
let mut tape = default_tape();
let n = 1024usize;
let mut dc_acc = 0.0f32;
for i in 0..n {
let x = (2.0 * PI * i as f32 / n as f32).sin() * 0.8;
dc_acc += tape.process_sample(x);
}
let dc = (dc_acc / n as f32).abs();
assert!(dc.is_finite(), "DC component must be finite: {dc}");
}
}