Skip to main content

aether_nodes/
karplus_strong.rs

1//! Karplus-Strong plucked string synthesis.
2//!
3//! Physically accurate plucked string model. A Krar played through this
4//! with the right tuning table sounds more like a Krar than any sample.
5//!
6//! Param layout:
7//!   0 = frequency   (Hz, 20 – 4000)
8//!   1 = decay       (0.9 – 0.9999, higher = longer sustain)
9//!   2 = brightness  (0.0 – 1.0, high-frequency content of excitation)
10//!   3 = trigger     (0→1 edge = pluck the string)
11
12use aether_core::{node::DspNode, param::ParamBlock, state::StateBlob, BUFFER_SIZE, MAX_INPUTS};
13
14const MAX_DELAY: usize = 4096; // supports down to ~12 Hz at 48kHz
15
16pub struct KarplusStrong {
17    delay_line: Box<[f32; MAX_DELAY]>,
18    write_pos: usize,
19    delay_len: usize,
20    prev_trigger: f32,
21    noise_seed: u32,
22}
23
24impl KarplusStrong {
25    pub fn new() -> Self {
26        Self {
27            delay_line: Box::new([0.0; MAX_DELAY]),
28            write_pos: 0,
29            delay_len: 100,
30            prev_trigger: 0.0,
31            noise_seed: 12345,
32        }
33    }
34
35    /// Excite the string with a burst of filtered noise.
36    fn pluck(&mut self, brightness: f32) {
37        for i in 0..self.delay_len {
38            let noise = self.white_noise();
39            // Low-pass filter the excitation based on brightness
40            // High brightness = more high-frequency content = brighter pluck
41            let filtered = if brightness > 0.5 {
42                noise
43            } else {
44                // Simple one-pole LP to reduce brightness
45                let alpha = brightness * 2.0;
46                noise * alpha
47            };
48            self.delay_line[(self.write_pos + i) % MAX_DELAY] = filtered;
49        }
50    }
51
52    #[inline(always)]
53    fn white_noise(&mut self) -> f32 {
54        // Xorshift32 PRNG — no allocation, deterministic
55        self.noise_seed ^= self.noise_seed << 13;
56        self.noise_seed ^= self.noise_seed >> 17;
57        self.noise_seed ^= self.noise_seed << 5;
58        (self.noise_seed as f32 / u32::MAX as f32) * 2.0 - 1.0
59    }
60
61    #[inline(always)]
62    fn process_sample(&mut self, decay: f32) -> f32 {
63        let read_pos = (self.write_pos + MAX_DELAY - self.delay_len) % MAX_DELAY;
64        let next_pos = (read_pos + 1) % MAX_DELAY;
65
66        // Averaging filter (low-pass) + decay = the Karplus-Strong algorithm
67        let output = (self.delay_line[read_pos] + self.delay_line[next_pos]) * 0.5 * decay;
68        self.delay_line[self.write_pos] = output;
69        self.write_pos = (self.write_pos + 1) % MAX_DELAY;
70        output
71    }
72}
73
74impl Default for KarplusStrong {
75    fn default() -> Self {
76        Self::new()
77    }
78}
79
80impl DspNode for KarplusStrong {
81    fn process(
82        &mut self,
83        _inputs: &[Option<&[f32; BUFFER_SIZE]>; MAX_INPUTS],
84        output: &mut [f32; BUFFER_SIZE],
85        params: &mut ParamBlock,
86        sample_rate: f32,
87    ) {
88        for sample in output.iter_mut() {
89            let freq = params.get(0).current.clamp(20.0, 4000.0);
90            let decay = params.get(1).current.clamp(0.9, 0.9999);
91            let brightness = params.get(2).current.clamp(0.0, 1.0);
92            let trigger = params.get(3).current;
93
94            // Update delay line length from frequency
95            let new_len = ((sample_rate / freq) as usize).clamp(2, MAX_DELAY - 1);
96            if new_len != self.delay_len {
97                self.delay_len = new_len;
98            }
99
100            // Trigger on rising edge
101            if trigger > 0.5 && self.prev_trigger <= 0.5 {
102                self.pluck(brightness);
103            }
104            self.prev_trigger = trigger;
105
106            *sample = self.process_sample(decay);
107            params.tick_all();
108        }
109    }
110
111    fn capture_state(&self) -> StateBlob {
112        StateBlob::EMPTY // delay line state not serialized (too large)
113    }
114
115    fn type_name(&self) -> &'static str {
116        "KarplusStrong"
117    }
118}