use super::smoother::Smoother;
const MAX_DELAY_S: f64 = 4.0;
const CROSSFADE_S: f64 = 0.020;
const SMOOTH_S: f64 = 0.020;
pub struct PingPong {
pub enabled: bool,
draining: bool,
sample_rate: f64,
buffer: Vec<f64>,
buf_frames: usize,
write_idx: usize,
current_delay: f64,
prev_delay: f64,
crossfade_remaining: usize,
crossfade_total: usize,
depth_target: f64,
depth_smooth: Smoother,
feedback_target: f64,
feedback_smooth: Smoother,
bpm: f64,
beat_value: f64,
}
impl PingPong {
pub fn new(sample_rate: f64) -> Self {
let buf_frames = (MAX_DELAY_S * 2.0 * sample_rate) as usize; let crossfade_total = (CROSSFADE_S * sample_rate) as usize;
let bpm = 120.0;
let beat_value = 1.0;
let initial_delay = (60.0 / bpm) * beat_value * sample_rate;
Self {
enabled: false,
draining: false,
sample_rate,
buffer: vec![0.0; buf_frames],
buf_frames,
write_idx: 0,
current_delay: initial_delay,
prev_delay: initial_delay,
crossfade_remaining: 0,
crossfade_total,
depth_target: 0.0,
depth_smooth: Smoother::new(sample_rate, SMOOTH_S),
feedback_target: 0.5,
feedback_smooth: Smoother::new(sample_rate, SMOOTH_S),
bpm,
beat_value,
}
}
pub fn set_enabled(&mut self, enabled: bool) {
match (self.enabled, enabled) {
(false, true) => {
self.buffer.fill(0.0);
self.write_idx = 0;
self.crossfade_remaining = 0;
self.draining = false;
self.enabled = true;
}
(true, false) => {
self.draining = true;
self.enabled = false;
}
_ => {}
}
}
pub fn set_bpm(&mut self, bpm: f64) {
if bpm > 0.0 && (bpm - self.bpm).abs() > 0.01 {
self.bpm = bpm;
self.retune();
}
}
pub fn set_beat_value(&mut self, beat_value: f64) {
if beat_value > 0.0 && (beat_value - self.beat_value).abs() > 1e-9 {
self.beat_value = beat_value;
self.retune();
}
}
pub fn set_depth(&mut self, depth: f64) {
self.depth_target = depth.clamp(0.0, 1.0);
}
pub fn set_feedback(&mut self, feedback: f64) {
self.feedback_target = feedback.clamp(0.0, 0.9);
}
fn retune(&mut self) {
let raw = (60.0 / self.bpm) * self.beat_value * self.sample_rate;
let clamped = raw.clamp(1.0, (self.buf_frames / 2 - 1) as f64);
if (clamped - self.current_delay).abs() < 1.0 {
return;
}
self.prev_delay = self.current_delay;
self.current_delay = clamped;
self.crossfade_remaining = self.crossfade_total;
}
pub fn process_sample(&mut self, sample: &mut [f64; 2]) {
if !self.enabled && !self.draining {
return;
}
let feedback = self.feedback_smooth.process(self.feedback_target);
let [left_wet, right_wet] = self.read_taps();
if self.draining {
self.depth_smooth.process(0.0);
let fb = (feedback * right_wet) * 0.8;
self.buffer[self.write_idx] = fb.tanh() / 0.8;
self.write_idx = (self.write_idx + 1) % self.buf_frames;
if left_wet.abs() < 1e-4 && right_wet.abs() < 1e-4 {
self.draining = false;
return;
}
sample[0] += left_wet;
sample[1] += right_wet;
return;
}
let depth = self.depth_smooth.process(self.depth_target);
let mono_in = (sample[0] + sample[1]) * 0.5;
let fb_in = (mono_in + feedback * right_wet) * 0.8;
self.buffer[self.write_idx] = fb_in.tanh() / 0.8;
self.write_idx = (self.write_idx + 1) % self.buf_frames;
let (dry_gain, wet_gain) = if depth < 0.5 {
(1.0, 2.0 * depth)
} else {
(2.0 * (1.0 - depth), 1.0)
};
sample[0] = sample[0] * dry_gain + left_wet * wet_gain;
sample[1] = sample[1] * dry_gain + right_wet * wet_gain;
}
fn read_taps(&mut self) -> [f64; 2] {
let left_cur = self.read_mono(self.current_delay);
let right_cur = self.read_mono(self.current_delay * 2.0);
if self.crossfade_remaining > 0 {
let left_prev = self.read_mono(self.prev_delay);
let right_prev = self.read_mono(self.prev_delay * 2.0);
let t = 1.0 - (self.crossfade_remaining as f64 / self.crossfade_total as f64);
self.crossfade_remaining -= 1;
[
left_prev * (1.0 - t) + left_cur * t,
right_prev * (1.0 - t) + right_cur * t,
]
} else {
[left_cur, right_cur]
}
}
fn read_mono(&self, delay_frames: f64) -> f64 {
let buf_len_f = self.buf_frames as f64;
let mut pos = self.write_idx as f64 - delay_frames;
while pos < 0.0 {
pos += buf_len_f;
}
let idx_lo = (pos.floor() as usize) % self.buf_frames;
let idx_hi = (idx_lo + 1) % self.buf_frames;
let frac = pos - pos.floor();
self.buffer[idx_lo] * (1.0 - frac) + self.buffer[idx_hi] * frac
}
}
#[cfg(test)]
mod tests {
use super::*;
const SR: f64 = 44100.0;
fn armed(beat_value: f64, feedback: f64) -> PingPong {
let mut p = PingPong::new(SR);
p.set_bpm(120.0);
p.set_beat_value(beat_value);
p.set_feedback(feedback);
p.set_depth(1.0);
p.set_enabled(true);
p
}
fn fill(pp: &mut PingPong, frames: usize, val: f64) {
for _ in 0..frames {
let mut s = [val, val];
pp.process_sample(&mut s);
}
}
fn beat_frames(bpm: f64, beat_value: f64, sr: f64) -> usize {
((60.0 / bpm) * beat_value * sr).ceil() as usize
}
#[test]
fn disabled_passes_through() {
let mut p = PingPong::new(SR);
p.set_depth(1.0);
let mut s = [0.7_f64, 0.3_f64];
p.process_sample(&mut s);
assert_eq!(s, [0.7, 0.3]);
}
#[test]
fn left_tap_fires_at_beat_value() {
let bv = 0.25;
let n = beat_frames(120.0, bv, SR);
let mut p = armed(bv, 0.0); fill(&mut p, n, 0.5);
let mut s = [0.0_f64, 0.0_f64];
p.process_sample(&mut s);
assert!(s[0].abs() > 0.1, "left tap should fire at beat_value; got {}", s[0]);
}
#[test]
fn right_tap_fires_at_double_beat_value() {
let bv = 0.25;
let n = beat_frames(120.0, bv, SR);
let mut p = armed(bv, 0.0);
fill(&mut p, 2 * n, 0.5);
let mut s = [0.0_f64, 0.0_f64];
p.process_sample(&mut s);
assert!(s[1].abs() > 0.1, "right tap should fire at 2×beat_value; got {}", s[1]);
}
#[test]
fn left_fires_before_right() {
let bv = 0.25;
let n = beat_frames(120.0, bv, SR);
let mut p = armed(bv, 0.0);
fill(&mut p, n, 0.5);
let mut s = [0.0_f64, 0.0_f64];
p.process_sample(&mut s);
let left_at_n = s[0];
let right_at_n = s[1];
assert!(left_at_n.abs() > 0.05, "left should fire at T; got {}", left_at_n);
assert!(right_at_n.abs() < left_at_n.abs(),
"right should not yet match left at T; left={left_at_n} right={right_at_n}");
}
#[test]
fn disarm_enters_drain() {
let bv = 0.25;
let n = beat_frames(120.0, bv, SR);
let mut p = armed(bv, 0.5);
fill(&mut p, n * 2, 0.5);
p.set_enabled(false);
assert!(!p.enabled);
assert!(p.draining, "disarm must enter drain mode");
}
#[test]
fn rearm_clears_buffer() {
let bv = 0.25;
let n = beat_frames(120.0, bv, SR);
let mut p = armed(bv, 0.5);
fill(&mut p, n * 3, 0.9);
p.set_enabled(false);
p.set_enabled(true);
let mut s = [0.0_f64, 0.0_f64];
p.process_sample(&mut s);
assert!(s[0].abs() < 1e-9 && s[1].abs() < 1e-9,
"buffer should be cleared on re-arm: {s:?}");
}
#[test]
fn dry_retains_stereo() {
let mut p = armed(1.0, 0.0);
p.set_depth(0.0);
let mut s = [0.8_f64, 0.2_f64];
p.process_sample(&mut s);
assert!((s[0] - 0.8).abs() < 1e-6, "dry L should be unchanged; got {}", s[0]);
assert!((s[1] - 0.2).abs() < 1e-6, "dry R should be unchanged; got {}", s[1]);
}
}