Skip to main content

audiobox/synth/
drum.rs

1//! Finite, synthesized one-shots. No recorded samples or platform transcendental math.
2use super::wave::{Aliasing, Oscillator, Waveform};
3use crate::math::{exp, exp2, sin_turns};
4use crate::{Audio, Error, Limits, Result};
5
6/// Character of the oscillators and noise, rather than a model of one console.
7#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
8pub enum DrumKit {
9    Chiptune,
10    Electronic,
11}
12
13#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
14pub enum DrumKind {
15    Kick,
16    Snare,
17    ClosedHat,
18    OpenHat,
19    Tom,
20    Clap,
21    Crash,
22}
23
24/// A drum preset with integer, portable controls. Notes transpose pitched components
25/// relative to C4; noise filters stay fixed. Envelope times are finite lengths, not
26/// exponential time constants. Each trigger resets oscillators, filters and seed.
27/// A component shorter than its 1 ms attack (including a one-frame envelope at low
28/// sample rates) can be silent. Presets use longer envelopes.
29#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
30pub struct Drum {
31    pub kind: DrumKind,
32    pub kit: DrumKit,
33    /// Fundamental/metal-bank base at C4, 20..=20000 Hz, clamped below Nyquist.
34    pub pitch_hz: u16,
35    /// Tone envelope length, 0..=10000 ms. Zero silences that component.
36    pub tone_ms: u32,
37    /// Noise envelope length, 0..=10000 ms. Clap adds 30 ms for its initial bursts.
38    pub noise_ms: u32,
39    /// Initial pitch above the base, 0..=4800 cents, falling linearly in cents.
40    pub sweep_cents: u16,
41    /// Time to reach the base pitch, 0..=1000 ms. Zero starts at the base.
42    pub sweep_ms: u32,
43    /// Noise blend, 0..=100 percent; the tone receives the complementary amount.
44    pub noise_percent: u8,
45    /// A separate 3 ms filtered-noise transient, 0..=100 percent.
46    pub click_percent: u8,
47    /// Fixed noise seed. Zero maps to one; the chiptune register uses its low 15 bits.
48    pub seed: u32,
49}
50impl Drum {
51    pub fn new(kind: DrumKind, kit: DrumKit) -> Self {
52        use DrumKind::*;
53        let (pitch, tone, noise, sweep, sweep_ms, blend, click) = match kind {
54            Kick => (55, 400, 0, 2400, 35, 0, 25),
55            Snare => (180, 100, 220, 0, 0, 70, 10),
56            ClosedHat => (3400, 45, 60, 0, 0, 80, 0),
57            OpenHat => (3400, 350, 450, 0, 0, 80, 0),
58            Tom => (130, 420, 80, 1200, 50, 15, 10),
59            Clap => (1800, 0, 180, 0, 0, 100, 0),
60            Crash => (2300, 1200, 1800, 0, 0, 65, 0),
61        };
62        let mut drum = Self {
63            kind,
64            kit,
65            pitch_hz: pitch,
66            tone_ms: tone,
67            noise_ms: noise,
68            sweep_cents: sweep,
69            sweep_ms,
70            noise_percent: blend,
71            click_percent: click,
72            seed: 1,
73        };
74        if kit == DrumKit::Chiptune {
75            drum.tone_ms = tone * 3 / 4;
76            drum.noise_ms = noise * 3 / 4;
77            drum.click_percent = click / 2;
78            if matches!(kind, ClosedHat | OpenHat | Crash) {
79                drum.noise_percent = 100;
80            }
81        }
82        drum
83    }
84    pub fn validate(&self) -> Result<()> {
85        if !(20..=20_000).contains(&self.pitch_hz)
86            || self.tone_ms > 10_000
87            || self.noise_ms > 10_000
88            || self.sweep_ms > 1000
89            || self.sweep_cents > 4800
90            || self.noise_percent > 100
91            || self.click_percent > 100
92        {
93            return Err(Error::invalid(
94                "Drum pitch, envelope, sweep or blend is outside its documented range",
95            ));
96        }
97        Ok(())
98    }
99    /// Finite duration including a clap's bursts and the click, excluding muted components.
100    pub fn duration_ms(&self) -> u32 {
101        let tone = if self.noise_percent < 100 && self.kind != DrumKind::Clap {
102            self.tone_ms
103        } else {
104            0
105        };
106        let noise = if self.noise_percent > 0 && self.noise_ms > 0 {
107            self.noise_ms
108                .saturating_add(if self.kind == DrumKind::Clap { 30 } else { 0 })
109        } else {
110            0
111        };
112        tone.max(noise)
113            .max(if self.click_percent > 0 { 3 } else { 0 })
114    }
115    /// Renders one hit at C4, with velocity 0..=127, using band-limited chip tones.
116    /// This offline convenience allocates its output; score `Synth::fill` does not.
117    pub fn render(&self, sample_rate: u32, velocity: u8) -> Result<Audio> {
118        self.validate()?;
119        if velocity > 127 {
120            return Err(Error::invalid("Velocity must be 0..=127"));
121        }
122        Limits::default().check(0, 1, sample_rate)?;
123        let plan = DrumPlan::new(*self, sample_rate);
124        let mut voice = DrumVoice::new(Aliasing::BandLimited);
125        voice.trigger(&plan, 1.0);
126        let mut samples = vec![0.0; plan.frames as usize];
127        for sample in &mut samples {
128            *sample = (voice.sample(&plan) * (velocity as f64 / 127.0)) as f32;
129        }
130        Audio::new(vec![samples], sample_rate)
131    }
132}
133
134#[derive(Clone, Debug)]
135pub(super) struct DrumPlan {
136    settings: Drum,
137    pub(super) frames: u64,
138    rate: u32,
139    tone: u64,
140    noise: u64,
141    sweep: u64,
142    attack: u64,
143    click: u64,
144    burst: u64,
145    offsets: [u64; 4],
146    low: f64,
147    high: f64,
148}
149impl DrumPlan {
150    pub(super) fn new(settings: Drum, rate: u32) -> Self {
151        let samples = |ms: u32| (ms as u64 * rate as u64 + 500) / 1000;
152        let (low, high) = match settings.kind {
153            DrumKind::Kick | DrumKind::Tom => (8000.0, 1500.0),
154            DrumKind::Snare => (8000.0, 700.0),
155            DrumKind::Clap => (6500.0, 1000.0),
156            _ => (14_000.0, 5000.0),
157        };
158        let alpha =
159            |hz: f64| exp(-std::f64::consts::TAU * hz.min(rate as f64 * 0.45) / rate as f64);
160        let tone = samples(settings.tone_ms);
161        let noise = samples(settings.noise_ms);
162        let click = samples(3).max(1);
163        let offsets = [0, samples(10), samples(20), samples(30)];
164        let tone_end = if settings.noise_percent < 100 && settings.kind != DrumKind::Clap {
165            tone
166        } else {
167            0
168        };
169        let noise_end = if settings.noise_percent > 0 && noise > 0 {
170            noise
171                + if settings.kind == DrumKind::Clap {
172                    offsets[3]
173                } else {
174                    0
175                }
176        } else {
177            0
178        };
179        Self {
180            frames: tone_end
181                .max(noise_end)
182                .max(if settings.click_percent > 0 { click } else { 0 }),
183            rate,
184            tone,
185            noise,
186            sweep: samples(settings.sweep_ms),
187            attack: samples(1).max(1),
188            click,
189            burst: samples(8).max(1),
190            offsets,
191            low: alpha(low),
192            high: alpha(high),
193            settings,
194        }
195    }
196}
197
198#[derive(Clone, Debug)]
199pub(super) struct DrumVoice {
200    age: u64,
201    frames: u64,
202    aliasing: Aliasing,
203    oscillator: Oscillator,
204    phases: [f64; 6],
205    random: u32,
206    noise_phase: f64,
207    low: f64,
208    high: f64,
209    transpose: f64,
210}
211impl DrumVoice {
212    pub(super) fn new(aliasing: Aliasing) -> Self {
213        Self {
214            age: 0,
215            frames: 0,
216            aliasing,
217            oscillator: Oscillator::new(aliasing),
218            phases: [0.0; 6],
219            random: 1,
220            noise_phase: 0.0,
221            low: 0.0,
222            high: 0.0,
223            transpose: 1.0,
224        }
225    }
226    pub(super) fn trigger(&mut self, plan: &DrumPlan, transpose: f64) {
227        *self = Self {
228            frames: plan.frames,
229            random: if plan.settings.kit == DrumKit::Chiptune {
230                (plan.settings.seed & 0x7fff).max(1)
231            } else {
232                plan.settings.seed.max(1)
233            },
234            transpose,
235            ..Self::new(self.aliasing)
236        };
237    }
238    pub(super) fn active(&self) -> bool {
239        self.age < self.frames
240    }
241    pub(super) fn sample(&mut self, plan: &DrumPlan) -> f64 {
242        if !self.active() {
243            return 0.0;
244        }
245        let d = plan.settings;
246        let elapsed = self.age;
247        self.age += 1;
248        let sweep = if plan.sweep == 0 {
249            0.0
250        } else {
251            (1.0 - elapsed as f64 / plan.sweep as f64).max(0.0)
252        };
253        let hz = (d.pitch_hz as f64 * self.transpose * exp2(d.sweep_cents as f64 * sweep / 1200.0))
254            .min(plan.rate as f64 * 0.45);
255        let raw_noise = if d.kit == DrumKit::Chiptune {
256            let value = if self.random & 1 == 0 { 1.0 } else { -1.0 };
257            self.noise_phase += (d.pitch_hz as f64 * 8.0 / plan.rate as f64).min(1.0);
258            if self.noise_phase >= 1.0 {
259                self.noise_phase -= 1.0;
260                let tap = if matches!(
261                    d.kind,
262                    DrumKind::ClosedHat | DrumKind::OpenHat | DrumKind::Crash
263                ) {
264                    6
265                } else {
266                    1
267                };
268                let bit = (self.random ^ (self.random >> tap)) & 1;
269                self.random = (self.random >> 1) | (bit << 14);
270            }
271            value
272        } else {
273            self.random ^= self.random << 13;
274            self.random ^= self.random >> 17;
275            self.random ^= self.random << 5;
276            (self.random >> 8) as f64 / (1u32 << 23) as f64 - 1.0
277        };
278        self.low = raw_noise * (1.0 - plan.low) + self.low * plan.low;
279        self.high = self.low * (1.0 - plan.high) + self.high * plan.high;
280        let noise = self.low - self.high;
281        let tone = match d.kind {
282            DrumKind::Clap => 0.0,
283            DrumKind::ClosedHat | DrumKind::OpenHat | DrumKind::Crash => {
284                let mut sum = 0.0;
285                for (phase, ratio) in self
286                    .phases
287                    .iter_mut()
288                    .zip([1.0, 1.342, 1.789, 2.129, 2.731, 3.113])
289                {
290                    sum += if *phase < 0.5 { 1.0 } else { -1.0 };
291                    advance(
292                        phase,
293                        (hz * ratio).min(plan.rate as f64 * 0.45) / plan.rate as f64,
294                    );
295                }
296                sum / 6.0
297            }
298            _ if d.kit == DrumKit::Chiptune => {
299                self.oscillator.next(Waveform::Triangle, hz, plan.rate)
300            }
301            _ => {
302                let fundamental = sin_turns(self.phases[0]);
303                advance(&mut self.phases[0], hz / plan.rate as f64);
304                if d.kind == DrumKind::Snare {
305                    let overtone = sin_turns(self.phases[1]);
306                    advance(
307                        &mut self.phases[1],
308                        (hz * 1.5).min(plan.rate as f64 * 0.45) / plan.rate as f64,
309                    );
310                    (fundamental + overtone * 0.35) / 1.35
311                } else {
312                    fundamental
313                }
314            }
315        };
316        let tone_env = envelope(elapsed, plan.tone, plan.attack);
317        let noise_env = if d.kind == DrumKind::Clap && plan.noise > 0 {
318            let mut bursts = 0.0;
319            for &offset in &plan.offsets[..3] {
320                if let Some(age) = elapsed.checked_sub(offset) {
321                    bursts += envelope(age, plan.burst, 1);
322                }
323            }
324            if let Some(age) = elapsed.checked_sub(plan.offsets[3]) {
325                bursts += envelope(age, plan.noise, plan.attack);
326            }
327            bursts
328        } else {
329            envelope(elapsed, plan.noise, plan.attack)
330        };
331        let click_env = envelope(elapsed, plan.click, 1);
332        let blend = d.noise_percent as f64 / 100.0;
333        tone * tone_env * (1.0 - blend)
334            + noise * noise_env * blend
335            + noise * click_env * d.click_percent as f64 / 100.0
336    }
337}
338fn envelope(age: u64, length: u64, attack: u64) -> f64 {
339    if age >= length {
340        return 0.0;
341    }
342    let t = 1.0 - age as f64 / length as f64;
343    t * t * (age as f64 / attack as f64).min(1.0)
344}
345fn advance(phase: &mut f64, step: f64) {
346    *phase += step;
347    *phase -= phase.floor();
348}