Skip to main content

embedded_audio/
pluck.rs

1//! Karplus-Strong plucked-string synthesis.
2//!
3//! Classic Karplus-Strong: a noise burst is written into a circular delay line sized to the
4//! desired pitch period, then repeatedly read back through a variable-weight two-tap blend
5//! (the "loop filter") that darkens and shortens the sound on every pass around the loop. No
6//! sample memory is needed — inspired by DaisySP's `PhysicalModeling::String`, reworked here as
7//! fixed-point `i8` PCM so it needs no floating-point math and stays in the always-on core
8//! (unlike the `dsp`-feature synthesis in [`crate::drums`]).
9
10use crate::fixed::clamp_sample;
11
12/// A plucked string voice with a fixed-capacity delay line of `N` samples.
13///
14/// `N` bounds the lowest playable frequency: at a given `sample_rate_hz`, frequencies below
15/// `sample_rate_hz / N` clamp to the longest period the buffer can hold. For a 16 kHz engine,
16/// `N = 512` reaches down to ~31 Hz.
17#[derive(Debug, Clone)]
18pub struct KarplusPluck<const N: usize> {
19    buffer: [i8; N],
20    period: usize,
21    pos: usize,
22    rng_state: u32,
23    /// 0 = darkest / fastest decay (full averaging), 255 = brightest / slowest decay (no filtering).
24    brightness_q8: u8,
25    /// Extra overall feedback attenuation per loop pass; 255 = lossless (rings until re-plucked).
26    decay_q8: u8,
27    samples_left: u32,
28    active: bool,
29}
30
31impl<const N: usize> KarplusPluck<N> {
32    /// Creates a stopped string voice. Call [`Self::pluck`] to start it ringing.
33    pub const fn new() -> Self {
34        Self {
35            buffer: [0; N],
36            period: if N < 2 { 2 } else { N },
37            pos: 0,
38            rng_state: 0x1234_5678,
39            brightness_q8: 200,
40            decay_q8: 255,
41            samples_left: 0,
42            active: false,
43        }
44    }
45
46    /// Sets the tone's brightness/decay-speed balance. Lower values sound darker and decay faster.
47    pub fn set_brightness_q8(&mut self, brightness_q8: u8) {
48        self.brightness_q8 = brightness_q8;
49    }
50
51    /// Sets extra overall feedback loss per pass. `255` lets the string ring until re-plucked;
52    /// lower values shorten the sustain independently of `brightness_q8`.
53    pub fn set_decay_q8(&mut self, decay_q8: u8) {
54        self.decay_q8 = decay_q8;
55    }
56
57    /// Excites the string at `freq_hz` with a pseudo-random noise burst of `amplitude`, ringing
58    /// for `duration_ms` before auto-stopping (matching [`crate::synth::ToneVoice`]'s convention).
59    pub fn pluck(&mut self, freq_hz: u32, amplitude: i8, duration_ms: u16, sample_rate_hz: u32) {
60        self.period = if freq_hz == 0 || sample_rate_hz == 0 {
61            self.buffer.len().max(2)
62        } else {
63            ((sample_rate_hz / freq_hz) as usize).clamp(2, self.buffer.len().max(2))
64        };
65
66        for i in 0..self.period {
67            self.buffer[i] = self.next_noise_sample(amplitude);
68        }
69        self.pos = 0;
70        self.samples_left = if duration_ms == 0 {
71            u32::MAX
72        } else {
73            (duration_ms as u32 * sample_rate_hz) / 1000
74        };
75        self.active = true;
76    }
77
78    fn next_noise_sample(&mut self, amplitude: i8) -> i8 {
79        // xorshift32: cheap, deterministic, no external dependency.
80        self.rng_state ^= self.rng_state << 13;
81        self.rng_state ^= self.rng_state >> 17;
82        self.rng_state ^= self.rng_state << 5;
83        let noise = (self.rng_state >> 24) as i8;
84        ((noise as i32 * amplitude as i32) / 127).clamp(-128, 127) as i8
85    }
86
87    /// Silences the string immediately.
88    pub fn stop(&mut self) {
89        self.active = false;
90        self.samples_left = 0;
91    }
92
93    /// Whether the string is still ringing (has not reached `duration_ms` or been stopped).
94    pub fn is_active(&self) -> bool {
95        self.active
96    }
97
98    /// Advances the string by one sample, returning `None` once it has stopped.
99    pub fn next_sample(&mut self) -> Option<i8> {
100        if !self.active {
101            return None;
102        }
103        if self.samples_left == 0 {
104            self.active = false;
105            return None;
106        }
107        if self.samples_left != u32::MAX {
108            self.samples_left -= 1;
109        }
110
111        let period = self.period.max(2);
112        let next_pos = if self.pos + 1 >= period {
113            0
114        } else {
115            self.pos + 1
116        };
117        let a = self.buffer[self.pos] as i32;
118        let b = self.buffer[next_pos] as i32;
119        let blended = a + ((b - a) * self.brightness_q8 as i32) / 256;
120        let attenuated = (blended * self.decay_q8 as i32) / 256;
121        self.buffer[self.pos] = clamp_sample(attenuated);
122        self.pos = next_pos;
123        Some(clamp_sample(a))
124    }
125}
126
127impl<const N: usize> Default for KarplusPluck<N> {
128    fn default() -> Self {
129        Self::new()
130    }
131}