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}