Skip to main content

tono_core/
drumkit.rs

1//! drumkit — a playable drum kit.
2//!
3//! A pitched instrument maps one sound across the keyboard; a drum kit maps each
4//! key to a *different* sound. [`DrumKit`] pre-renders the General MIDI drum map
5//! once (via the deterministic `kit` voice) and plays each piece as a one-shot on
6//! [`note_on`](DrumKit::note_on) — real-time-safe (a voice is just a cursor into
7//! a buffer), so it drops onto a `cpal` callback like any other [`AudioSource`].
8//!
9//! ```
10//! use tono_core::drumkit::DrumKit;
11//! use tono_core::instrument::Note;
12//!
13//! let mut kit = DrumKit::general_midi(48_000);
14//! kit.note_on(Note(36), 1.0); // kick
15//! kit.note_on(Note(38), 0.8); // snare
16//! ```
17
18use std::collections::BTreeMap;
19use std::sync::Arc;
20
21use crate::instrument::Note;
22use crate::render;
23use crate::runtime::AudioSource;
24
25/// General MIDI percussion notes covered by the default kit (kick → ride).
26const GM_DRUMS: std::ops::RangeInclusive<u8> = 35..=51;
27
28/// A playable drum kit: MIDI note → a pre-rendered one-shot.
29pub struct DrumKit {
30    /// note → mono samples (shared so a voice is a cheap cursor).
31    pieces: BTreeMap<u8, Arc<Vec<f32>>>,
32    voices: Vec<DrumVoice>,
33    /// Cap on simultaneous hits (oldest stolen with a short fade).
34    max_voices: usize,
35    /// Steal-declick ramp decrement per sample (≈ 5 ms at the kit's rate).
36    fade_step: f32,
37}
38
39struct DrumVoice {
40    samples: Arc<Vec<f32>>,
41    pos: usize,
42    gain: f32,
43    /// Steal declick: remaining ramp gain (1 = sounding normally) and its
44    /// per-sample decrement (0 = not stolen). Culled once silent.
45    fade: f32,
46    fade_step: f32,
47}
48
49/// Render one GM drum to mono samples via the deterministic `kit` voice, trimmed
50/// of trailing silence. Empty if the pitch maps to no drum.
51fn render_drum(midi: u8, sample_rate: u32) -> Vec<f32> {
52    let doc = serde_json::json!({
53        "name": "drum", "duration": 1.5, "sample_rate": sample_rate, "engine": 2,
54        "root": { "type": "seq", "bpm": 120, "steps_per_beat": 4, "wave": "kit",
55            "env": { "a": 0.001, "d": 0.4, "s": 0.0, "r": 0.15 },
56            "notes": [ { "step": 0, "len": 2, "pitch": format!("midi:{midi}"), "gain": 1.0 } ] }
57    });
58    let Ok(doc) = serde_json::from_value(doc) else {
59        return Vec::new();
60    };
61    let mut s = render::render(&doc);
62    let end = s
63        .iter()
64        .rposition(|x| x.abs() > 1e-4)
65        .map(|i| i + 1)
66        .unwrap_or(0);
67    s.truncate(end);
68    s
69}
70
71impl DrumKit {
72    /// The General MIDI drum map, synthesized once at `sample_rate`.
73    pub fn general_midi(sample_rate: u32) -> Self {
74        let mut pieces = BTreeMap::new();
75        for midi in GM_DRUMS {
76            let samples = render_drum(midi, sample_rate);
77            if !samples.is_empty() {
78                pieces.insert(midi, Arc::new(samples));
79            }
80        }
81        DrumKit {
82            pieces,
83            voices: Vec::new(),
84            max_voices: 32,
85            fade_step: 1.0 / (sample_rate as f32 * 0.005),
86        }
87    }
88
89    /// Set the polyphony cap — the number of simultaneous hits before the oldest
90    /// is stolen with a short fade (builder style; default 32, clamped to ≥ 1).
91    pub fn with_max_voices(mut self, max: usize) -> Self {
92        self.max_voices = max.max(1);
93        self
94    }
95
96    /// Strike the drum mapped to `note` (velocity scales its level). Notes with no
97    /// mapped piece are ignored.
98    pub fn note_on(&mut self, note: Note, velocity: f32) {
99        if let Some(samples) = self.pieces.get(&note.midi()) {
100            // Steal the oldest still-sounding hit with a ~5 ms fade — a full
101            // pool means it is guaranteed audible, so a hard cut would click
102            // (a booming 808 kick truncated mid-sample). A hit flood faster
103            // than the fade window falls back to hard removal for the bound.
104            let sounding = self.voices.iter().filter(|v| v.fade_step == 0.0).count();
105            if sounding >= self.max_voices
106                && let Some(oldest) = self.voices.iter_mut().find(|v| v.fade_step == 0.0)
107            {
108                oldest.fade_step = self.fade_step;
109            }
110            if self.voices.len() >= self.max_voices * 2 {
111                self.voices.remove(0);
112            }
113            self.voices.push(DrumVoice {
114                samples: samples.clone(),
115                pos: 0,
116                gain: velocity.clamp(0.0, 1.0),
117                fade: 1.0,
118                fade_step: 0.0,
119            });
120        }
121    }
122
123    /// The MIDI notes this kit responds to.
124    pub fn notes(&self) -> impl Iterator<Item = u8> + '_ {
125        self.pieces.keys().copied()
126    }
127
128    /// Number of drums still ringing.
129    pub fn active_voices(&self) -> usize {
130        self.voices.len()
131    }
132}
133
134impl AudioSource for DrumKit {
135    fn fill(&mut self, out: &mut [f32]) -> usize {
136        let frames = out.len() / 2;
137        out.fill(0.0);
138        for v in self.voices.iter_mut() {
139            for f in 0..frames {
140                let Some(&s) = v.samples.get(v.pos) else {
141                    break;
142                };
143                if v.fade_step > 0.0 {
144                    v.fade = (v.fade - v.fade_step).max(0.0);
145                    if v.fade == 0.0 {
146                        v.pos = v.samples.len(); // fully declicked — cull below
147                        break;
148                    }
149                }
150                let x = s * v.gain * v.fade;
151                out[f * 2] += x;
152                out[f * 2 + 1] += x;
153                v.pos += 1;
154            }
155        }
156        self.voices.retain(|v| v.pos < v.samples.len());
157        frames
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    fn peak(s: &[f32]) -> f32 {
166        s.iter().fold(0.0f32, |m, &x| m.max(x.abs()))
167    }
168
169    #[test]
170    fn gm_kit_maps_and_plays_drums() {
171        let mut kit = DrumKit::general_midi(48_000);
172        assert!(kit.notes().count() >= 8, "a usable set of GM drums");
173        kit.note_on(Note(36), 1.0); // kick
174        kit.note_on(Note(38), 0.9); // snare
175        assert_eq!(kit.active_voices(), 2);
176        let mut out = vec![0.0f32; 512 * 2];
177        assert_eq!(kit.fill(&mut out), 512);
178        assert!(peak(&out) > 0.0, "the kit makes sound");
179        assert!((0..512).all(|f| out[f * 2] == out[f * 2 + 1]), "centered");
180    }
181
182    #[test]
183    fn one_shots_cull_when_finished() {
184        let mut kit = DrumKit::general_midi(48_000);
185        kit.note_on(Note(42), 1.0); // closed hi-hat — short
186        assert_eq!(kit.active_voices(), 1);
187        // Serve well past any drum's length (2 s) so the one-shot ends.
188        for _ in 0..200 {
189            kit.fill(&mut vec![0.0f32; 512 * 2]);
190        }
191        assert_eq!(kit.active_voices(), 0, "finished one-shot reclaimed");
192    }
193
194    #[test]
195    fn unmapped_note_is_ignored() {
196        let mut kit = DrumKit::general_midi(48_000);
197        kit.note_on(Note(0), 1.0); // nothing at MIDI 0
198        assert_eq!(kit.active_voices(), 0);
199    }
200
201    #[test]
202    fn stealing_fades_the_oldest_hit_instead_of_cutting() {
203        // Baseline: the kick's own natural sample-to-sample slope.
204        let max_jump = |kit: &mut DrumKit, prev: f32| {
205            let mut buf = vec![0.0f32; 512 * 2];
206            kit.fill(&mut buf);
207            let mut m = 0.0f32;
208            let mut p = prev;
209            for f in 0..512 {
210                m = m.max((buf[f * 2] - p).abs());
211                p = buf[f * 2];
212            }
213            m
214        };
215        let warmup = |kit: &mut DrumKit| {
216            kit.note_on(Note(36), 1.0); // kick — long, booming
217            let mut out = vec![0.0f32; 64 * 2];
218            kit.fill(&mut out);
219            out[out.len() - 2]
220        };
221        let mut natural = DrumKit::general_midi(48_000);
222        let prev = warmup(&mut natural);
223        let natural_jump = max_jump(&mut natural, prev);
224
225        // Stolen: velocity-0 strikes occupy the pool without adding their own
226        // transients, so the mix isolates the stolen kick's ramp-out.
227        let mut kit = DrumKit::general_midi(48_000);
228        kit.max_voices = 2;
229        let prev = warmup(&mut kit);
230        kit.note_on(Note(38), 0.0);
231        kit.note_on(Note(42), 0.0); // pool full: steals the kick
232        let stolen_jump = max_jump(&mut kit, prev);
233
234        // A hard cut steps by the kick's full instantaneous level on top of
235        // its natural slope; the 5 ms ramp must add almost nothing.
236        assert!(
237            stolen_jump <= natural_jump + 0.05,
238            "steal clicked: jump {stolen_jump} vs natural {natural_jump}"
239        );
240        // The stolen voice drains within the declick window.
241        kit.fill(&mut vec![0.0f32; 512 * 2]);
242        assert_eq!(kit.active_voices(), 2, "faded voice culled after steal");
243    }
244}