#![allow(dead_code)]
use std::f32::consts::PI;
#[derive(Debug, Clone)]
pub struct TapeHead {
pub delay_ms: f32,
pub feedback: f32,
pub level: f32,
pub pan: f32,
pub enabled: bool,
}
impl Default for TapeHead {
fn default() -> Self {
Self {
delay_ms: 300.0,
feedback: 0.4,
level: 1.0,
pan: 0.0,
enabled: true,
}
}
}
#[derive(Debug, Clone)]
pub struct TapeEchoConfig {
pub heads: Vec<TapeHead>,
pub saturation: f32,
pub wow_flutter: f32,
pub wow_rate_hz: f32,
pub hf_damping: f32,
pub mix: f32,
pub input_gain: f32,
pub sample_rate: f32,
}
impl Default for TapeEchoConfig {
fn default() -> Self {
Self {
heads: vec![TapeHead::default()],
saturation: 0.3,
wow_flutter: 0.1,
wow_rate_hz: 0.5,
hf_damping: 0.3,
mix: 0.5,
input_gain: 1.0,
sample_rate: 48000.0,
}
}
}
#[derive(Debug)]
struct DelayBuffer {
buffer: Vec<f32>,
write_pos: usize,
length: usize,
}
impl DelayBuffer {
fn new(max_samples: usize) -> Self {
Self {
buffer: vec![0.0; max_samples],
write_pos: 0,
length: max_samples,
}
}
fn write(&mut self, sample: f32) {
self.buffer[self.write_pos] = sample;
self.write_pos = (self.write_pos + 1) % self.length;
}
#[allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss
)]
fn read(&self, delay_samples: f32) -> f32 {
let delay_int = delay_samples as usize;
let frac = delay_samples - delay_int as f32;
let idx0 = (self.write_pos + self.length - delay_int - 1) % self.length;
let idx1 = (self.write_pos + self.length - delay_int - 2) % self.length;
self.buffer[idx0] * (1.0 - frac) + self.buffer[idx1] * frac
}
fn clear(&mut self) {
self.buffer.fill(0.0);
self.write_pos = 0;
}
}
#[derive(Debug)]
pub struct TapeEcho {
config: TapeEchoConfig,
delay_buf: DelayBuffer,
lp_state: f32,
wow_phase: f32,
wow_phase_inc: f32,
feedback_acc: f32,
}
impl TapeEcho {
#[allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss
)]
pub fn new(config: TapeEchoConfig) -> Self {
let max_delay_ms = config
.heads
.iter()
.map(|h| h.delay_ms)
.fold(0.0f32, f32::max)
+ 50.0; let max_samples = ((max_delay_ms / 1000.0) * config.sample_rate) as usize + 1;
let wow_phase_inc = config.wow_rate_hz / config.sample_rate;
Self {
delay_buf: DelayBuffer::new(max_samples.max(1)),
lp_state: 0.0,
wow_phase: 0.0,
wow_phase_inc,
feedback_acc: 0.0,
config,
}
}
#[must_use]
pub fn default_effect() -> Self {
Self::new(TapeEchoConfig::default())
}
pub fn reset(&mut self) {
self.delay_buf.clear();
self.lp_state = 0.0;
self.wow_phase = 0.0;
self.feedback_acc = 0.0;
}
pub fn set_mix(&mut self, mix: f32) {
self.config.mix = mix.clamp(0.0, 1.0);
}
pub fn set_saturation(&mut self, saturation: f32) {
self.config.saturation = saturation.clamp(0.0, 1.0);
}
pub fn set_wow_flutter(&mut self, depth: f32) {
self.config.wow_flutter = depth.clamp(0.0, 1.0);
}
pub fn set_hf_damping(&mut self, damping: f32) {
self.config.hf_damping = damping.clamp(0.0, 1.0);
}
fn saturate(sample: f32, amount: f32) -> f32 {
if amount < 0.001 {
return sample;
}
let drive = 1.0 + amount * 4.0;
let driven = sample * drive;
let x = driven.clamp(-3.0, 3.0);
let x2 = x * x;
let result = x * (27.0 + x2) / (27.0 + 9.0 * x2);
result / drive.sqrt()
}
fn apply_lp(&mut self, sample: f32) -> f32 {
let coeff = self.config.hf_damping * 0.7;
self.lp_state = self.lp_state + coeff * (sample - self.lp_state);
self.lp_state
}
#[allow(clippy::cast_precision_loss)]
fn wow_modulation(&self) -> f32 {
let max_mod_ms = self.config.wow_flutter * 5.0; let mod_samples = (max_mod_ms / 1000.0) * self.config.sample_rate;
(2.0 * PI * self.wow_phase).sin() * mod_samples
}
#[allow(clippy::cast_precision_loss)]
pub fn process_sample(&mut self, input: f32) -> f32 {
let input_gained = input * self.config.input_gain;
let write_val = Self::saturate(input_gained + self.feedback_acc, self.config.saturation);
self.delay_buf.write(write_val);
let mut wet = 0.0f32;
let mut feedback_sum = 0.0f32;
let wow_mod = self.wow_modulation();
for head in &self.config.heads {
if !head.enabled {
continue;
}
let delay_samples = (head.delay_ms / 1000.0) * self.config.sample_rate + wow_mod;
let delay_samples = delay_samples.max(1.0);
let max_delay = (self.delay_buf.length as f32) - 2.0;
let clamped_delay = delay_samples.min(max_delay);
let tap = self.delay_buf.read(clamped_delay);
wet += tap * head.level;
feedback_sum += tap * head.feedback;
}
if self.config.hf_damping > 0.001 {
feedback_sum = self.apply_lp(feedback_sum);
}
self.feedback_acc = feedback_sum.clamp(-2.0, 2.0);
self.wow_phase += self.wow_phase_inc;
if self.wow_phase >= 1.0 {
self.wow_phase -= 1.0;
}
input * (1.0 - self.config.mix) + wet * self.config.mix
}
pub fn process(&mut self, buffer: &mut [f32]) {
for sample in buffer.iter_mut() {
*sample = self.process_sample(*sample);
}
}
pub fn process_stereo(&mut self, left: &mut [f32], right: &mut [f32]) {
let len = left.len().min(right.len());
for i in 0..len {
let mono = (left[i] + right[i]) * 0.5;
let processed = self.process_sample(mono);
left[i] = left[i] * (1.0 - self.config.mix) + processed * self.config.mix;
right[i] = right[i] * (1.0 - self.config.mix) + processed * self.config.mix;
}
}
#[must_use]
pub fn active_head_count(&self) -> usize {
self.config.heads.iter().filter(|h| h.enabled).count()
}
pub fn max_delay_ms(&self) -> f32 {
self.config
.heads
.iter()
.filter(|h| h.enabled)
.map(|h| h.delay_ms)
.fold(0.0f32, f32::max)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_tape_head() {
let head = TapeHead::default();
assert!((head.delay_ms - 300.0).abs() < 1e-6);
assert!((head.feedback - 0.4).abs() < 1e-6);
assert!(head.enabled);
}
#[test]
fn test_default_config() {
let cfg = TapeEchoConfig::default();
assert_eq!(cfg.heads.len(), 1);
assert!((cfg.saturation - 0.3).abs() < 1e-6);
assert!((cfg.mix - 0.5).abs() < 1e-6);
}
#[test]
fn test_tape_echo_creation() {
let echo = TapeEcho::default_effect();
assert!(echo.delay_buf.length > 0);
assert!((echo.wow_phase - 0.0).abs() < 1e-9);
}
#[test]
fn test_silence_passthrough() {
let mut echo = TapeEcho::default_effect();
let mut buffer = vec![0.0f32; 256];
echo.process(&mut buffer);
for &s in &buffer {
assert!(s.abs() < 1e-6, "Echo of silence should be near-silence");
}
}
#[test]
fn test_dry_mix() {
let mut echo = TapeEcho::new(TapeEchoConfig {
mix: 0.0,
..Default::default()
});
let input = 0.5f32;
let output = echo.process_sample(input);
assert!((output - input).abs() < 1e-4);
}
#[test]
fn test_saturate_clean() {
let result = TapeEcho::saturate(0.5, 0.0);
assert!((result - 0.5).abs() < 1e-6);
}
#[test]
fn test_saturate_soft_clip() {
let result = TapeEcho::saturate(1.0, 1.0);
assert!(result < 5.0);
assert!(result > 0.0);
}
#[test]
fn test_delay_buffer_write_read() {
let mut buf = DelayBuffer::new(100);
buf.write(1.0);
let val = buf.read(0.0);
assert!((val - 1.0).abs() < 1e-6);
}
#[test]
fn test_delay_buffer_clear() {
let mut buf = DelayBuffer::new(100);
buf.write(1.0);
buf.clear();
let val = buf.read(0.0);
assert!(val.abs() < 1e-9);
}
#[test]
fn test_set_mix() {
let mut echo = TapeEcho::default_effect();
echo.set_mix(0.8);
assert!((echo.config.mix - 0.8).abs() < 1e-6);
echo.set_mix(2.0);
assert!((echo.config.mix - 1.0).abs() < 1e-6);
}
#[test]
fn test_set_saturation() {
let mut echo = TapeEcho::default_effect();
echo.set_saturation(0.7);
assert!((echo.config.saturation - 0.7).abs() < 1e-6);
}
#[test]
fn test_active_head_count() {
let echo = TapeEcho::new(TapeEchoConfig {
heads: vec![
TapeHead {
enabled: true,
..Default::default()
},
TapeHead {
enabled: false,
..Default::default()
},
TapeHead {
enabled: true,
..Default::default()
},
],
..Default::default()
});
assert_eq!(echo.active_head_count(), 2);
}
#[test]
fn test_max_delay_ms() {
let echo = TapeEcho::new(TapeEchoConfig {
heads: vec![
TapeHead {
delay_ms: 200.0,
..Default::default()
},
TapeHead {
delay_ms: 500.0,
..Default::default()
},
TapeHead {
delay_ms: 100.0,
enabled: false,
..Default::default()
},
],
..Default::default()
});
assert!((echo.max_delay_ms() - 500.0).abs() < 1e-6);
}
#[test]
fn test_process_stereo() {
let mut echo = TapeEcho::default_effect();
let mut left = vec![0.5f32; 64];
let mut right = vec![0.5f32; 64];
echo.process_stereo(&mut left, &mut right);
for &s in left.iter().chain(right.iter()) {
assert!(s.is_finite());
}
}
#[test]
fn test_reset() {
let mut echo = TapeEcho::default_effect();
let mut buffer = vec![1.0f32; 128];
echo.process(&mut buffer);
echo.reset();
assert!((echo.feedback_acc - 0.0).abs() < 1e-9);
assert!((echo.lp_state - 0.0).abs() < 1e-9);
}
}