use crate::dsp::{key_freq, vel_amp, Biquad, OnePole, Rng, Sine};
use crate::sampler;
use crate::voices::Voice;
use std::f32::consts::TAU;
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Kit {
V1,
#[cfg_attr(not(test), allow(dead_code))]
V2,
V3,
Brush,
Synth,
}
struct Tone {
phase: f32,
freq: f32,
glide: f32, min_freq: f32,
amp: f32,
decay: f32,
}
struct NoiseBand {
amp: f32,
decay: f32,
filt: Biquad,
onset: u32,
env: f32,
atk: f32,
floor: f32,
}
struct Shimmer {
lp: OnePole,
depth: f32,
norm: f32,
}
impl Shimmer {
fn new(rate_hz: f32, depth: f32, sr: f32) -> Self {
let a = 1.0 - (-2.0 * std::f32::consts::PI * (rate_hz / sr).min(0.49)).exp();
let norm = ((2.0 - a) / a * 3.0).sqrt();
Shimmer {
lp: OnePole::lowpass(rate_hz, sr),
depth,
norm,
}
}
}
struct WireRes {
bands: [Biquad; 3], gains: [f32; 3], amp: f32, decay: f32, onset: u32, env: f32, atk: f32, head_amp0: f32, head_track: f32, am_depth: f32, grain: Option<Grain>, }
struct Grain {
e: f32,
decay: f32,
rate: f32,
floor: f32,
}
pub struct Drum {
tones: Vec<Tone>,
noise: Vec<NoiseBand>,
bursts: Vec<(u32, f32)>, shimmer: Option<Shimmer>, wire: Option<WireRes>, rng: Rng,
t: u32,
life: u32,
gain: f32,
sr: f32,
}
fn dmul(t60: f32, sr: f32) -> f32 {
10f32.powf(-3.0 / (t60.max(0.005) * sr))
}
impl Drum {
#[allow(clippy::too_many_arguments)]
fn new(
sr: f32,
seed: u32,
tones: &[(f32, f32, f32, f32)], noise: &[(f32, f32, Biquad)], life_s: f32,
gain: f32,
) -> Self {
let mut rng = Rng::new(seed);
let edge = rng.white();
let jf = 1.0 + 0.03 * rng.white();
let jd = 1.0 + 0.10 * rng.white();
let n_tones = tones.len();
let tones = tones
.iter()
.enumerate()
.map(|(i, &(f, a, t, glide_oct_per_s))| {
let modal = if n_tones > 1 {
i as f32 / (n_tones - 1) as f32
} else {
0.5
};
let tilt = 0.20 * edge * (2.0 * modal - 1.0);
Tone {
phase: rng.white() * TAU,
freq: f * jf,
glide: if glide_oct_per_s != 0.0 {
2f32.powf(-glide_oct_per_s / sr)
} else {
1.0
},
min_freq: f * jf * 0.3,
amp: (a * (1.0 + tilt + 0.12 * rng.white())).max(0.0),
decay: dmul(t * jd, sr),
}
})
.collect();
let noise = noise
.iter()
.map(|&(amp, t, filt)| NoiseBand {
amp: (amp * (1.0 + 0.15 * edge + 0.10 * rng.white())).max(0.0),
decay: dmul(t * jd, sr),
filt,
onset: 0,
env: 1.0,
atk: 1.0,
floor: 1.0,
})
.collect();
Drum {
tones,
noise,
bursts: Vec::new(),
shimmer: None,
wire: None,
rng,
t: 0,
life: (life_s * sr) as u32,
gain,
sr,
}
}
fn with_bursts(mut self, bursts: &[(f32, f32)]) -> Self {
self.bursts = bursts
.iter()
.map(|&(sec, amp)| ((sec * self.sr) as u32, amp))
.collect();
self
}
fn with_shimmer(mut self, rate_hz: f32, depth: f32) -> Self {
self.shimmer = Some(Shimmer::new(rate_hz, depth, self.sr));
self
}
fn with_wire(mut self, wire: WireRes) -> Self {
self.wire = Some(wire);
self
}
fn with_band_ext(mut self, idx: usize, onset_s: f32, atk_s: f32, floor: f32) -> Self {
let b = &mut self.noise[idx];
b.onset = (onset_s * self.sr) as u32;
b.floor = floor;
if atk_s > 0.0 {
b.atk = 1.0 / (atk_s * self.sr);
b.env = 0.0;
}
self
}
fn with_glide_floor(mut self, ratio: f32) -> Self {
for tone in &mut self.tones {
tone.min_freq = tone.freq * ratio;
}
self
}
}
impl Voice for Drum {
fn render(&mut self, out: &mut [f32]) -> bool {
for o in out.iter_mut() {
if self.t >= self.life {
return false;
}
let mut s = 0.0;
for tone in &mut self.tones {
s += tone.amp * tone.phase.sin();
tone.phase += TAU * tone.freq / self.sr;
if tone.phase > TAU {
tone.phase -= TAU;
}
if (tone.glide < 1.0 && tone.freq > tone.min_freq)
|| (tone.glide > 1.0 && tone.freq < tone.min_freq)
{
tone.freq *= tone.glide;
}
tone.amp *= tone.decay;
}
for &(offset, amp) in &self.bursts {
if self.t == offset {
for band in &mut self.noise {
band.amp = band.amp.max(amp);
}
}
}
let white = self.rng.white();
for band in &mut self.noise {
if self.t < band.onset {
continue; }
if band.env < 1.0 {
band.env = (band.env + band.atk).min(1.0);
}
let g = band.amp * (band.floor + (1.0 - band.floor) * band.env);
if g > 1e-5 {
s += band.filt.process(white) * g;
band.amp *= band.decay;
}
}
if let Some(w) = self.wire.as_mut() {
let head_amp_now = self.tones.first().map_or(0.0, |t| t.amp);
let head_phase_sin = self.tones.first().map_or(0.0, |t| t.phase.sin());
if self.t >= w.onset {
if w.env < 1.0 {
w.env = (w.env + w.atk).min(1.0);
}
let head = (head_amp_now / w.head_amp0).clamp(0.0, 1.0);
let track = head.powf(w.head_track);
let gate = if let Some(gr) = w.grain.as_mut() {
let lam = track;
let u = white * 0.5 + 0.5; if u < gr.rate * lam {
gr.e = 1.0;
}
let out = gr.floor + (1.0 - gr.floor) * gr.e;
gr.e *= gr.decay;
out
} else {
1.0 + w.am_depth * head * head_phase_sin.max(0.0)
};
let mut wsum = 0.0;
for (band, &g) in w.bands.iter_mut().zip(w.gains.iter()) {
wsum += band.process(white) * g;
}
s += wsum * w.amp * w.env * track * gate;
w.amp *= w.decay;
}
}
let am = if let Some(sh) = &mut self.shimmer {
(1.0 + sh.depth * sh.norm * sh.lp.process(white)).clamp(0.0, 2.0)
} else {
1.0
};
*o += s * self.gain * am;
self.t += 1;
}
true
}
fn note_off(&mut self) {}
fn released(&self) -> bool {
true
}
fn choke(&mut self) {
let fast = dmul(0.010, self.sr);
for tone in &mut self.tones {
tone.decay = tone.decay.min(fast);
}
for band in &mut self.noise {
band.decay = band.decay.min(fast);
}
self.life = self.life.min(self.t + (0.030 * self.sr) as u32);
}
#[cfg(test)]
fn kind(&self) -> &'static str {
"drum"
}
}
struct MetalMode {
osc: Sine,
amp: f32,
decay: f32,
band: u8, }
struct Cascade {
c: [f32; 3], atk: f32, floor: [f32; 4], depth: [f32; 4], }
struct MetalPlate {
modes: Vec<MetalMode>,
bands: Vec<NoiseBand>,
casc: Option<Cascade>,
wash_floor: Option<f32>,
rng: Rng,
shimmer: Option<Shimmer>,
t: u32,
life: u32,
gain: f32,
sr: f32,
}
#[derive(Clone, Copy)]
struct BloomSpec {
floor: [f32; 3],
tau_a: f32,
tau_b: f32,
depth_scale: f32,
}
struct MetalSpec {
lo: f32,
hi: f32,
singles: usize,
pairs: usize,
tone_amp: f32,
t60_low: f32,
t60_high: f32,
wash_amp: f32,
wash_t60: f32,
wash_hp: f32,
mid_amp: f32,
mid_hz: f32,
click_amp: f32,
click_t60: f32,
click_hp: f32,
life: f32,
gain: f32,
swell: bool,
shimmer: Option<(f32, f32)>,
bloom: Option<BloomSpec>,
}
impl MetalPlate {
fn new(spec: &MetalSpec, sr: f32, seed: u32, vel: u8) -> Self {
let mut rng = Rng::new(seed ^ 0xC1A5_5EED);
let velnorm = vel as f32 / 127.0;
let log_lo = spec.lo.ln();
let log_span = (spec.hi / spec.lo).ln();
let n_centers = spec.singles + spec.pairs;
let mut modes = Vec::with_capacity(n_centers + spec.pairs);
let band_of = |f: f32| -> u8 {
if f < 1500.0 {
0
} else if f < 4000.0 {
1
} else if f < 8000.0 {
2
} else {
3
}
};
for i in 0..n_centers {
let frac = (i as f32 + 0.37 + 0.26 * rng.white()).clamp(0.0, n_centers as f32)
/ n_centers as f32;
let freq = (log_lo + log_span * frac).exp() * (1.0 + 0.018 * rng.white());
let decay_frac = 1.0 - frac;
let t60 = spec.t60_high + (spec.t60_low - spec.t60_high) * decay_frac.powf(0.7);
let amp = spec.tone_amp
* (1.0 - 0.45 * frac)
* (0.65 + 0.55 * velnorm)
* (0.75 + 0.25 * rng.white().abs());
let decay = dmul(t60, sr);
if i < spec.singles {
modes.push(MetalMode {
osc: Sine::new(freq, sr, rng.white() * TAU),
amp,
decay,
band: band_of(freq),
});
} else {
let df = (4.5 + 0.0011 * freq) * (1.0 + 0.25 * rng.white());
for sgn in [-0.5f32, 0.5] {
let f = freq + sgn * df;
modes.push(MetalMode {
osc: Sine::new(f, sr, rng.white() * TAU),
amp,
decay,
band: band_of(f),
});
}
}
}
let sm = ((velnorm - 0.25) / 0.75).clamp(0.0, 1.0);
let g = sm * sm * (3.0 - 2.0 * sm);
let casc = spec.bloom.map(|b| {
let tau_s = (b.tau_a - b.tau_b * g) * 0.001;
let atk = 1.0 - (-1.0 / (tau_s * sr)).exp();
Cascade {
c: [0.0; 3],
atk,
floor: [1.0, b.floor[0], b.floor[1], b.floor[2]],
depth: [
0.0,
b.depth_scale * (1.0 - b.floor[0]) * g.powf(1.0 / 3.0),
b.depth_scale * (1.0 - b.floor[1]) * g.powf(2.0 / 3.0),
b.depth_scale * (1.0 - b.floor[2]) * g,
],
}
});
let wash_floor = if spec.swell && spec.bloom.is_some() {
Some(1.0 - 0.55 * g.powf(0.8))
} else {
None
};
let hp = spec.wash_hp * (0.85 + 0.35 * velnorm);
let mut bands = vec![
NoiseBand {
amp: spec.wash_amp * (0.75 + 0.35 * velnorm),
decay: dmul(spec.wash_t60, sr),
filt: Biquad::highpass(hp, 0.7, sr),
onset: 0,
env: if spec.swell { 0.0 } else { 1.0 },
atk: if spec.swell { 1.0 / (0.050 * sr) } else { 1.0 },
floor: if spec.swell { 0.45 } else { 1.0 },
},
NoiseBand {
amp: spec.mid_amp,
decay: dmul(spec.wash_t60 * 0.8, sr),
filt: Biquad::bandpass(spec.mid_hz, 0.7, sr),
onset: 0,
env: if spec.swell { 0.0 } else { 1.0 },
atk: if spec.swell { 1.0 / (0.040 * sr) } else { 1.0 },
floor: if spec.swell { 0.35 } else { 1.0 },
},
];
if spec.click_amp > 0.0 {
bands.push(NoiseBand {
amp: spec.click_amp * velnorm,
decay: dmul(spec.click_t60, sr),
filt: Biquad::highpass(spec.click_hp, 0.7, sr),
onset: 0,
env: 1.0,
atk: 1.0,
floor: 1.0,
});
}
Self {
modes,
bands,
casc,
wash_floor,
rng,
shimmer: spec
.shimmer
.map(|(rate, depth)| Shimmer::new(rate, depth, sr)),
t: 0,
life: (spec.life * sr) as u32,
gain: spec.gain * vel_amp(vel),
sr,
}
}
}
impl Voice for MetalPlate {
fn render(&mut self, out: &mut [f32]) -> bool {
for o in out.iter_mut() {
if self.t >= self.life {
return false;
}
let mut s = 0.0;
let (m, c2) = if let Some(cx) = &mut self.casc {
cx.c[0] += cx.atk * (1.0 - cx.c[0]);
cx.c[1] += cx.atk * (cx.c[0] - cx.c[1]);
cx.c[2] += cx.atk * (cx.c[1] - cx.c[2]);
(
[
1.0,
cx.floor[1] + cx.depth[1] * cx.c[0],
cx.floor[2] + cx.depth[2] * cx.c[1],
cx.floor[3] + cx.depth[3] * cx.c[2],
],
cx.c[2],
)
} else {
([1.0; 4], 0.0)
};
for mode in &mut self.modes {
s += mode.osc.next() * mode.amp * m[mode.band as usize];
mode.amp *= mode.decay;
}
let white = self.rng.white();
let wash_floor = self.wash_floor;
for (bi, band) in self.bands.iter_mut().enumerate() {
if band.env < 1.0 {
band.env = (band.env + band.atk).min(1.0);
}
let swell = match (bi, wash_floor) {
(0, Some(fw)) => fw + (1.0 - fw) * c2,
_ => band.floor + (1.0 - band.floor) * band.env,
};
let g = band.amp * swell;
if g > 1e-5 {
s += band.filt.process(white) * g;
band.amp *= band.decay;
}
}
let am = if let Some(sh) = &mut self.shimmer {
(1.0 + sh.depth * sh.norm * sh.lp.process(white)).clamp(0.0, 2.0)
} else {
1.0
};
*o += s * self.gain * am;
self.t += 1;
}
true
}
fn note_off(&mut self) {}
fn released(&self) -> bool {
true
}
fn choke(&mut self) {
let fast = dmul(0.010, self.sr);
for mode in &mut self.modes {
mode.decay = mode.decay.min(fast);
}
for band in &mut self.bands {
band.decay = band.decay.min(fast);
}
self.life = self.life.min(self.t + (0.030 * self.sr) as u32);
}
#[cfg(test)]
fn kind(&self) -> &'static str {
"drum"
}
}
fn noise_vel_gain(vn: f32) -> f32 {
0.5 + 0.5 * vn * vn
}
#[allow(clippy::type_complexity)]
fn membrane_velocity(
tones: &[(f32, f32, f32, f32)],
noise: &[(f32, f32, Biquad)],
vn: f32,
) -> (Vec<(f32, f32, f32, f32)>, Vec<(f32, f32, Biquad)>) {
let tones = tones
.iter()
.map(|&(f, a, t, glide)| {
(
f * (1.0 + 0.03 * vn),
a,
t * (0.85 + 0.30 * vn),
glide * (0.6 + 0.8 * vn),
)
})
.collect();
let noise = noise
.iter()
.map(|&(a, t, filt)| (a * noise_vel_gain(vn), t, filt))
.collect();
(tones, noise)
}
fn tom_tones(f0: f32, t60: f32, glide: f32) -> [(f32, f32, f32, f32); 3] {
[
(f0, 1.0, t60, glide),
(f0 * 1.59, 0.40, t60 * 0.7, glide),
(f0 * 2.14, 0.18, t60 * 0.5, glide),
]
}
fn tom_tones_v3(f0: f32, t60: f32, glide: f32) -> [(f32, f32, f32, f32); 6] {
[
(f0, 1.0, t60, glide),
(f0 * 1.50, 0.50, t60 * 0.72, glide),
(f0 * 2.00, 0.32, t60 * 0.55, glide),
(f0 * 2.44, 0.20, t60 * 0.42, glide),
(f0 * 2.90, 0.13, t60 * 0.32, glide),
(f0 * 3.50, 0.08, t60 * 0.24, glide),
]
}
const TOM_OVERSHOOT: f32 = 1.155; const TOM_GLIDE_V2: f32 = 4.0; const TOM_BEAT_DF: f32 = 6.0;
const SNARE_TONES: [(f32, f32, f32, f32); 4] = [
(186.0, 0.8, 0.10, 4.0),
(280.0, 0.35, 0.08, 2.0),
(330.0, 0.30, 0.07, 2.0),
(430.0, 0.18, 0.05, 2.0),
];
const SNARE_TONES_V3: [(f32, f32, f32, f32); 5] = [
(186.0, 0.8, 0.10, 4.0),
(296.0, 0.42, 0.08, 2.0),
(397.0, 0.28, 0.065, 2.0),
(428.0, 0.18, 0.055, 2.0),
(493.0, 0.11, 0.045, 2.0),
];
const WIRE_CENTERS: [f32; 3] = [3400.0, 5100.0, 7300.0];
const WIRE_QS: [f32; 3] = [6.0, 7.0, 8.0];
const WIRE_MAKEUP: f32 = 2.9;
const WIRE_HEAD_TRACK: f32 = 0.4;
const GRAIN_RATE: f32 = 0.03;
const GRAIN_RATE_EL: f32 = 0.045;
const GRAIN_FLOOR: f32 = 0.15;
const GRAIN_T60: f32 = 0.0006;
const GRAIN_MAKEUP: f32 = 2.5;
const METAL_RATIOS: [f32; 6] = [1.0, 1.483, 1.932, 2.546, 3.363, 4.365];
const CRASH_TWIN_DF_HZ: [f32; 6] = [5.6, 7.9, 9.3, 11.7, 14.2, 17.5];
const CRASH_TONE_NORM: f32 = 0.82;
const CRASH_WASH_AMP_V2: f32 = 0.74;
struct CymSpec {
base: f32,
tone_amp: f32,
t60_first: f32,
t60_last: f32,
noise: (f32, f32, f32), life: f32,
gain: f32,
click: Option<(f32, f32, f32)>,
swell: bool,
pairs: bool,
v2: Option<CrashV2>,
noise2: Option<(f32, f32, f32)>,
shimmer: Option<(f32, f32)>,
}
struct CrashV2 {
twin_df: [f32; 6],
pairs_t60: f32,
low_pair: bool,
}
fn cymbal(spec: &CymSpec, sr: f32, seed: u32, vel: u8) -> Option<Box<dyn Voice>> {
let v = vel_amp(vel);
let velnorm = vel as f32 / 127.0;
let mut tones = Vec::with_capacity(12);
for (i, &r) in METAL_RATIOS.iter().enumerate() {
let frac = i as f32 / (METAL_RATIOS.len() - 1) as f32;
let amp = spec.tone_amp * (1.0 - 0.6 * frac) * (0.55 + 0.55 * velnorm.powf(frac + 0.5));
let t60 = spec.t60_first + (spec.t60_last - spec.t60_first) * frac;
match &spec.v2 {
Some(v2) => {
tones.push((spec.base * r, amp * CRASH_TONE_NORM, t60, 0.0));
tones.push((
spec.base * r + v2.twin_df[i],
amp * CRASH_TONE_NORM * 0.7,
t60,
0.0,
));
}
None => tones.push((spec.base * r, amp, t60, 0.0)),
}
}
let hp = spec.noise.2 * (0.85 + 0.35 * velnorm);
let mut bands = vec![(spec.noise.0, spec.noise.1, Biquad::highpass(hp, 0.7, sr))];
let mut swelled = vec![0usize];
if let Some((amp2, t60_2, hp2)) = spec.noise2 {
bands.push((
amp2,
t60_2,
Biquad::highpass(hp2 * (0.85 + 0.35 * velnorm), 0.7, sr),
));
}
if spec.pairs {
let pair_t60 = match &spec.v2 {
Some(v2) => v2.pairs_t60,
None => spec.noise.1,
};
for &(fa, fb) in &[(6000.0f32, 6055.0f32), (8300.0, 8380.0)] {
for f in [fa, fb] {
swelled.push(bands.len());
bands.push((spec.noise.0 * 6.0, pair_t60, Biquad::bandpass(f, 800.0, sr)));
}
}
if let Some(v2) = &spec.v2 {
if v2.low_pair {
for f in [1180.0f32, 1196.0] {
swelled.push(bands.len());
bands.push((
spec.noise.0 * 4.0,
v2.pairs_t60,
Biquad::bandpass(f, 800.0, sr),
));
}
}
}
}
if let Some((amp, t60, hp)) = spec.click {
bands.push((amp, t60, Biquad::highpass(hp, 0.7, sr)));
}
let mut drum = Drum::new(sr, seed, &tones, &bands, spec.life, spec.gain * v);
if spec.swell {
for &idx in &swelled {
drum = drum.with_band_ext(idx, 0.0, 0.05, 0.5);
}
}
if let Some((rate, depth)) = spec.shimmer {
drum = drum.with_shimmer(rate, depth);
}
Some(Box::new(drum))
}
#[allow(clippy::too_many_arguments)]
fn crash_spec(
kit: Kit,
base: f32,
hp: f32,
life: f32,
gain: f32,
velnorm: f32,
t60_v1: (f32, f32),
t60_v2: (f32, f32),
) -> CymSpec {
let v2_kit = match kit {
Kit::V1 => false,
Kit::V2 | Kit::V3 | Kit::Brush | Kit::Synth => true,
};
let (t60_first, t60_last) = if v2_kit { t60_v2 } else { t60_v1 };
let (wash_amp, wash_t60) = if v2_kit {
(CRASH_WASH_AMP_V2, 2.6)
} else {
(1.0, 1.9)
};
let (v2, shimmer) = if v2_kit {
(
Some(CrashV2 {
twin_df: CRASH_TWIN_DF_HZ,
pairs_t60: 2.8,
low_pair: true,
}),
Some((9.0, 0.35)),
)
} else {
(None, None)
};
CymSpec {
base,
tone_amp: 0.13,
t60_first,
t60_last,
noise: (wash_amp, wash_t60, hp),
life,
gain,
click: Some((0.7 * velnorm, 0.004, 8000.0)),
swell: true,
pairs: true,
v2,
noise2: None,
shimmer,
}
}
fn metal_spec_for(key: u8) -> MetalSpec {
match key {
49 => MetalSpec {
lo: 650.0,
hi: 14_000.0,
singles: 12,
pairs: 16,
tone_amp: 0.028,
t60_low: 1.35,
t60_high: 0.22,
wash_amp: 1.25,
wash_t60: 3.0,
wash_hp: 1900.0,
mid_amp: 0.26,
mid_hz: 2600.0,
click_amp: 0.95,
click_t60: 0.004,
click_hp: 8500.0,
life: 4.2,
gain: 0.34,
swell: true,
shimmer: Some((11.0, 0.28)),
bloom: Some(BloomSpec {
floor: [0.40, 0.18, 0.10],
tau_a: 21.0,
tau_b: 6.0,
depth_scale: 1.0,
}),
},
57 => MetalSpec {
lo: 560.0,
hi: 13_000.0,
singles: 12,
pairs: 16,
tone_amp: 0.027,
t60_low: 1.25,
t60_high: 0.20,
wash_amp: 1.22,
wash_t60: 3.2,
wash_hp: 1800.0,
mid_amp: 0.24,
mid_hz: 2300.0,
click_amp: 0.90,
click_t60: 0.004,
click_hp: 8200.0,
life: 4.6,
gain: 0.35,
swell: true,
shimmer: Some((10.0, 0.28)),
bloom: Some(BloomSpec {
floor: [0.40, 0.18, 0.10],
tau_a: 21.0,
tau_b: 6.0,
depth_scale: 1.0,
}),
},
51 | 59 => MetalSpec {
lo: 950.0,
hi: 12_000.0,
singles: 14,
pairs: 11,
tone_amp: 0.036,
t60_low: 1.8,
t60_high: 0.28,
wash_amp: 0.42,
wash_t60: 2.2,
wash_hp: 5200.0,
mid_amp: 0.10,
mid_hz: 3400.0,
click_amp: 1.05,
click_t60: 0.045,
click_hp: 7600.0,
life: 2.8,
gain: 0.39,
swell: false,
shimmer: Some((18.0, 0.16)),
bloom: Some(BloomSpec {
floor: [0.92, 0.88, 0.85],
tau_a: 21.0,
tau_b: 6.0,
depth_scale: 0.15,
}),
},
52 => MetalSpec {
lo: 760.0,
hi: 14_500.0,
singles: 10,
pairs: 14,
tone_amp: 0.024,
t60_low: 0.75,
t60_high: 0.16,
wash_amp: 1.75,
wash_t60: 0.62,
wash_hp: 5200.0,
mid_amp: 0.36,
mid_hz: 1850.0,
click_amp: 0.45,
click_t60: 0.006,
click_hp: 9000.0,
life: 1.2,
gain: 0.44,
swell: false,
shimmer: Some((24.0, 0.22)),
bloom: Some(BloomSpec {
floor: [0.35, 0.15, 0.10],
tau_a: 13.0,
tau_b: 4.0,
depth_scale: 1.0,
}),
},
55 => MetalSpec {
lo: 1100.0,
hi: 14_000.0,
singles: 8,
pairs: 12,
tone_amp: 0.030,
t60_low: 0.58,
t60_high: 0.14,
wash_amp: 1.05,
wash_t60: 0.34,
wash_hp: 4600.0,
mid_amp: 0.16,
mid_hz: 3200.0,
click_amp: 0.55,
click_t60: 0.004,
click_hp: 8500.0,
life: 0.75,
gain: 0.42,
swell: false,
shimmer: Some((22.0, 0.16)),
bloom: Some(BloomSpec {
floor: [0.40, 0.20, 0.12],
tau_a: 10.0,
tau_b: 3.0,
depth_scale: 1.0,
}),
},
_ => unreachable!("no V3 metal profile for key {key}"),
}
}
fn metal_plate(key: u8, sr: f32, seed: u32, vel: u8) -> Box<dyn Voice> {
Box::new(MetalPlate::new(&metal_spec_for(key), sr, seed, vel))
}
const BRUSH_TAP_TONES: [(f32, f32, f32, f32); 4] = [
(186.0, 0.8, 0.055, 4.0),
(280.0, 0.35, 0.045, 2.0),
(330.0, 0.30, 0.040, 2.0),
(430.0, 0.18, 0.030, 2.0),
];
const BRUSH_TAP_GAIN: f32 = 1.15;
const BRUSH_SLAP_GAIN: f32 = 1.26;
const BRUSH_SWIRL_GAIN: f32 = 0.48;
const BRUSH_CLOSED_HAT_GAIN: f32 = 0.36;
const BRUSH_OPEN_HAT_GAIN: f32 = 0.44;
const BRUSH_RIM_GAIN: f32 = 0.34;
const BRUSH_KICK_GAIN: f32 = 0.85;
const BRUSH_SWIRL_AM_RATE_HZ: f32 = 5.0;
const BRUSH_SWIRL_AM_DEPTH: f32 = 0.65;
fn brush_tap_noise(sr: f32) -> [(f32, f32, Biquad); 2] {
[
(0.50, 0.055, Biquad::bandpass(1300.0, 0.7, sr)),
(0.55, 0.075, Biquad::bandpass(2900.0, 0.8, sr)),
]
}
fn brush_tap(vel: u8, sr: f32, seed: u32) -> Option<Box<dyn Voice>> {
let v = vel_amp(vel);
let velnorm = vel as f32 / 127.0;
let (tones, noise) = membrane_velocity(&BRUSH_TAP_TONES, &brush_tap_noise(sr), velnorm);
Some(Box::new(Drum::new(
sr,
seed,
&tones,
&noise,
0.35,
BRUSH_TAP_GAIN * v,
)) as Box<dyn Voice>)
}
fn brush_slap(vel: u8, sr: f32, seed: u32) -> Option<Box<dyn Voice>> {
let v = vel_amp(vel);
let velnorm = vel as f32 / 127.0;
let (tones, noise) = membrane_velocity(&BRUSH_TAP_TONES, &brush_tap_noise(sr), velnorm);
let reexcite = 0.50 * noise_vel_gain(velnorm);
let drum = Drum::new(sr, seed, &tones, &noise, 0.40, BRUSH_SLAP_GAIN * v)
.with_bursts(&[(0.012, reexcite)]);
Some(Box::new(drum) as Box<dyn Voice>)
}
fn brush_swirl_drum(vel: u8, sr: f32, seed: u32) -> Drum {
let v = vel_amp(vel);
let velnorm = vel as f32 / 127.0;
let amp = 0.7 + 0.3 * velnorm;
Drum::new(
sr,
seed,
&[],
&[
(amp, 0.32, Biquad::bandpass(2100.0, 0.8, sr)),
(0.85 * amp, 0.30, Biquad::bandpass(3100.0, 0.8, sr)),
(1.45 * amp, 0.32, Biquad::bandpass(4300.0, 0.8, sr)),
],
0.85,
BRUSH_SWIRL_GAIN * v,
)
.with_band_ext(0, 0.0, 0.18, 0.0)
.with_band_ext(1, 0.22, 0.16, 0.0)
.with_band_ext(2, 0.45, 0.14, 0.0)
}
fn brush_swirl(vel: u8, sr: f32, seed: u32) -> Option<Box<dyn Voice>> {
Some(Box::new(
brush_swirl_drum(vel, sr, seed).with_shimmer(BRUSH_SWIRL_AM_RATE_HZ, BRUSH_SWIRL_AM_DEPTH),
) as Box<dyn Voice>)
}
fn brush_closed_hat(vel: u8, sr: f32, seed: u32) -> Option<Box<dyn Voice>> {
let velnorm = vel as f32 / 127.0;
cymbal(
&CymSpec {
base: 3300.0,
tone_amp: 0.10,
t60_first: 0.05,
t60_last: 0.03,
noise: (0.8, 0.035, 3600.0),
life: 0.14,
gain: BRUSH_CLOSED_HAT_GAIN,
click: Some((0.7 * velnorm, 0.005, 3800.0)),
swell: false,
pairs: false,
v2: None,
noise2: Some((0.55, 0.030, 1700.0)),
shimmer: None,
},
sr,
seed,
vel,
)
}
fn brush_open_hat_spec() -> CymSpec {
CymSpec {
base: 3300.0,
tone_amp: 0.10,
t60_first: 0.45,
t60_last: 0.10,
noise: (0.55, 0.30, 3000.0),
life: 0.95,
gain: BRUSH_OPEN_HAT_GAIN,
click: None,
swell: false,
pairs: false,
v2: None,
noise2: Some((0.22, 0.16, 6200.0)),
shimmer: Some((45.0, 0.45)),
}
}
fn brush_open_hat(vel: u8, sr: f32, seed: u32) -> Option<Box<dyn Voice>> {
cymbal(&brush_open_hat_spec(), sr, seed, vel)
}
fn brush_rim(vel: u8, sr: f32, seed: u32) -> Option<Box<dyn Voice>> {
let v = vel_amp(vel);
Some(Box::new(Drum::new(
sr,
seed,
&[(330.0, 0.6, 0.06, 0.0), (620.0, 0.35, 0.045, 0.0)],
&[(0.55, 0.03, Biquad::bandpass(1250.0, 1.2, sr))],
0.2,
BRUSH_RIM_GAIN * v,
)) as Box<dyn Voice>)
}
fn brush_kick(vel: u8, sr: f32, seed: u32) -> Option<Box<dyn Voice>> {
let v = vel_amp(vel);
let velnorm = vel as f32 / 127.0;
let (tones, noise) = membrane_velocity(
&[
(165.0, 0.8, 0.16, 28.0),
(86.0, 1.1 + 0.4 * velnorm, 0.42, 3.0),
(130.0, 0.4 * velnorm, 0.01, 0.0),
],
&[
(0.05, 0.005, Biquad::highpass(1500.0, 0.7, sr)),
(0.03, 0.005, Biquad::bandpass(2200.0, 0.9, sr)),
],
velnorm,
);
Some(Box::new(Drum::new(
sr,
seed,
&tones,
&noise,
0.8,
BRUSH_KICK_GAIN * v,
)) as Box<dyn Voice>)
}
const HYBRID_TOM_MIX: [f32; 3] = [0.55, 0.70, 0.78];
fn hybrid_tom_body(key: u8) -> Option<(f32, f32, f32, f32, f32)> {
let (f0, t60, noise_hz, life, modeled_g, mix) = match key {
47 => (240.0, 0.24, 1500.0, 0.4, 0.64, HYBRID_TOM_MIX[0]),
48 => (293.0, 0.22, 1700.0, 0.36, 0.61, HYBRID_TOM_MIX[1]),
50 => (352.0, 0.20, 1900.0, 0.33, 0.58, HYBRID_TOM_MIX[2]),
_ => return None,
};
Some((f0, t60, noise_hz, life, modeled_g * mix))
}
struct HybridTom {
sample: Box<dyn Voice>,
body: Box<dyn Voice>,
}
impl Voice for HybridTom {
fn render(&mut self, out: &mut [f32]) -> bool {
let sample_active = self.sample.render(out);
let body_active = self.body.render(out);
sample_active || body_active
}
fn note_off(&mut self) {
self.sample.note_off();
self.body.note_off();
}
fn released(&self) -> bool {
true
}
fn choke(&mut self) {
self.sample.choke();
self.body.choke();
}
#[cfg(test)]
fn kind(&self) -> &'static str {
"hybridtom"
}
}
#[rustfmt::skip]
pub(crate) const DRUM_VEL_LEVEL_EXP_V1: [f32; 128] = {
let mut t = [2.0f32; 128];
t[35] = 1.845; t[36] = 1.845; t[38] = 1.651; t[40] = 1.651; t[42] = 1.810; t[44] = 1.810; t
};
#[rustfmt::skip]
pub(crate) const DRUM_VEL_LEVEL_EXP_V3: [f32; 128] = {
let mut t = [2.0f32; 128];
t[35] = 2.198; t[36] = 2.198;
t[42] = 1.506; t[46] = 1.791;
t[43] = 2.171; t[47] = 2.263; t[48] = 2.170;
t[49] = 1.846; t[53] = 1.394; t[57] = 1.846;
t
};
#[rustfmt::skip]
pub(crate) const DRUM_VEL_LEVEL_EXP_BRUSH: [f32; 128] = {
let mut t = [2.0f32; 128];
t[35] = 1.846; t[36] = 1.846;
t[38] = 1.822; t[39] = 1.776; t[40] = 1.816;
t[43] = 2.171; t[47] = 2.263; t[48] = 2.170;
t[49] = 1.846; t[53] = 1.394; t[57] = 1.846;
t
};
#[rustfmt::skip]
pub(crate) const DRUM_VEL_LEVEL_EXP_SYNTH: [f32; 128] = {
let mut t = [2.0f32; 128];
t[35] = 1.769; t[36] = 1.769;
t[38] = 1.545; t[40] = 1.462;
t[42] = 1.810; t[44] = 1.810;
t[51] = 1.770; t[52] = 1.828; t[55] = 1.822; t[59] = 1.770;
t
};
#[rustfmt::skip]
pub(crate) const DRUM_VEL_LEVEL_EXP_BRUSH_MODELED: [f32; 128] = {
let mut t = [2.0f32; 128];
t[35] = 1.846; t[36] = 1.846;
t[38] = 1.822; t[39] = 1.776; t[40] = 1.816;
t[51] = 1.770; t[52] = 1.828; t[55] = 1.822; t[59] = 1.770;
t
};
pub(crate) fn drum_vel_level_exp(kit: Kit, samples: bool, key: u8) -> f32 {
let t = match (kit, samples) {
(Kit::V1 | Kit::V2, _) => &DRUM_VEL_LEVEL_EXP_V1,
(Kit::Synth, _) => &DRUM_VEL_LEVEL_EXP_SYNTH,
(Kit::V3, false) => &DRUM_VEL_LEVEL_EXP_SYNTH, (Kit::V3, true) => &DRUM_VEL_LEVEL_EXP_V3,
(Kit::Brush, true) => &DRUM_VEL_LEVEL_EXP_BRUSH,
(Kit::Brush, false) => &DRUM_VEL_LEVEL_EXP_BRUSH_MODELED,
};
t[key as usize]
}
pub fn make(
key: u8,
vel: u8,
sr: f32,
seed: u32,
kit: Kit,
samples: bool,
rr: u8,
) -> Option<Box<dyn Voice>> {
let samples = samples && crate::embedded_samples_available();
let voice = make_uncorrected(key, vel, sr, seed, kit, samples, rr)?;
let exp = drum_vel_level_exp(kit, samples, key);
if exp == 2.0 {
return Some(voice);
}
Some(Box::new(crate::voices::ScaledVoice {
inner: voice,
exp,
g: crate::voices::ScaledVoice::gain(vel, exp),
scratch: Vec::new(),
}))
}
fn make_uncorrected(
key: u8,
vel: u8,
sr: f32,
seed: u32,
kit: Kit,
samples: bool,
rr: u8,
) -> Option<Box<dyn Voice>> {
let samples = samples && crate::embedded_samples_available();
let samples = samples && kit != Kit::Synth;
let kit = if kit == Kit::Synth { Kit::V3 } else { kit };
let v = vel_amp(vel);
let velnorm = vel as f32 / 127.0;
let d = |tones: &[(f32, f32, f32, f32)], noise: &[(f32, f32, Biquad)], life: f32, g: f32| {
Some(Box::new(Drum::new(sr, seed, tones, noise, life, g * v)) as Box<dyn Voice>)
};
let dm = |tones: &[(f32, f32, f32, f32)], noise: &[(f32, f32, Biquad)], life: f32, g: f32| {
let (tones, noise) = membrane_velocity(tones, noise, velnorm);
Some(Box::new(Drum::new(sr, seed, &tones, &noise, life, g * v)) as Box<dyn Voice>)
};
let one = |amp: f32, t: f32, filt: Biquad| vec![(amp, t, filt)];
let tom = |f0: f32, t60: f32, noise: &[(f32, f32, Biquad)], life: f32, g: f32| {
let drum = match kit {
Kit::V1 => {
let (tones, nb) = membrane_velocity(&tom_tones(f0, t60, 10.0), noise, velnorm);
Drum::new(sr, seed, &tones, &nb, life, g * v)
}
Kit::V2 => {
let start = f0 * TOM_OVERSHOOT;
let (tones, nb) =
membrane_velocity(&tom_tones(start, t60, TOM_GLIDE_V2), noise, velnorm);
Drum::new(sr, seed, &tones, &nb, life, g * v).with_glide_floor(1.0 / TOM_OVERSHOOT)
}
Kit::V3 | Kit::Brush | Kit::Synth => {
let start = f0 * TOM_OVERSHOOT;
let mut tv: Vec<(f32, f32, f32, f32)> =
tom_tones_v3(start, t60, TOM_GLIDE_V2).to_vec();
tv.push((start + TOM_BEAT_DF, 0.6, t60 * 1.6, TOM_GLIDE_V2));
tv.push((
start * 1.5 + TOM_BEAT_DF,
0.3,
t60 * 0.72 * 1.6,
TOM_GLIDE_V2,
));
let mut nb: Vec<(f32, f32, Biquad)> = noise.to_vec();
nb.push((0.18, 0.03, Biquad::highpass(1600.0, 0.7, sr)));
nb.push((0.4, 0.004, Biquad::highpass(4000.0, 0.7, sr)));
let (tones, nbv) = membrane_velocity(&tv, &nb, velnorm);
Drum::new(sr, seed, &tones, &nbv, life, g * v).with_glide_floor(1.0 / TOM_OVERSHOOT)
}
};
Some(Box::new(drum) as Box<dyn Voice>)
};
if kit == Kit::Brush {
match key {
38 => return brush_tap(vel, sr, seed),
39 => return brush_slap(vel, sr, seed),
40 => return brush_swirl(vel, sr, seed),
42 | 44 => return brush_closed_hat(vel, sr, seed),
46 => return brush_open_hat(vel, sr, seed),
37 => return brush_rim(vel, sr, seed),
35 | 36 => return brush_kick(vel, sr, seed),
_ => {} }
}
if samples && matches!(kit, Kit::V3 | Kit::Brush) {
if let Some(v) = sampler::sampled_drum(key, vel, seed, rr, sr) {
if let Some((f0, t60, noise_hz, life, g)) = hybrid_tom_body(key) {
if let Some(body) = tom(
f0,
t60,
&one(0.2, 0.04, Biquad::bandpass(noise_hz, 0.8, sr)),
life,
g,
) {
return Some(Box::new(HybridTom { sample: v, body }) as Box<dyn Voice>);
}
}
return Some(v);
}
}
let key = match key {
27 => 80, 28 => 39, 29 => 73, 30 => 74, 31 => 37, 32 => 37, 33 => 37, 34 => 80, 83 => 54, 84 => 81, 85 => 75, k => k,
};
match key {
35 | 36 => {
if matches!(kit, Kit::V3 | Kit::Brush) {
let tones = [
(172.0, 0.76, 0.14, 30.0),
(78.0, 1.25 + 0.35 * velnorm, 0.46, 2.6),
(118.0, 0.38, 0.05, 0.0),
(255.0, 0.22 * velnorm, 0.018, 0.0),
];
let noise = [
(0.45, 0.006, Biquad::highpass(2300.0, 0.7, sr)),
(0.42, 0.006, Biquad::bandpass(3600.0, 0.9, sr)),
(0.18, 0.030, Biquad::bandpass(820.0, 0.9, sr)),
];
dm(&tones, &noise, 0.8, 1.0)
} else {
let tones = [
(165.0, 0.8, 0.16, 28.0),
(86.0, 1.1 + 0.4 * velnorm, 0.42, 3.0),
(130.0, 0.4 * velnorm, 0.01, 0.0),
];
let noise = [
(0.5, 0.005, Biquad::highpass(2500.0, 0.7, sr)),
(0.3, 0.005, Biquad::bandpass(3500.0, 0.9, sr)),
];
dm(&tones, &noise, 0.8, 1.0)
}
}
37 => d(
&[(430.0, 0.5, 0.05, 0.0)],
&one(0.6, 0.03, Biquad::bandpass(2200.0, 1.5, sr)),
0.2,
0.55,
),
38 | 40 => match kit {
Kit::V1 => {
let (tones, noise) = membrane_velocity(
&SNARE_TONES,
&[
(0.55, 0.09, Biquad::bandpass(1300.0, 0.7, sr)),
(
0.75,
0.19,
Biquad::highpass(2800.0 * (0.85 + 0.35 * velnorm), 0.7, sr),
),
(0.35, 0.35, Biquad::highpass(1800.0, 0.7, sr)),
],
velnorm,
);
let drum = Drum::new(sr, seed, &tones, &noise, 0.6, 0.68 * v)
.with_band_ext(1, 0.0015, 0.0005, 0.0)
.with_band_ext(2, 0.0015, 0.0005, 0.0);
Some(Box::new(drum) as Box<dyn Voice>)
}
Kit::V2 | Kit::V3 | Kit::Brush | Kit::Synth => {
let head: &[(f32, f32, f32, f32)] = if matches!(kit, Kit::V2) {
&SNARE_TONES
} else {
&SNARE_TONES_V3
};
let mut nb = vec![
(0.55f32, 0.09f32, Biquad::bandpass(1300.0, 0.7, sr)),
(0.22, 0.35, Biquad::highpass(1800.0, 0.7, sr)),
];
if !matches!(kit, Kit::V2) {
nb.push((0.55, 0.003, Biquad::highpass(5000.0, 0.7, sr)));
}
let (tones, noise) = membrane_velocity(head, &nb, velnorm);
let drum = Drum::new(sr, seed, &tones, &noise, 0.6, 0.68 * v)
.with_band_ext(1, 0.0015, 0.0005, 0.0);
let (center_mul, am_depth) = if key == 40 { (1.15, 0.48) } else { (1.0, 0.6) };
let bands = [
Biquad::bandpass(WIRE_CENTERS[0] * center_mul, WIRE_QS[0], sr),
Biquad::bandpass(WIRE_CENTERS[1] * center_mul, WIRE_QS[1], sr),
Biquad::bandpass(WIRE_CENTERS[2] * center_mul, WIRE_QS[2], sr),
];
let d1_noise = noise_vel_gain(velnorm);
let base_amp = 0.75 * d1_noise * WIRE_MAKEUP;
let (grain, amp) = if matches!(kit, Kit::V2) {
(None, base_amp)
} else {
let rate = if key == 40 { GRAIN_RATE_EL } else { GRAIN_RATE };
(
Some(Grain {
e: 0.0,
decay: dmul(GRAIN_T60, sr),
rate,
floor: GRAIN_FLOOR,
}),
base_amp * GRAIN_MAKEUP,
)
};
let wire = WireRes {
bands,
gains: [1.0, 0.55 + 0.45 * velnorm, 0.30 + 0.70 * velnorm],
amp,
decay: dmul(0.19, sr),
onset: (0.0015 * sr) as u32,
env: 0.0,
atk: 1.0 / (0.0005 * sr),
head_amp0: drum.tones[0].amp,
head_track: WIRE_HEAD_TRACK,
am_depth,
grain,
};
Some(Box::new(drum.with_wire(wire)) as Box<dyn Voice>)
}
},
39 => Some(Box::new(
Drum::new(
sr,
seed,
&[],
&[(0.9, 0.10, Biquad::bandpass(1100.0, 1.2, sr))],
0.35,
0.70 * v,
)
.with_bursts(&[(0.010, 0.75), (0.022, 0.6)]),
) as Box<dyn Voice>), 41 => tom(
100.0,
0.32,
&one(0.25, 0.05, Biquad::bandpass(900.0, 0.8, sr)),
0.55,
0.78,
),
43 => tom(
140.0,
0.30,
&one(0.25, 0.05, Biquad::bandpass(1100.0, 0.8, sr)),
0.5,
0.74,
),
45 => tom(
190.0,
0.28,
&one(0.25, 0.05, Biquad::bandpass(1300.0, 0.8, sr)),
0.45,
0.69,
),
47 => tom(
240.0,
0.24,
&one(0.2, 0.04, Biquad::bandpass(1500.0, 0.8, sr)),
0.4,
0.64,
),
48 => tom(
293.0,
0.22,
&one(0.2, 0.04, Biquad::bandpass(1700.0, 0.8, sr)),
0.36,
0.61,
),
50 => tom(
352.0,
0.20,
&one(0.2, 0.04, Biquad::bandpass(1900.0, 0.8, sr)),
0.33,
0.58,
),
42 => cymbal(
&CymSpec {
base: 3300.0,
tone_amp: 0.10,
t60_first: 0.05,
t60_last: 0.03,
noise: (0.8, 0.035, 6500.0),
life: 0.14,
gain: 0.42,
click: Some((1.8 * velnorm, 0.005, 9000.0)),
swell: false,
pairs: false,
v2: None,
noise2: None,
shimmer: None,
},
sr,
seed,
vel,
),
44 => cymbal(
&CymSpec {
base: 2900.0,
tone_amp: 0.08,
t60_first: 0.04,
t60_last: 0.025,
noise: (0.85, 0.028, 5200.0),
life: 0.10,
gain: 0.60,
click: None,
swell: false,
pairs: false,
v2: None,
noise2: None,
shimmer: None,
},
sr,
seed,
vel,
),
46 => {
let spec = if matches!(kit, Kit::V2 | Kit::V3 | Kit::Brush) {
CymSpec {
base: 3300.0,
tone_amp: 0.10,
t60_first: 0.45,
t60_last: 0.10,
noise: (0.55, 0.30, 6000.0),
life: 0.95,
gain: 0.40,
click: None,
swell: false,
pairs: false,
v2: None,
noise2: Some((0.55, 0.16, 10000.0)),
shimmer: Some((45.0, 0.45)),
}
} else {
CymSpec {
base: 3300.0,
tone_amp: 0.10,
t60_first: 0.30,
t60_last: 0.18,
noise: (0.8, 0.28, 6000.0),
life: 0.95,
gain: 0.40,
click: None,
swell: false,
pairs: false,
v2: None,
noise2: None,
shimmer: None,
}
};
cymbal(&spec, sr, seed, vel)
}
49 if matches!(kit, Kit::V3 | Kit::Brush) => Some(metal_plate(49, sr, seed, vel)),
49 => {
let spec = crash_spec(
kit,
950.0,
4200.0,
4.2,
0.50,
velnorm,
(2.6, 1.0),
(1.7, 0.9),
);
cymbal(&spec, sr, seed, vel)
}
52 if matches!(kit, Kit::V3 | Kit::Brush) => Some(metal_plate(52, sr, seed, vel)),
52 => cymbal(
&CymSpec {
base: 1400.0,
tone_amp: 0.09,
t60_first: 0.62,
t60_last: 0.26,
noise: (1.7, 0.45, 7500.0),
life: 1.0,
gain: 0.50,
click: None,
swell: false,
pairs: false,
v2: None,
noise2: None,
shimmer: None,
},
sr,
seed,
vel,
),
55 if matches!(kit, Kit::V3 | Kit::Brush) => Some(metal_plate(55, sr, seed, vel)),
55 => cymbal(
&CymSpec {
base: 1600.0,
tone_amp: 0.12,
t60_first: 0.4,
t60_last: 0.2,
noise: (0.9, 0.25, 5000.0),
life: 0.6,
gain: 0.45,
click: None,
swell: false,
pairs: false,
v2: None,
noise2: None,
shimmer: None,
},
sr,
seed,
vel,
),
57 if matches!(kit, Kit::V3 | Kit::Brush) => Some(metal_plate(57, sr, seed, vel)),
57 => {
let spec = crash_spec(
kit,
820.0,
3800.0,
4.6,
0.52,
velnorm,
(2.4, 1.0),
(1.6, 0.9),
);
cymbal(&spec, sr, seed, vel)
}
51 | 59 if matches!(kit, Kit::V3 | Kit::Brush) => Some(metal_plate(key, sr, seed, vel)),
51 | 59 => cymbal(
&CymSpec {
base: 1150.0,
tone_amp: 0.16,
t60_first: 0.9,
t60_last: 0.30,
noise: (0.30, 1.3, 6000.0),
life: 2.6,
gain: 0.42,
click: Some((0.55 * (0.4 + 0.6 * velnorm), 0.07, 7500.0)),
swell: false,
pairs: false,
v2: None,
noise2: None,
shimmer: None,
},
sr,
seed,
vel,
),
53 => d(
&[
(1650.0, 0.50, 0.60, 0.0),
(1650.0 * 1.483, 0.38, 0.55, 0.0),
(1650.0 * 1.932, 0.30, 0.50, 0.0),
(1650.0 * 2.546, 0.24, 0.42, 0.0),
(1650.0 * 3.363, 0.18, 0.34, 0.0),
(1650.0 * 4.365, 0.13, 0.26, 0.0),
],
&[
(1.6 * velnorm, 0.005, Biquad::highpass(7000.0, 0.7, sr)),
(0.05, 0.4, Biquad::highpass(6000.0, 0.7, sr)),
],
1.5,
0.5,
),
54 => d(
&[(6100.0, 0.15, 0.10, 0.0), (7900.0, 0.12, 0.09, 0.0)],
&one(0.9, 0.11, Biquad::highpass(5200.0, 0.8, sr)),
0.35,
0.42,
),
56 => d(
&[(560.0, 0.8, 0.22, 0.0), (845.0, 0.55, 0.18, 0.0)],
&one(0.15, 0.02, Biquad::bandpass(700.0, 2.0, sr)),
0.5,
0.55,
),
58 => Some(Box::new(
Drum::new(
sr,
seed,
&[
(1730.0, 0.45, 0.50, 0.0),
(2470.0, 0.30, 0.42, 0.0),
(3150.0, 0.18, 0.35, 0.0),
],
&one(0.45, 0.45, Biquad::bandpass(2100.0, 1.0, sr)),
0.85,
0.45 * v,
)
.with_shimmer(28.0, 0.9),
) as Box<dyn Voice>),
60 => dm(
&[(400.0, 1.0, 0.11, 3.0)],
&one(0.35, 0.02, Biquad::bandpass(1400.0, 1.0, sr)),
0.3,
0.6,
),
61 => dm(
&[(300.0, 1.0, 0.13, 3.0)],
&one(0.35, 0.02, Biquad::bandpass(1100.0, 1.0, sr)),
0.3,
0.6,
),
62 => dm(
&[(230.0, 1.0, 0.05, 0.0)],
&one(0.3, 0.015, Biquad::bandpass(1200.0, 1.0, sr)),
0.2,
0.6,
),
63 => dm(
&[(190.0, 1.0, 0.24, 3.0)],
&one(0.3, 0.02, Biquad::bandpass(1000.0, 1.0, sr)),
0.4,
0.65,
),
64 => dm(
&[(145.0, 1.0, 0.28, 3.0)],
&one(0.3, 0.02, Biquad::bandpass(800.0, 1.0, sr)),
0.45,
0.65,
),
65 => dm(
&[(430.0, 0.9, 0.15, 4.0)],
&one(0.3, 0.02, Biquad::bandpass(1600.0, 1.0, sr)),
0.3,
0.6,
),
66 => dm(
&[(320.0, 0.9, 0.16, 4.0)],
&one(0.3, 0.02, Biquad::bandpass(1200.0, 1.0, sr)),
0.3,
0.6,
),
67 => d(
&[
(1650.0, 0.9, 0.20, 0.0),
(2805.0, 0.55, 0.16, 0.0),
(4700.0, 0.18, 0.10, 0.0),
],
&one(0.12, 0.008, Biquad::bandpass(3500.0, 1.0, sr)),
0.5,
0.50,
),
68 => d(
&[
(1220.0, 0.9, 0.22, 0.0),
(2074.0, 0.55, 0.17, 0.0),
(3477.0, 0.18, 0.11, 0.0),
],
&one(0.12, 0.008, Biquad::bandpass(2900.0, 1.0, sr)),
0.5,
0.50,
),
69 => d(
&[],
&one(1.0, 0.08, Biquad::highpass(5500.0, 0.7, sr)),
0.22,
0.40,
),
70 => d(
&[],
&one(1.0, 0.030, Biquad::highpass(3600.0, 0.7, sr)),
0.10,
0.40,
),
82 => d(
&[],
&one(1.0, 0.055, Biquad::highpass(4200.0, 0.7, sr)),
0.18,
0.40,
),
71 | 72 => {
let (t60, life) = if key == 71 {
(0.10, 0.18)
} else {
(0.35, 0.50)
};
d(
&[(2350.0, 0.55, t60, 0.0)],
&[
(0.30, t60, Biquad::bandpass(2350.0, 6.0, sr)),
(0.10, t60 * 0.8, Biquad::highpass(5000.0, 0.7, sr)),
],
life,
0.50,
)
}
73 | 74 => {
let (step, n, center, life) = if key == 73 {
(0.011f32, 10usize, 2600.0, 0.16)
} else {
(0.016, 28, 2200.0, 0.50)
};
let bursts: Vec<(f32, f32)> = (1..n)
.map(|i| {
let frac = i as f32 / n as f32;
(i as f32 * step, 0.9 * (1.0 - 0.5 * frac))
})
.collect();
Some(Box::new(
Drum::new(
sr,
seed,
&[],
&one(0.9, 0.012, Biquad::bandpass(center, 1.2, sr)),
life,
0.50 * v,
)
.with_bursts(&bursts),
) as Box<dyn Voice>)
}
75 => d(
&[(2500.0, 1.0, 0.09, 0.0)],
&one(0.1, 0.01, Biquad::bandpass(2500.0, 3.0, sr)),
0.25,
0.55,
),
76 => d(
&[(850.0, 1.0, 0.09, 0.0)],
&one(0.25, 0.012, Biquad::bandpass(1700.0, 1.5, sr)),
0.25,
0.60,
),
77 => d(
&[(620.0, 1.0, 0.10, 0.0)],
&one(0.25, 0.012, Biquad::bandpass(1300.0, 1.5, sr)),
0.3,
0.60,
),
78 => Some(Box::new(
Drum::new(
sr,
seed,
&[(640.0, 0.9, 0.12, 7.0), (1280.0, 0.30, 0.10, 7.0)],
&one(0.15, 0.03, Biquad::bandpass(1500.0, 1.0, sr)),
0.18,
0.50 * v,
)
.with_glide_floor(0.55),
) as Box<dyn Voice>),
79 => Some(Box::new(
Drum::new(
sr,
seed,
&[(390.0, 0.9, 0.40, -2.5), (780.0, 0.25, 0.30, -2.5)],
&one(0.12, 0.10, Biquad::bandpass(1200.0, 1.0, sr)),
0.50,
0.50 * v,
)
.with_glide_floor(1.85),
) as Box<dyn Voice>),
81 => d(
&[
(4050.0, 0.7, 1.8, 0.0),
(6420.0, 0.45, 1.5, 0.0),
(8900.0, 0.25, 1.2, 0.0),
],
&one(0.08, 0.01, Biquad::highpass(6000.0, 0.7, sr)),
2.5,
0.30,
),
80 => d(
&[(4050.0, 0.7, 0.12, 0.0), (6420.0, 0.4, 0.1, 0.0)],
&one(0.08, 0.01, Biquad::highpass(6000.0, 0.7, sr)),
0.3,
0.30,
),
86 => tom(
82.0,
0.16,
&one(0.3, 0.03, Biquad::bandpass(360.0, 0.9, sr)),
0.28,
0.82,
),
87 => tom(
82.0,
0.55,
&one(0.3, 0.05, Biquad::bandpass(360.0, 0.9, sr)),
0.9,
0.85,
),
_ => d(
&[(1000.0, 0.4, 0.05, 0.0)],
&one(0.3, 0.02, Biquad::bandpass(2000.0, 1.0, sr)),
0.15,
0.4,
),
}
}
const TAIKO_B_OVERSHOOT: f32 = 1.0905;
const TAIKO_B_GLIDE_OCT_S: f32 = 1.2;
const TAIKO_B_GAIN: f32 = 0.71;
pub(crate) fn taiko_b(key: u8, vel: u8, sr: f32, seed: u32) -> Box<dyn Voice> {
let f0 = key_freq(key.clamp(31, 55));
let v = vel_amp(vel);
let velnorm = vel as f32 / 127.0;
let start = f0 * TAIKO_B_OVERSHOOT;
let tones = [
(start, 1.00, 1.5, TAIKO_B_GLIDE_OCT_S),
(start * 1.52, 0.32, 0.8, TAIKO_B_GLIDE_OCT_S),
(start * 1.99, 0.18, 0.55, TAIKO_B_GLIDE_OCT_S),
(start * 2.44, 0.09, 0.40, TAIKO_B_GLIDE_OCT_S),
];
let noise = [
(1.1, 0.09, Biquad::lowpass(140.0, 0.8, sr)),
(0.45, 0.020, Biquad::bandpass(1600.0, 0.8, sr)),
];
let (tones, noise) = membrane_velocity(&tones, &noise, velnorm);
Box::new(
Drum::new(sr, seed, &tones, &noise, 2.4, TAIKO_B_GAIN * v)
.with_glide_floor(1.0 / TAIKO_B_OVERSHOOT),
)
}
const MELODIC_TOM_B_GAIN: f32 = 0.97;
pub(crate) fn melodic_tom_b(key: u8, vel: u8, sr: f32, seed: u32) -> Box<dyn Voice> {
let f0 = key_freq(key.clamp(36, 72));
let v = vel_amp(vel);
let velnorm = vel as f32 / 127.0;
let t60 = (0.30 * (196.0 / f0).powf(0.25)).clamp(0.18, 0.45);
let stick_bp = (f0 * 6.0).clamp(600.0, 1600.0);
let noise = [(0.25, 0.05, Biquad::bandpass(stick_bp, 0.8, sr))];
let (tones, noise) = membrane_velocity(
&tom_tones(f0 * TOM_OVERSHOOT, t60, TOM_GLIDE_V2),
&noise,
velnorm,
);
Box::new(
Drum::new(sr, seed, &tones, &noise, 0.75, MELODIC_TOM_B_GAIN * v)
.with_glide_floor(1.0 / TOM_OVERSHOOT),
)
}
const SYNTH_DRUM_B_ZAP_RATIO: f32 = 2.83;
const SYNTH_DRUM_B_GLIDE_OCT_S: f32 = 24.0;
const SYNTH_DRUM_B_GAIN: f32 = 0.80;
pub(crate) fn synth_drum_b(key: u8, vel: u8, sr: f32, seed: u32) -> Box<dyn Voice> {
let f0 = key_freq(key.clamp(33, 81));
let v = vel_amp(vel);
let velnorm = vel as f32 / 127.0;
let tones = [(
f0 * SYNTH_DRUM_B_ZAP_RATIO,
1.0,
0.55,
SYNTH_DRUM_B_GLIDE_OCT_S,
)];
let noise = [(0.15, 0.004, Biquad::highpass(3000.0, 0.7, sr))];
let (tones, noise) = membrane_velocity(&tones, &noise, velnorm);
Box::new(
Drum::new(sr, seed, &tones, &noise, 0.9, SYNTH_DRUM_B_GAIN * v)
.with_glide_floor(1.0 / SYNTH_DRUM_B_ZAP_RATIO),
)
}
const REV_CYM_B_RISE_CAP_S: f32 = 2.5;
const REV_CYM_B_STOP_T60: f32 = 0.008;
const REV_CYM_B_LIFE_AFTER_STOP_S: f32 = 0.15;
pub(crate) const REV_CYM_B_BASE_HZ: f32 = 950.0;
const REV_CYM_B_TONE_AMP: f32 = 0.13;
const REV_CYM_B_GAIN: f32 = 0.50;
struct RevPartial {
phase: f32,
freq: f32,
amp: f32, target: f32, rise: f32, onset: u32, }
struct RevBand {
filt: Biquad,
amp: f32,
target: f32,
rise: f32,
onset: u32,
}
struct RevCymB {
partials: Vec<RevPartial>,
bands: Vec<RevBand>,
rng: Rng,
t: u32,
cap: u32, life: u32, stopped: bool,
stop_mul: f32,
released: bool,
gain: f32,
sr: f32,
}
impl Voice for RevCymB {
fn render(&mut self, out: &mut [f32]) -> bool {
for o in out.iter_mut() {
if self.t >= self.life {
return false;
}
if !self.stopped && (self.released || self.t >= self.cap) {
self.stopped = true;
self.life = self
.life
.min(self.t + (REV_CYM_B_LIFE_AFTER_STOP_S * self.sr) as u32);
}
let mut s = 0.0;
let white = self.rng.white();
for p in &mut self.partials {
if self.t >= p.onset {
s += p.amp * p.phase.sin();
p.phase += TAU * p.freq / self.sr;
if p.phase > TAU {
p.phase -= TAU;
}
if self.stopped {
p.amp *= self.stop_mul;
} else if p.amp < p.target {
p.amp = (p.amp * p.rise).min(p.target);
}
}
}
for b in &mut self.bands {
if self.t >= b.onset && b.amp > 1e-6 {
s += b.filt.process(white) * b.amp;
if self.stopped {
b.amp *= self.stop_mul;
} else if b.amp < b.target {
b.amp = (b.amp * b.rise).min(b.target);
}
}
}
*o += s * self.gain;
self.t += 1;
}
true
}
fn note_off(&mut self) {
self.released = true;
}
fn released(&self) -> bool {
self.released
}
#[cfg(test)]
fn kind(&self) -> &'static str {
"revcym_b"
}
}
pub(crate) fn reverse_cymbal_b(key: u8, vel: u8, sr: f32, seed: u32) -> Box<dyn Voice> {
let v = vel_amp(vel);
let velnorm = vel as f32 / 127.0;
let base = REV_CYM_B_BASE_HZ * 2f32.powf((key.clamp(48, 72) as f32 - 60.0) / 24.0);
let cap = (REV_CYM_B_RISE_CAP_S * sr) as u32;
let rise_for = |t60: f32| 10f32.powf(3.0 / (t60.max(0.005) * sr)); let mut partials = Vec::with_capacity(METAL_RATIOS.len());
for (i, &r) in METAL_RATIOS.iter().enumerate() {
let frac = i as f32 / (METAL_RATIOS.len() - 1) as f32;
let target =
REV_CYM_B_TONE_AMP * (1.0 - 0.6 * frac) * (0.55 + 0.55 * velnorm.powf(frac + 0.5));
let t60 = 2.2 + (0.8 - 2.2) * frac;
partials.push(RevPartial {
phase: 0.0,
freq: base * r,
amp: target * 1e-3,
target,
rise: rise_for(t60),
onset: ((REV_CYM_B_RISE_CAP_S - t60).max(0.0) * sr) as u32,
});
}
let bands = vec![
RevBand {
filt: Biquad::highpass(3500.0, 0.7, sr),
amp: 0.8e-3,
target: 0.8,
rise: rise_for(REV_CYM_B_RISE_CAP_S),
onset: 0,
},
RevBand {
filt: Biquad::highpass(8500.0, 0.7, sr),
amp: 0.55e-3,
target: 0.55,
rise: rise_for(0.9),
onset: (1.6 * sr) as u32,
},
];
Box::new(RevCymB {
partials,
bands,
rng: Rng::new(seed ^ 0x9EC7_3119),
t: 0,
cap,
life: cap + (REV_CYM_B_LIFE_AFTER_STOP_S * sr) as u32,
stopped: false,
stop_mul: dmul(REV_CYM_B_STOP_T60, sr),
released: false,
gain: REV_CYM_B_GAIN * v,
sr,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil;
#[test]
fn membrane_velocity_transform_is_directional() {
let sr = 44100.0;
let tones = [(100.0, 1.0, 0.32, 10.0)];
let noise = [(0.25, 0.05, Biquad::bandpass(900.0, 0.8, sr))];
let (t_hard, n_hard) = membrane_velocity(&tones, &noise, 1.0);
let (t_soft, n_soft) = membrane_velocity(&tones, &noise, 0.0);
assert!(t_hard[0].0 > t_soft[0].0, "start pitch");
assert!(t_hard[0].2 > t_soft[0].2, "decay t60");
assert!(t_hard[0].3 > t_soft[0].3, "glide rate");
assert!(n_hard[0].0 > n_soft[0].0, "click/noise energy");
assert!((n_hard[0].0 / n_soft[0].0 - 2.0).abs() < 1e-4);
}
fn render_drum_v1(key: u8, vel: u8, secs: f32) -> Vec<f32> {
render_drum_kit(key, vel, secs, Kit::V1)
}
fn for_each_kit(mut check: impl FnMut(Kit, &str)) {
check(Kit::V3, "V3 (engine default)");
check(Kit::V1, "V1 (legacy, PC 25)");
}
fn render_drum_kit(key: u8, vel: u8, secs: f32, kit: Kit) -> Vec<f32> {
render_drum_kit_samples(key, vel, secs, kit, false)
}
fn render_drum_kit_samples(key: u8, vel: u8, secs: f32, kit: Kit, samples: bool) -> Vec<f32> {
let sr = 44100.0;
let mut v = make(key, vel, sr, 7, kit, samples, 0).unwrap();
let mut buf = vec![0f32; (sr * secs) as usize];
v.render(&mut buf);
buf
}
fn sec_window(s: &[f32], sr: f32, a: f32, b: f32) -> &[f32] {
&s[(a * sr) as usize..(b * sr) as usize]
}
#[test]
fn kit_v2_seam_wired_and_inert_for_untouched_keys() {
let sr = 44100.0;
let render = |kit| {
let mut v = make(51, 100, sr, 7, kit, false, 0).unwrap();
let mut buf = vec![0f32; (sr * 1.5) as usize];
v.render(&mut buf);
buf
};
let v1 = render(Kit::V1);
let v2 = render(Kit::V2);
assert!(v1.iter().any(|&x| x.abs() > 1e-4), "ride voice makes sound");
assert_eq!(v1, v2, "kit-agnostic key 51 identical under V1 and V2");
}
#[test]
fn v1_drum_render_signatures_are_stable() {
let cases = [
(
36,
testutil::RenderSignature {
rms_db: -12.058,
centroid_hz: 188.589,
late_early_db: -7.436,
},
),
(
38,
testutil::RenderSignature {
rms_db: -21.062,
centroid_hz: 957.116,
late_early_db: -15.458,
},
),
(
42,
testutil::RenderSignature {
rms_db: -36.138,
centroid_hz: 7887.204,
late_early_db: -59.097,
},
),
(
49,
testutil::RenderSignature {
rms_db: -19.276,
centroid_hz: 5960.781,
late_early_db: 1.826,
},
),
(
41,
testutil::RenderSignature {
rms_db: -15.884,
centroid_hz: 184.404,
late_early_db: -7.775,
},
),
];
for (key, expected) in cases {
let rendered = render_drum_kit(key, 100, 1.0, Kit::V1);
testutil::assert_render_signature(
&format!("V1 drum key {key}"),
testutil::render_signature(
&rendered,
44100.0,
(0.0, 0.20),
(0.0, 0.02),
(0.04, 0.08),
),
expected,
);
}
}
#[test]
fn dr_o1_crash_v2_diverges_from_v1() {
let v1 = render_drum_kit(49, 100, 1.0, Kit::V1);
let v2 = render_drum_kit(49, 100, 1.0, Kit::V2);
assert!(v1.iter().any(|&x| x.abs() > 1e-4), "v1 crash makes sound");
assert!(v2.iter().any(|&x| x.abs() > 1e-4), "v2 crash makes sound");
assert_ne!(v1, v2, "crash 49 must differ under kit v2 (DR1 engaged)");
}
fn twin_beat_cv(buf: &[f32]) -> f32 {
let sr = 44100.0f32;
let a = (0.3 * sr) as usize;
let b = (1.2 * sr) as usize;
let center = testutil::peak_locate(&buf[a..b], sr, 905.0, 1000.0);
let mut bp = Biquad::bandpass(center, 20.0, sr);
let mut env_lp = OnePole::lowpass(70.0, sr);
let mut slow = OnePole::lowpass(2.0, sr);
let mut env = Vec::with_capacity(buf.len());
let mut detr = Vec::with_capacity(buf.len());
for &x in buf {
let e = env_lp.process(bp.process(x).abs());
detr.push((e - slow.process(e)) as f64);
env.push(e as f64);
}
let mean = env[a..b].iter().sum::<f64>() / (b - a) as f64;
let var = detr[a..b].iter().map(|&d| d * d).sum::<f64>() / (b - a) as f64;
(var.sqrt() / mean.max(1e-12)) as f32
}
#[test]
fn dr_o4_crash_twin_field_beats_in_live_window() {
let v1 = render_drum_kit(49, 100, 1.5, Kit::V1);
let v2 = render_drum_kit(49, 100, 1.5, Kit::V2);
let cv1 = twin_beat_cv(&v1);
let cv2 = twin_beat_cv(&v2);
println!(
"DR-O4 twin-beat CV: v1={cv1:.4} v2={cv2:.4} ratio={:.2}",
cv2 / cv1
);
assert!(
cv2 > 3.0 * cv1,
"twin beat not present: v2 CV {cv2} vs v1 CV {cv1}"
);
}
#[test]
fn dr_o10_crash_level_parity() {
let sr = 44100.0;
let win = (1.0 * sr) as usize;
let r1 = testutil::rms(&render_drum_kit(49, 100, 1.0, Kit::V1)[..win]);
let r2 = testutil::rms(&render_drum_kit(49, 100, 1.0, Kit::V2)[..win]);
let db = 20.0 * (r2 / r1).log10();
println!("DR-O10 crash49 [0,1s] RMS: v1={r1:.6} v2={r2:.6} delta={db:.3} dB");
assert!(
db.abs() <= 2.0,
"crash level parity {db} dB exceeds ±2 dB (CRASH_WASH_AMP_V2 = {CRASH_WASH_AMP_V2})"
);
}
#[test]
fn dr_o10_open_hat_level_parity() {
let r1 = testutil::rms(&render_drum_kit(46, 100, 0.95, Kit::V1));
let r2 = testutil::rms(&render_drum_kit(46, 100, 0.95, Kit::V2));
let db = 20.0 * (r2 / r1).log10();
println!("DR-O10 openhat46 [0,0.95s] RMS: v1={r1:.6} v2={r2:.6} delta={db:.3} dB");
assert!(
db.abs() <= 2.0,
"open-hat level parity {db} dB exceeds ±2 dB"
);
}
#[test]
fn v3_crash_tail_is_broadband_in_samples_off_and_on_modes() {
let sr = 44100.0;
let v2 = render_drum_kit(49, 100, 4.0, Kit::V2);
let v3 = render_drum_kit_samples(49, 100, 4.0, Kit::V3, false);
let v3s = render_drum_kit_samples(49, 100, 4.0, Kit::V3, true);
for (a, b) in [(0.50, 1.50), (1.50, 3.50)] {
let f2 = testutil::flatness(sec_window(&v2, sr, a, b), sr, 700.0, 12_000.0);
let f3 = testutil::flatness(sec_window(&v3, sr, a, b), sr, 700.0, 12_000.0);
let f3s = testutil::flatness(sec_window(&v3s, sr, a, b), sr, 700.0, 12_000.0);
println!("V3 crash flatness [{a:.1},{b:.1}]s: v2={f2:.3} v3={f3:.3} v3s={f3s:.3}");
assert!(
f3 >= 0.18 && f3 > 1.35 * f2,
"modeled V3 crash still too tonal"
);
assert!(f3s >= 0.18, "sampled V3 crash tail not broadband: {f3s:.3}");
}
let r2 = testutil::rms(&v2);
for (name, s) in [("v3", &v3), ("v3 samples", &v3s)] {
let db = 20.0 * (testutil::rms(s) / r2.max(1e-12)).log10();
assert!(
db.abs() <= 3.0,
"{name} crash level moved {db:+.2} dB vs V2"
);
assert!(
s.iter().all(|x| x.is_finite()),
"{name} crash produced non-finite samples"
);
}
}
#[cfg(feature = "embedded-samples")]
#[test]
fn v3_sampled_kit_engages_for_crash_kick_and_snare() {
for key in [49u8, 36, 38] {
let plain = render_drum_kit_samples(key, 110, 0.7, Kit::V3, false);
let sampled = render_drum_kit_samples(key, 110, 0.7, Kit::V3, true);
assert_ne!(plain, sampled, "sampled kit did not engage for key {key}");
assert!(
sampled.iter().all(|x| x.is_finite()),
"sampled key {key} non-finite"
);
let delta_db =
20.0 * (testutil::rms(&sampled) / testutil::rms(&plain).max(1e-12)).log10();
assert!(
delta_db.abs() <= 6.0,
"sampled kit level jump for key {key}: {delta_db:+.2} dB"
);
}
}
#[cfg(feature = "embedded-samples")]
#[test]
fn synth_kit_is_modeled_v3_never_sampled() {
for key in [36u8, 38, 39, 42, 46, 49, 54] {
let synth_on = render_drum_kit_samples(key, 110, 0.7, Kit::Synth, true);
let synth_off = render_drum_kit_samples(key, 110, 0.7, Kit::Synth, false);
let v3_modeled = render_drum_kit_samples(key, 110, 0.7, Kit::V3, false);
assert_eq!(
synth_on, synth_off,
"synth kit key {key} reacted to the samples flag"
);
assert_eq!(synth_on, v3_modeled, "synth kit key {key} != modeled V3");
}
for key in [36u8, 38, 42, 46, 49] {
let synth = render_drum_kit_samples(key, 110, 0.7, Kit::Synth, true);
let v3_sampled = render_drum_kit_samples(key, 110, 0.7, Kit::V3, true);
assert_ne!(
synth, v3_sampled,
"synth kit key {key} still took the sampled replacement"
);
}
}
#[cfg(feature = "embedded-samples")]
#[test]
fn sampled_kit_routes_only_on_v3_brush_with_samples() {
let sr = 44100.0;
let kit_keys = [
35u8, 36, 37, 38, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 55, 57, 59,
];
for key in kit_keys {
let v = make(key, 100, sr, 7, Kit::V3, true, 0).unwrap();
let expected = if matches!(key, 47 | 48 | 50) {
"hybridtom"
} else {
"sampleddrum"
};
assert_eq!(v.kind(), expected, "key {key} V3+samples");
let m = make(key, 100, sr, 7, Kit::V3, false, 0).unwrap();
assert_ne!(m.kind(), "sampleddrum", "--no-samples key {key}");
for (kit, name) in [(Kit::V1, "V1"), (Kit::V2, "V2")] {
let m = make(key, 100, sr, 7, kit, true, 0).unwrap();
assert_ne!(m.kind(), "sampleddrum", "legacy kit {name} key {key}");
}
}
for key in [35u8, 36, 37, 38, 39, 40, 42, 44, 46] {
let v = make(key, 100, sr, 7, Kit::Brush, true, 0).unwrap();
assert_ne!(v.kind(), "sampleddrum", "brush key {key} must stay brush");
}
for key in [41u8, 43, 45, 47, 48, 50, 49, 51, 52, 53, 55, 57, 59] {
let v = make(key, 100, sr, 7, Kit::Brush, true, 0).unwrap();
let expected = if matches!(key, 47 | 48 | 50) {
"hybridtom"
} else {
"sampleddrum"
};
assert_eq!(v.kind(), expected, "brush fall-through key {key}");
}
}
#[cfg(feature = "embedded-samples")]
#[test]
fn sampled_kit_velocity_selects_layers_through_make() {
for (key, soft, hard) in [
(36u8, 20, 120), (38, 15, 120), (42, 20, 120), (45, 20, 120), ] {
let a = render_drum_kit_samples(key, soft, 0.5, Kit::V3, true);
let b = render_drum_kit_samples(key, hard, 0.5, Kit::V3, true);
let (ra, rb) = (testutil::rms(&a), testutil::rms(&b));
assert!(
rb > 2.0 * ra,
"key {key}: hard hit not louder ({rb:.5} vs {ra:.5})"
);
let g = rb / ra.max(1e-12);
let scaled_diff: f32 = a
.iter()
.zip(&b)
.map(|(x, y)| (x * g - y).abs())
.fold(0f32, f32::max);
let peak_b = b.iter().fold(0f32, |m, &x| m.max(x.abs()));
assert!(
scaled_diff > 0.2 * peak_b,
"key {key}: hard hit looks like a scaled soft hit (layer not switching)"
);
}
}
#[cfg(feature = "embedded-samples")]
#[test]
fn sampled_drum_level_parity() {
let mut worst: (u8, f32) = (0, 0.0);
for key in [
35u8, 36, 37, 38, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 55, 57, 59,
] {
let model = render_drum_kit_samples(key, 100, 3.0, Kit::V3, false);
let sampled = render_drum_kit_samples(key, 100, 3.0, Kit::V3, true);
let (rm, rs) = (testutil::rms(&model), testutil::rms(&sampled));
let db = 20.0 * (rs / rm.max(1e-12)).log10();
println!("key {key}: model rms {rm:.5} sampled rms {rs:.5} delta {db:+.2} dB");
if db.abs() > worst.1.abs() {
worst = (key, db);
}
}
assert!(
worst.1.abs() <= 3.0,
"sampled drum {} level {:+.2} dB vs model",
worst.0,
worst.1
);
}
#[cfg(feature = "embedded-samples")]
#[test]
fn hybrid_tom_restores_body_on_stretched_high_toms() {
let sr = 44100.0;
for (key, target) in [(48u8, 293.0f32), (50, 352.0)] {
let mut solo = sampler::sampled_drum(key, 100, 7, 0, sr).unwrap();
let mut sbuf = vec![0f32; (sr * 0.4) as usize];
solo.render(&mut sbuf);
let hyb = render_drum_kit_samples(key, 100, 0.4, Kit::V3, true);
let win = |s: &[f32]| s[..(0.30 * sr) as usize].to_vec();
let c_solo = testutil::spectral_centroid(&win(&sbuf), sr, 80.0, 8000.0);
let c_hyb = testutil::spectral_centroid(&win(&hyb), sr, 80.0, 8000.0);
let lm =
|s: &[f32]| testutil::spectral_band_rms(&win(s), sr, target * 0.5, target * 1.6);
let (lm_solo, lm_hyb) = (lm(&sbuf), lm(&hyb));
println!(
"key {key}: centroid solo={c_solo:.0} hyb={c_hyb:.0}; low-mid solo={lm_solo:.4} hyb={lm_hyb:.4}"
);
assert!(
c_hyb < c_solo * 0.93,
"key {key}: hybrid not less top-heavy ({c_hyb:.0} vs {c_solo:.0} Hz)"
);
assert!(
lm_hyb > lm_solo * 1.10,
"key {key}: hybrid did not add low-mid body ({lm_hyb:.4} vs {lm_solo:.4})"
);
let f =
testutil::peak_locate(&hyb[..(0.25 * sr) as usize], sr, target * 0.6, target * 1.4);
assert!(
(f - target).abs() < target * 0.15,
"key {key}: hybrid pitch {f:.0} Hz off target {target}"
);
}
}
#[cfg(feature = "embedded-samples")]
#[test]
fn sampled_toms_repitch_preserves_the_ladder() {
let sr = 44100.0;
let mut prev = 0.0f32;
for (key, table) in [
(41u8, 100.0f32),
(43, 140.0),
(45, 190.0),
(47, 240.0),
(48, 293.0),
(50, 352.0),
] {
let b = render_drum_kit_samples(key, 100, 0.6, Kit::V3, true);
let f =
testutil::peak_locate(&b[..(0.35 * sr) as usize], sr, table * 0.70, table * 1.35);
println!("sampled tom {key}: {f:.1} Hz (table {table})");
assert!(
(f - table).abs() < table * 0.12,
"sampled tom {key} at {f:.1} Hz, expected ~{table}"
);
assert!(
f > prev * 1.08,
"tom ladder not ascending at key {key}: {f:.1} Hz vs prev {prev:.1}"
);
prev = f;
}
}
#[test]
fn v3_kick_keeps_sub_without_dc() {
let sr = 44100.0;
let kick = render_drum_kit(36, 115, 0.8, Kit::V3);
let sub = testutil::band_rms(
&kick[(0.020 * sr) as usize..(0.300 * sr) as usize],
sr,
60.0,
1.0,
);
let dc = kick.iter().sum::<f32>() / kick.len() as f32;
let peak = kick.iter().fold(0f32, |m, &x| m.max(x.abs())).max(1e-12);
assert!(sub > 0.01, "V3 kick lost sub body: {sub}");
assert!(dc.abs() < 0.015 * peak, "V3 kick DC offset too high: {dc}");
}
#[test]
fn v3_toms_settle_near_table_pitch() {
let sr = 44100.0;
for (key, table) in [
(41u8, 100.0),
(43, 140.0),
(45, 190.0),
(47, 240.0),
(48, 293.0),
(50, 352.0),
] {
let b = render_drum_kit(key, 100, 0.5, Kit::V3);
let f = testutil::peak_locate(
&b[(sr * 0.15) as usize..(sr * 0.30) as usize],
sr,
table * 0.65,
table * 1.35,
);
assert!(
(f - table).abs() < table * 0.22,
"tom {key} settled at {f:.1} Hz, expected {table}"
);
}
}
#[test]
fn v3_auxiliary_percussion_stays_audible_and_finite() {
for key in [
37u8, 39, 54, 56, 60, 61, 62, 63, 64, 65, 66, 69, 70, 82, 80, 81,
] {
let old = render_drum_kit(key, 100, 0.6, Kit::V2);
let new = render_drum_kit(key, 100, 0.6, Kit::V3);
assert!(new.iter().all(|x| x.is_finite()), "key {key} non-finite");
let nr = testutil::rms(&new);
assert!(nr > 1e-5, "key {key} fell silent");
let db = 20.0 * (nr / testutil::rms(&old).max(1e-12)).log10();
assert!(db.abs() <= 4.0, "key {key} level changed {db:+.2} dB vs V2");
}
}
#[test]
fn dr2_tom_v2_settles_at_table_pitch() {
let sr = 44100.0;
let settled = |kit| {
let b = render_drum_kit(41, 100, 0.5, kit);
let a = (sr * 0.15) as usize;
let z = (sr * 0.30) as usize;
testutil::peak_locate(&b[a..z], sr, 20.0, 200.0)
};
let f_v1 = settled(Kit::V1);
let f_v2 = settled(Kit::V2);
println!("DR2 tom41 settled pitch: v1={f_v1:.1} Hz v2={f_v2:.1} Hz");
assert!(
(f_v2 - 100.0).abs() < 20.0,
"v2 tom settles near the 100 Hz table pitch (got {f_v2:.1})"
);
assert!(
f_v1 < 60.0,
"v1 tom dived far below the table (got {f_v1:.1})"
);
assert!(
f_v2 > f_v1 * 1.4,
"v2 settled pitch sits well above v1's dived pitch (v1={f_v1:.1}, v2={f_v2:.1})"
);
}
#[test]
fn tom_ladder_v3() {
let sr = 44100.0;
let f0 = 190.0;
let v2 = render_drum_kit(45, 100, 0.45, Kit::V2);
let v3 = render_drum_kit(45, 100, 0.45, Kit::V3);
let s2 = &v2[(0.08 * sr) as usize..(0.16 * sr) as usize];
let s3 = &v3[(0.08 * sr) as usize..(0.16 * sr) as usize];
let up2 = testutil::spectral_band_rms(s2, sr, 2.3 * f0, 3.7 * f0);
let up3 = testutil::spectral_band_rms(s3, sr, 2.3 * f0, 3.7 * f0);
let settled = testutil::peak_locate(
&v3[(0.15 * sr) as usize..(0.30 * sr) as usize],
sr,
100.0,
300.0,
);
let db = 20.0 * (testutil::rms(&v3) / testutil::rms(&v2).max(1e-12)).log10();
let mean = v3.iter().sum::<f32>() / v3.len() as f32;
println!(
"P-T1 tom45: upper[437,703] v2={up2:.5} v3={up3:.5} ratio={:.2}; settled={settled:.0} Hz; \
level v3-v2={db:+.2} dB; DC={mean:.2e}",
up3 / up2.max(1e-9)
);
assert!(
up3 > 1.5 * up2,
"(1) v3 lacks the richer upper ladder: v3={up3:.5} v2={up2:.5}"
);
assert!(
(settled - f0).abs() < 20.0,
"(2) v3 tom settled pitch {settled:.0} Hz off table {f0}"
);
assert!(db.abs() <= 3.0, "(3) v3 tom level {db:+.2} dB far from v2");
assert!(mean.abs() < 1e-3, "(4) v3 tom DC {mean:.2e}");
}
#[test]
fn tom_ladder_is_six_distinct_drums() {
let sr = 44100.0;
let keys = [41u8, 43, 45, 47, 48, 50];
let mut f0s = Vec::new();
let mut bufs = Vec::new();
for &k in &keys {
let buf = render_drum_kit(k, 100, 0.45, Kit::V3);
let settled = testutil::peak_locate(
&buf[(0.15 * sr) as usize..(0.30 * sr) as usize],
sr,
80.0,
520.0,
);
f0s.push(settled);
bufs.push(buf);
}
println!("P-T5 tom ladder f0 (keys {keys:?}): {f0s:?}");
for i in 1..keys.len() {
assert!(
f0s[i] > f0s[i - 1] * 1.10,
"(a) tom ladder is not ascending at key {} -> {}: {:.0} Hz -> {:.0} Hz \
(need >= 1.10x). The six GM toms must be six distinct drums.",
keys[i - 1],
keys[i],
f0s[i - 1],
f0s[i],
);
}
for i in 0..keys.len() {
for j in (i + 1)..keys.len() {
assert!(
bufs[i] != bufs[j],
"(b) keys {} and {} render BIT-IDENTICALLY -- they are the same drum",
keys[i],
keys[j],
);
}
}
}
#[test]
fn gm2_extended_percussion_not_generic_tick() {
let sr = 44100.0;
let generic = render_drum_kit(26, 100, 0.4, Kit::V3);
let aliases = [
(31u8, 37u8), (32, 37), (33, 37), (28, 39), (29, 73), (30, 74), (85, 75), (83, 54), (84, 81), (27, 80), (34, 80), ];
for (src, dst) in aliases {
let rs = render_drum_kit(src, 100, 0.4, Kit::V3);
let rd = render_drum_kit(dst, 100, 0.4, Kit::V3);
assert!(
rs == rd,
"key {src} is not aliased to its nearest voice {dst}"
);
assert!(rs != generic, "key {src} still renders as the generic tick");
}
let mute = render_drum_kit(86, 100, 0.5, Kit::V3);
let open = render_drum_kit(87, 100, 0.5, Kit::V3);
assert!(
mute != generic && open != generic,
"a surdo still renders as the generic tick"
);
assert!(
mute != open,
"mute (86) and open (87) surdo render identically"
);
let f_open = testutil::peak_locate(
&open[(0.12 * sr) as usize..(0.28 * sr) as usize],
sr,
40.0,
160.0,
);
println!("open surdo settled pitch: {f_open:.1} Hz");
assert!(
f_open < 130.0,
"open surdo (87) not a low drum: {f_open:.1} Hz"
);
}
#[test]
fn timbales_hi_vs_lo_distinct_pitch() {
let sr = 44100.0;
let hi = render_drum_kit(65, 100, 0.4, Kit::V3);
let lo = render_drum_kit(66, 100, 0.4, Kit::V3);
assert!(
hi != lo,
"timbale 65 and 66 render bit-identically -- they are the same drum (shared match arm)"
);
let (a, b) = ((0.02 * sr) as usize, (0.14 * sr) as usize);
let f_hi = testutil::peak_locate(&hi[a..b], sr, 150.0, 600.0);
let f_lo = testutil::peak_locate(&lo[a..b], sr, 150.0, 600.0);
println!("timbale pitches: hi={f_hi:.0} lo={f_lo:.0}");
assert!(
f_hi > f_lo * 1.12,
"timbales not pitch-distinct: hi={f_hi:.0} Hz lo={f_lo:.0} Hz (need hi >= 1.12x lo)"
);
}
#[test]
fn cabasa_maracas_shaker_are_distinct() {
let sr = 44100.0;
let cabasa = render_drum_kit(69, 100, 0.4, Kit::V3);
let maracas = render_drum_kit(70, 100, 0.4, Kit::V3);
let shaker = render_drum_kit(82, 100, 0.4, Kit::V3);
assert!(
cabasa != maracas && cabasa != shaker && maracas != shaker,
"cabasa/maracas/shaker render bit-identically -- still one shared voice"
);
let n = (0.10 * sr) as usize;
let (cc, cs, cm) = (
testutil::centroid(&cabasa[..n], sr),
testutil::centroid(&shaker[..n], sr),
testutil::centroid(&maracas[..n], sr),
);
println!("shaken centroids: cabasa={cc:.0} shaker={cs:.0} maracas={cm:.0}");
assert!(
cc > cs && cs > cm,
"brightness not ordered cabasa>shaker>maracas: {cc:.0}/{cs:.0}/{cm:.0}"
);
let decay = |s: &[f32]| {
let early = testutil::rms(&s[(0.005 * sr) as usize..(0.03 * sr) as usize]);
let late = testutil::rms(&s[(0.08 * sr) as usize..(0.16 * sr) as usize]);
late / early.max(1e-9)
};
let (dc, dm) = (decay(&cabasa), decay(&maracas));
println!("shaken decay ratios: cabasa={dc:.3} maracas={dm:.3}");
assert!(
dc > dm,
"cabasa should sustain longer than maracas: cabasa={dc:.3} maracas={dm:.3}"
);
}
#[test]
fn tom_attack_snap_v3() {
let sr = 44100.0;
let ratio = |s: &[f32]| -> f32 {
let snap = testutil::hp_rms(&s[..(0.010 * sr) as usize], sr, 3000.0);
let body = testutil::rms(&s[(0.050 * sr) as usize..(0.150 * sr) as usize]);
snap / body.max(1e-9)
};
let v2s = render_drum_kit(45, 100, 0.45, Kit::V2);
let v3s = render_drum_kit(45, 100, 0.45, Kit::V3);
let (v2, v3) = (ratio(&v2s), ratio(&v3s));
let snap_gn = |vel: u8| -> f32 {
let s = render_drum_kit(45, vel, 0.45, Kit::V3);
testutil::hp_rms(&s[..(0.010 * sr) as usize], sr, 3000.0)
/ crate::dsp::vel_amp(vel).max(1e-9)
};
let (c40, c80, c120) = (snap_gn(40), snap_gn(80), snap_gn(120));
let db = 20.0 * (testutil::rms(&v3s) / testutil::rms(&v2s).max(1e-12)).log10();
let mean = v3s.iter().sum::<f32>() / v3s.len() as f32;
println!(
"P-T2 tom45 attack: ratio v2={v2:.3} v3={v3:.3}; vel 40/80/120={c40:.3}/{c80:.3}/{c120:.3}; \
level v3-v2={db:+.2} dB; DC={mean:.2e}"
);
assert!(
v3 > 1.4 * v2,
"(1) v3 tom no snappier than v2: v3={v3:.3} v2={v2:.3}"
);
assert!(
c120 > c80 && c80 > c40,
"(2) tom snap not velocity-monotone: {c40:.3}/{c80:.3}/{c120:.3}"
);
assert!(db.abs() <= 3.0, "(3) v3 tom level {db:+.2} dB far from v2");
assert!(mean.abs() < 1e-3, "(4) v3 tom DC {mean:.2e}");
}
#[test]
fn tom_two_head_sustain_v3() {
let sr = 44100.0;
let v2 = render_drum_kit(45, 100, 0.5, Kit::V2);
let v3 = render_drum_kit(45, 100, 0.5, Kit::V3);
let late2 = testutil::band_rms(
&v2[(0.20 * sr) as usize..(0.40 * sr) as usize],
sr,
195.0,
4.0,
);
let late3 = testutil::band_rms(
&v3[(0.20 * sr) as usize..(0.40 * sr) as usize],
sr,
195.0,
4.0,
);
let db = 20.0 * (testutil::rms(&v3) / testutil::rms(&v2).max(1e-12)).log10();
let mean = v3.iter().sum::<f32>() / v3.len() as f32;
println!(
"P-T3 tom45: late-sustain v2={late2:.5} v3={late3:.5} ratio={:.2}; \
level v3-v2={db:+.2} dB; DC={mean:.2e}",
late3 / late2.max(1e-9)
);
assert!(
late3 > 1.4 * late2,
"(1) v3 no two-stage sustain: v3={late3:.5} v2={late2:.5}"
);
assert!(db.abs() <= 3.0, "(2) v3 tom level {db:+.2} dB far from v2");
assert!(mean.abs() < 1e-3, "(3) v3 tom DC {mean:.2e}");
}
#[test]
fn dr3_open_hat_v2_centroid_falls() {
let sr = 44100.0;
let ratio = |kit| {
let b = render_drum_kit(46, 100, 0.95, kit);
let c =
|a: f32, z: f32| testutil::centroid(&b[(sr * a) as usize..(sr * z) as usize], sr);
let (early, late) = (c(0.02, 0.12), c(0.30, 0.50));
(early, late, early / late)
};
let (e1, l1, r1) = ratio(Kit::V1);
let (e2, l2, r2) = ratio(Kit::V2);
println!(
"DR3 open-hat centroid early/late: v1 {e1:.0}/{l1:.0} r={r1:.2} v2 {e2:.0}/{l2:.0} r={r2:.2}"
);
assert!(
r2 > r1 * 1.3,
"v2 centroid falls materially more than v1 (r1={r1:.2}, r2={r2:.2})"
);
assert!(
r2 > 1.25,
"v2 early centroid well above its late (r2={r2:.2})"
);
}
#[test]
fn tom_membrane_modes_present() {
let sr = 44100.0;
let tones = tom_tones(100.0, 0.32, 0.0); let mut v = Drum::new(sr, 7, &tones, &[], 0.6, 0.8);
let mut buf = vec![0f32; (0.4 * sr) as usize];
Voice::render(&mut v, &mut buf);
let floor = testutil::mag_at(&buf, sr, 100.0) * 0.05;
for f0 in [100.0f32, 159.0, 214.0] {
let p = testutil::peak_locate(&buf, sr, f0 * 0.92, f0 * 1.08);
let m = testutil::mag_at(&buf, sr, p);
assert!(m > floor, "mode near {f0} Hz missing (mag {m})");
}
}
#[test]
fn snare_head_modes_present() {
let sr = 44100.0;
let mut tones = SNARE_TONES;
for t in &mut tones {
t.3 = 0.0; }
let mut v = Drum::new(sr, 7, &tones, &[], 0.5, 0.7);
let mut buf = vec![0f32; (0.3 * sr) as usize];
Voice::render(&mut v, &mut buf);
let floor = testutil::mag_at(&buf, sr, 186.0) * 0.05;
for f0 in [186.0f32, 280.0, 330.0, 430.0] {
let p = testutil::peak_locate(&buf, sr, f0 * 0.92, f0 * 1.08);
let m = testutil::mag_at(&buf, sr, p);
assert!(m > floor, "head mode near {f0} Hz missing (mag {m})");
}
}
#[test]
fn kick_beater_point_superlinear() {
let sr = 44100.0;
for_each_kit(|kit, name| {
let hard = render_drum_kit(36, 120, 0.5, kit);
let soft = render_drum_kit(36, 30, 0.5, kit);
let early = (0.004 * sr) as usize;
let point = |s: &[f32]| testutil::hp_rms(&s[..early], sr, 3500.0);
let gain_ratio = crate::dsp::vel_amp(120) / crate::dsp::vel_amp(30);
let point_ratio = point(&hard) / point(&soft).max(1e-9);
let want = if kit == Kit::V3 { 1.08 } else { 1.3 };
assert!(
point_ratio > want * gain_ratio,
"{name}: beater ratio {point_ratio} vs amplitude ratio {gain_ratio} \
(needs {want}x)"
);
});
}
#[test]
fn ride_bell_dense_and_clicky() {
let sr = 44100.0;
let buf = render_drum_v1(53, 110, 1.0);
let floor = testutil::mag_at(&buf, sr, 1650.0) * 0.04;
let mut found = 0;
for r in METAL_RATIOS {
let f0 = 1650.0 * r;
let p = testutil::peak_locate(&buf, sr, f0 * 0.92, f0 * 1.08);
if testutil::mag_at(&buf, sr, p) > floor {
found += 1;
}
}
assert!(found >= 5, "only {found} bell partials found");
let early = testutil::hp_rms(&buf[..(0.006 * sr) as usize], sr, 7000.0);
let late = testutil::hp_rms(
&buf[(0.010 * sr) as usize..(0.030 * sr) as usize],
sr,
7000.0,
);
assert!(
early > 2.0 * late,
"no stick click: early {early} late {late}"
);
}
fn last_audible(s: &[f32]) -> f32 {
let sr = 44100.0;
let peak = s.iter().fold(0f32, |m, &x| m.max(x.abs())).max(1e-12);
let idx = s.iter().rposition(|&x| x.abs() > 1e-4 * peak).unwrap_or(0);
idx as f32 / sr
}
#[test]
fn china_splash_crash_are_distinct() {
let sr = 44100.0;
for_each_kit(|kit, name| {
let china = render_drum_kit(52, 110, 1.6, kit);
let splash = render_drum_kit(55, 110, 1.2, kit);
let crash = render_drum_kit(49, 110, 4.2, kit);
let (lc, ls, lx) = (
last_audible(&china),
last_audible(&splash),
last_audible(&crash),
);
assert!((0.6..=1.2).contains(&lc), "{name}: china life {lc}");
assert!(ls < 0.7, "{name}: splash life {ls}");
assert!(lx > 3.0, "{name}: crash life {lx}");
let trash = |s: &[f32]| {
testutil::hp_rms(s, sr, 8000.0) / testutil::band_rms(s, sr, 3000.0, 0.8).max(1e-9)
};
let (tc, tx) = (
trash(&china[..(0.5 * sr) as usize]),
trash(&crash[..(0.5 * sr) as usize]),
);
assert!(tc > 1.5 * tx, "{name}: china trash {tc} vs crash {tx}");
});
}
#[cfg(feature = "embedded-samples")]
#[test]
fn second_cymbal_of_each_pair_is_the_first_a_semitone_up() {
let sr = 44100.0;
const PAIRS: [(u8, u8, &str); 2] = [(49, 57, "crash"), (51, 59, "ride")];
let render_rr = |key: u8, vel: u8, rr: u8| {
let mut v = make(key, vel, sr, 7, Kit::V3, true, rr).unwrap();
let mut buf = vec![0f32; (sr * 4.0) as usize];
v.render(&mut buf);
buf
};
let peak_norm = |s: &[f32]| -> Vec<f32> {
let p = s.iter().fold(0f32, |m, &x| m.max(x.abs())).max(1e-12);
s.iter().map(|&x| x / p).collect()
};
for (first, second, label) in PAIRS {
for rr in 0..4u8 {
let a = peak_norm(&render_rr(first, 110, rr));
let b = peak_norm(&render_rr(second, 110, rr));
let dev = a
.iter()
.zip(&b)
.fold(0f32, |m, (x, y)| m.max((x - y).abs()));
println!("{label} rr {rr}: peak-normalised max deviation {dev:.4}");
assert!(
dev > 0.05,
"key {second} round robin {rr} is key {first}'s signal at another \
volume (max deviation {dev:.4} after peak normalisation) — the two \
{label} keys are sharing one voice again"
);
}
}
let grid: Vec<f32> = {
let (mut v, mut f) = (Vec::new(), 300.0f32);
while f <= 3500.0 {
v.push(f);
f *= 1.005;
}
v
};
let spectrum = |s: &[f32]| -> Vec<f32> {
let w = &s[..(0.5 * sr) as usize];
grid.iter().map(|&f| testutil::mag_at(w, sr, f)).collect()
};
let similarity = |a: &[f32], b: &[f32], k: usize| -> f32 {
let (mut dot, mut na, mut nb) = (0f32, 0f32, 0f32);
for i in k..a.len() {
let (x, y) = (a[i - k], b[i]);
dot += x * y;
na += x * x;
nb += y * y;
}
dot / (na.sqrt() * nb.sqrt()).max(1e-12)
};
for (first, second, label) in PAIRS {
for vel in [70u8, 110] {
for rr in 0..4u8 {
let a = spectrum(&render_rr(first, vel, rr));
let b = spectrum(&render_rr(second, vel, rr));
let (mut best_k, mut best_s) = (0usize, f32::NEG_INFINITY);
for k in 0..=24usize {
let s = similarity(&a, &b, k);
if s > best_s {
best_s = s;
best_k = k;
}
}
let ratio = 1.005f32.powi(best_k as i32);
println!(
"{label} vel {vel} rr {rr}: best log-spectral shift k={best_k} \
(ratio {ratio:.4}, similarity {best_s:.3})"
);
assert!(
(10..=13).contains(&best_k),
"key {second} is not one semitone above key {first} ({label}): best \
spectral alignment is k={best_k} (ratio {ratio:.4}) at vel {vel} \
rr {rr}, expected k=11-12 (2^(1/12) = 1.0595). k=0 means the \
repitch is gone."
);
}
}
}
}
#[test]
fn pedal_hat_is_not_a_closed_hat() {
let sr = 44100.0;
for_each_kit(|kit, name| {
let closed = render_drum_kit(42, 100, 0.3, kit);
let pedal = render_drum_kit(44, 100, 0.3, kit);
assert!(
closed != pedal,
"{name}: keys 42 and 44 render bit-identically — still one shared voice"
);
let click_ratio = |s: &[f32]| {
let w = &s[..(0.005 * sr) as usize];
testutil::spectral_band_rms(w, sr, 9000.0, 20000.0)
/ testutil::spectral_band_rms(w, sr, 6500.0, 8500.0).max(1e-9)
};
let cr = click_ratio(&closed);
let pr = click_ratio(&pedal);
println!("{name}: 9k+/6.5-8.5k in first 5 ms closed={cr:.3} pedal={pr:.3}");
assert!(
pr < cr * 0.8,
"{name}: the pedal hat still leads with a 9 kHz stick tick \
(9k+/ref closed={cr:.3} pedal={pr:.3}) — it is played with the foot"
);
let n = (0.05 * sr) as usize;
let cc = testutil::centroid(&closed[..n], sr);
let pc = testutil::centroid(&pedal[..n], sr);
println!("{name}: centroid closed={cc:.0} pedal={pc:.0}");
assert!(
pc < cc,
"{name}: pedal hat is not darker than the closed hat \
(centroid closed={cc:.0} pedal={pc:.0})"
);
});
}
#[test]
fn strike_variation_is_real_and_bounded() {
let sr = 44100.0;
let mut ratios = Vec::new();
for seed in 1..=8u32 {
let tones = tom_tones(100.0, 0.32, 0.0);
let mut v = Drum::new(sr, seed * 977, &tones, &[], 0.6, 0.8);
let mut buf = vec![0f32; (0.3 * sr) as usize];
Voice::render(&mut v, &mut buf);
let f = testutil::peak_locate(&buf, sr, 92.0, 108.0);
let m = testutil::peak_locate(&buf, sr, 146.0, 172.0);
ratios.push(testutil::mag_at(&buf, sr, f) / testutil::mag_at(&buf, sr, m).max(1e-9));
}
let hi = ratios.iter().fold(f32::MIN, |m, &x| m.max(x));
let lo = ratios.iter().fold(f32::MAX, |m, &x| m.min(x));
assert!(hi / lo > 1.3, "ratio spread {} .. {} too uniform", lo, hi);
assert!(hi / lo < 6.0, "ratio spread {} .. {} unbounded", lo, hi);
}
#[test]
fn crash_blooms_hat_does_not() {
let sr = 44100.0;
let hf_argmax_ms = |s: &[f32]| {
let win = (0.005 * sr) as usize;
let mut hp = Biquad::highpass(6000.0, 0.7, sr);
let f: Vec<f32> = s.iter().map(|&x| hp.process(x)).collect();
let (mut bi, mut bv) = (0usize, 0.0f32);
for (i, w) in f.chunks(win).take(60).enumerate() {
let r = testutil::rms(w);
if r > bv {
bi = i;
bv = r;
}
}
bi as f32 * 5.0
};
for_each_kit(|kit, name| {
let crash = render_drum_kit(49, 110, 0.5, kit);
assert!(
hf_argmax_ms(&crash) > 20.0,
"{name}: crash wash peaked at {} ms — no bloom",
hf_argmax_ms(&crash)
);
let chick = testutil::hp_rms(&crash[..(0.003 * sr) as usize], sr, 6000.0);
assert!(chick > 0.02, "{name}: no chick at the front: {chick}");
let hat = render_drum_kit(42, 110, 0.2, kit);
assert!(
hf_argmax_ms(&hat) < 10.0,
"{name}: hat should not bloom ({} ms)",
hf_argmax_ms(&hat)
);
});
}
#[test]
fn cymbal_bloom_cascade_v3() {
let sr = 44100.0;
let ehi = |s: &[f32], a: f32, b: f32, lo: f32, hi: f32| {
testutil::spectral_band_rms(&s[(a * sr) as usize..(b * sr) as usize], sr, lo, hi)
};
let bloom_gain = |key: u8, vel: u8, lo: f32, hi: f32| {
let s = render_drum_kit(key, vel, 1.0, Kit::V3);
ehi(&s, 0.040, 0.085, lo, hi) / ehi(&s, 0.005, 0.020, lo, hi).max(1e-9)
};
let crash = render_drum_kit(49, 120, 4.0, Kit::V3);
let t_low = testutil::traj_peak_time_s(&testutil::traj(&crash, sr, 650.0, 1400.0));
let t_hi = testutil::traj_peak_time_s(&testutil::traj(&crash, sr, 6000.0, 12000.0));
let c_early = testutil::spectral_centroid(
&crash[(0.005 * sr) as usize..(0.030 * sr) as usize],
sr,
800.0,
13000.0,
);
let c_mid = testutil::spectral_centroid(
&crash[(0.040 * sr) as usize..(0.090 * sr) as usize],
sr,
800.0,
13000.0,
);
let (g120, g40) = (
bloom_gain(49, 120, 6000.0, 12000.0),
bloom_gain(49, 40, 6000.0, 12000.0),
);
let rg = bloom_gain(51, 120, 6000.0, 11000.0);
let gong = testutil::spectral_band_rms(
&crash[(0.38 * sr) as usize..(0.42 * sr) as usize],
sr,
650.0,
1400.0,
);
let mean = crash.iter().sum::<f32>() / crash.len() as f32;
println!(
"P-C1 crash49: t_low={:.1}ms t_hi={:.1}ms; centroid early={:.0} mid={:.0}; \
gain120={g120:.2} gain40={g40:.2}; ride gain={rg:.2}; gong={gong:.5}; DC={mean:.2e}",
t_low * 1000.0,
t_hi * 1000.0,
c_early,
c_mid
);
assert!(t_low <= 0.015, "BLOOM-1 low not immediate: {t_low}");
assert!(
(0.020..=0.090).contains(&t_hi),
"BLOOM-2 high bloom outside [20,90]ms: {t_hi}"
);
assert!(
t_hi - t_low >= 0.015,
"BLOOM-3 no bloom delay: {}",
t_hi - t_low
);
let _ = (c_early, c_mid); assert!(g120 >= 1.3, "VEL-1 hard crash doesn't bloom: {g120}");
assert!(
g40 <= 1.05,
"VEL-1 soft crash blooms (should stay flat): {g40}"
);
assert!(
g120 >= 1.5 * g40,
"VEL-1 bloom not velocity-gated: {g120} vs {g40}"
);
assert!(rg <= 1.25, "RIDE-1 ride blooms like a crash: {rg}");
assert!(gong > 1e-4, "GONG-1 low gong tone died: {gong}");
assert!(mean.abs() < 1e-3, "crash DC {mean:.2e}");
assert!(crash.iter().all(|x| x.is_finite()), "crash non-finite");
}
#[test]
fn cymbal_density_pairs_beat_v3() {
let sr = 44100.0;
let crash = render_drum_kit(49, 100, 1.5, Kit::V3);
let beat_peak = |buf: &[f32], center: f32| -> f32 {
let (a, b) = ((0.2 * sr) as usize, (1.5 * sr) as usize);
let mut bp = Biquad::bandpass(center, 8.0, sr);
let filt: Vec<f32> = buf[a..b].iter().map(|&x| bp.process(x)).collect();
testutil::env_autocorr_peak_detrend(&filt, sr, 1.0 / 22.0, 1.0 / 6.0, 3.0).0
};
let p_low = beat_peak(&crash, 1050.0); let p_hi = beat_peak(&crash, 5000.0); println!("P-C3 crash49 beat-autocorr: low(singles)={p_low:.3} upper(pairs)={p_hi:.3}");
assert!(
p_hi > 1.2 * p_low,
"(DENS-2) upper pairs don't beat above the low singles: upper {p_hi} vs low {p_low}"
);
}
#[test]
fn cymbal_noise2_sizzle_does_not_swell() {
let sr = 44100.0;
let spec = |swell| CymSpec {
base: 3300.0,
tone_amp: 0.0,
t60_first: 0.10,
t60_last: 0.05,
noise: (0.0, 0.20, 3000.0),
life: 0.20,
gain: 1.0,
click: None,
swell,
pairs: false,
v2: None,
noise2: Some((1.0, 0.10, 6000.0)),
shimmer: None,
};
let render = |swell| {
let mut voice = cymbal(&spec(swell), sr, 7, 127).unwrap();
let mut buf = vec![0.0; (0.20 * sr) as usize];
voice.render(&mut buf);
buf
};
assert_eq!(render(true), render(false));
}
#[test]
fn crash_wash_beats() {
let sr = 44100.0;
let spec = |pairs: bool| CymSpec {
base: 950.0,
tone_amp: 0.13,
t60_first: 2.6,
t60_last: 1.0,
noise: (1.0, 1.9, 4200.0),
life: 4.2,
gain: 0.50,
click: None,
swell: true,
pairs,
v2: None,
noise2: None,
shimmer: None,
};
let render = |pairs: bool| {
let mut v = cymbal(&spec(pairs), sr, 7, 110).unwrap();
let mut buf = vec![0f32; (1.0 * sr) as usize];
v.render(&mut buf);
let mut bp = Biquad::bandpass(6030.0, 15.0, sr);
buf.iter()
.skip((0.05 * sr) as usize)
.map(|&x| bp.process(x))
.collect::<Vec<f32>>()
};
let (with_peak, rate) =
testutil::env_autocorr_peak(&render(true), sr, 1.0 / 100.0, 1.0 / 40.0);
let (without_peak, _) =
testutil::env_autocorr_peak(&render(false), sr, 1.0 / 100.0, 1.0 / 40.0);
assert!(
with_peak > without_peak + 0.1,
"no added beat: with {with_peak} vs without {without_peak}"
);
assert!((40.0..=100.0).contains(&rate), "beat rate {rate} Hz");
}
#[test]
fn closed_hat_tick() {
let sr = 44100.0;
let ratio = |s: &[f32]| {
testutil::hp_rms(&s[..(0.002 * sr) as usize], sr, 9000.0)
/ testutil::hp_rms(&s[(0.010 * sr) as usize..(0.030 * sr) as usize], sr, 9000.0)
.max(1e-9)
};
let with = render_drum_v1(42, 110, 0.15);
let spec = CymSpec {
base: 3300.0,
tone_amp: 0.10,
t60_first: 0.05,
t60_last: 0.03,
noise: (0.8, 0.035, 6500.0),
life: 0.14,
gain: 0.42,
click: None,
swell: false,
pairs: false,
v2: None,
noise2: None,
shimmer: None,
};
let mut v = cymbal(&spec, sr, 7, 110).unwrap();
let mut without = vec![0f32; (0.15 * sr) as usize];
v.render(&mut without);
assert!(
ratio(&with) > 1.5 * ratio(&without),
"tick missing: {} vs {}",
ratio(&with),
ratio(&without)
);
let soft = render_drum_v1(42, 40, 0.15);
let tick = |s: &[f32]| testutil::hp_rms(&s[..(0.002 * sr) as usize], sr, 9000.0);
assert!(
tick(&with) > tick(&soft),
"tick does not scale with velocity"
);
}
#[test]
fn ride_ping_over_wash() {
let sr = 44100.0;
for_each_kit(|kit, name| {
let ride = render_drum_kit(51, 110, 2.0, kit);
let early = testutil::hp_rms(&ride[..(0.030 * sr) as usize], sr, 7500.0);
let late = testutil::hp_rms(
&ride[(0.150 * sr) as usize..(0.300 * sr) as usize],
sr,
7500.0,
);
let want = if kit == Kit::V3 { 2.5 } else { 3.0 };
assert!(
early > want * late,
"{name}: no ping: early {early} vs late {late} (needs {want}x)"
);
let wash = testutil::rms(&ride[(1.4 * sr) as usize..(1.8 * sr) as usize]);
assert!(wash > 5e-6, "{name}: the wash died with the ping: {wash}");
});
}
#[test]
fn snare_wires_engage_late() {
let sr = 44100.0;
for_each_kit(|kit, name| {
let snare = render_drum_kit(38, 110, 0.3, kit);
let wire = |a: f32, b: f32| {
testutil::hp_rms(&snare[(a * sr) as usize..(b * sr) as usize], sr, 2800.0)
};
let pre = wire(0.0, 0.001);
let post = wire(0.002, 0.006);
assert!(
pre < 0.35 * post,
"{name}: wires too early: {pre} vs {post}"
);
let shell = testutil::band_rms(&snare[..(0.001 * sr) as usize], sr, 1300.0, 0.7);
assert!(shell > 1e-3, "{name}: shell slap missing at t=0: {shell}");
});
}
#[test]
fn snare_grain_is_aperiodic_and_impulsive_v3() {
let sr = 44100.0;
let v2 = render_drum_kit(38, 100, 0.6, Kit::V2);
let v3 = render_drum_kit(38, 100, 0.6, Kit::V3);
let wire = |s: &[f32]| -> Vec<f32> {
let seg = &s[(0.010 * sr) as usize..(0.090 * sr) as usize];
let mut hp1 = Biquad::highpass(3000.0, 0.7, sr);
let mut hp2 = Biquad::highpass(3000.0, 0.7, sr);
seg.iter().map(|&x| hp2.process(hp1.process(x))).collect()
};
let (w2, w3) = (wire(&v2), wire(&v3));
let (p2, rate2) = testutil::env_autocorr_peak(&w2, sr, 1.0 / 300.0, 1.0 / 120.0);
let (p3, _) = testutil::env_autocorr_peak(&w3, sr, 1.0 / 300.0, 1.0 / 120.0);
let grain_kurt = |w: &[f32]| -> f32 {
let mut env = OnePole::lowpass(600.0, sr);
let norm: Vec<f32> = w
.iter()
.map(|&x| x / env.process(x.abs()).max(1e-6))
.collect();
testutil::kurtosis(&norm)
};
let (k2, k3) = (grain_kurt(&w2), grain_kurt(&w3));
let (r2, r3) = (testutil::rms(&v2), testutil::rms(&v3));
let db = 20.0 * (r3 / r2.max(1e-12)).log10();
let mean = v3.iter().sum::<f32>() / v3.len() as f32;
println!(
"P-S1 snare wire: env-autocorr v2={p2:.3}@{rate2:.0}Hz v3={p3:.3}; \
grain-kurtosis v2={k2:.2} v3={k3:.2}; level v3-v2={db:+.2} dB; v3 DC={mean:.2e}"
);
assert!(
p2 > 0.4,
"(a) v2 slap envelope periodicity not detected: v2={p2:.3}"
);
assert!(
p3 < 0.65 * p2,
"(a) v3 wire buzz still periodic: v3={p3:.3} vs v2={p2:.3}"
);
assert!(
db.abs() <= 2.0,
"(c) v3 snare level {db:+.2} dB outside ±2 dB of v2"
);
assert!(mean.abs() < 1e-3, "(d) v3 snare DC {mean:.2e}");
}
#[test]
fn snare_head_is_richer_and_physical_v3() {
let sr = 44100.0;
let v2 = render_drum_kit(38, 100, 0.3, Kit::V2);
let v3 = render_drum_kit(38, 100, 0.3, Kit::V3);
let h2 = &v2[..(0.04 * sr) as usize];
let h3 = &v3[..(0.04 * sr) as usize];
let c2 = testutil::spectral_centroid(h2, sr, 240.0, 600.0);
let c3 = testutil::spectral_centroid(h3, sr, 240.0, 600.0);
let f0 = testutil::spectral_band_rms(h3, sr, 165.0, 205.0);
let (r2, r3) = (testutil::rms(&v2), testutil::rms(&v3));
let db = 20.0 * (r3 / r2.max(1e-12)).log10();
let mean = v3.iter().sum::<f32>() / v3.len() as f32;
println!(
"P-S4 snare head: centroid v2={c2:.0} v3={c3:.0} Hz; f0band={f0:.4}; \
level v3-v2={db:+.2} dB; DC={mean:.2e}"
);
assert!(
c3 > c2 + 12.0,
"(1) v3 head not re-seated higher: v3={c3:.0} v2={c2:.0} Hz"
);
assert!(f0 > 1e-3, "(2) v3 fundamental head missing: {f0:.4}");
assert!(
db.abs() <= 2.0,
"(3) v3 snare level {db:+.2} dB outside ±2 dB of v2"
);
assert!(mean.abs() < 1e-3, "(4) v3 snare DC {mean:.2e}");
}
#[test]
fn snare_crack_snap_v3() {
let sr = 44100.0;
let ratio = |s: &[f32]| -> f32 {
let snap = testutil::hp_rms(&s[..(0.003 * sr) as usize], sr, 5000.0);
let body = testutil::rms(&s[(0.010 * sr) as usize..(0.040 * sr) as usize]);
snap / body.max(1e-9)
};
let v2s = render_drum_kit(38, 100, 0.3, Kit::V2);
let v3s = render_drum_kit(38, 100, 0.3, Kit::V3);
let (v2, v3) = (ratio(&v2s), ratio(&v3s));
let c40 = ratio(&render_drum_kit(38, 40, 0.3, Kit::V3));
let c80 = ratio(&render_drum_kit(38, 80, 0.3, Kit::V3));
let c120 = ratio(&render_drum_kit(38, 120, 0.3, Kit::V3));
let db = 20.0 * (testutil::rms(&v3s) / testutil::rms(&v2s).max(1e-12)).log10();
let mean = v3s.iter().sum::<f32>() / v3s.len() as f32;
println!(
"P-S2 snare crack: ratio v2={v2:.3} v3={v3:.3}; vel 40/80/120={c40:.3}/{c80:.3}/{c120:.3}; \
level v3-v2={db:+.2} dB; DC={mean:.2e}"
);
assert!(
v3 > 1.3 * v2,
"(1) v3 crack no louder than v2: v3={v3:.3} v2={v2:.3}"
);
assert!(
c120 > c80 && c80 > c40,
"(2) crack not velocity-monotone: {c40:.3}/{c80:.3}/{c120:.3}"
);
assert!(
db.abs() <= 3.0,
"(3) v3 snare level {db:+.2} dB far from v2"
);
assert!(mean.abs() < 1e-3, "(4) v3 snare DC {mean:.2e}");
}
#[test]
fn choke_kills_open_hat() {
let sr = 44100.0;
let mut v = make(46, 110, sr, 7, Kit::V1, false, 0).unwrap();
let mut head = vec![0f32; (0.05 * sr) as usize];
assert!(v.render(&mut head));
let before = testutil::rms(&head[(0.03 * sr) as usize..]);
v.choke();
let mut tail = vec![0f32; (0.1 * sr) as usize];
let alive = v.render(&mut tail);
assert!(!alive, "choked voice out-lived its 30 ms cap");
let after = testutil::rms(&tail[(0.03 * sr) as usize..]);
assert!(after < 0.05 * before, "choke too soft: {after} vs {before}");
}
#[test]
fn drum_velocity_shapes_timbre() {
let sr = 44100.0;
for_each_kit(|kit, name| {
let hard = render_drum_kit(36, 120, 1.0, kit);
let soft = render_drum_kit(36, 30, 1.0, kit);
let click = |s: &[f32], vel: u8| {
testutil::hp_rms(&s[..(0.005 * sr) as usize], sr, 2500.0)
/ crate::dsp::vel_amp(vel).max(1e-9)
};
let (ch, cs) = (click(&hard, 120), click(&soft, 30));
let want = if kit == Kit::V3 { 1.05 } else { 1.3 };
assert!(
ch > want * cs,
"{name}: click (gain-normalised) hard {ch} vs soft {cs} (needs {want}x)"
);
let (th, ts) = (testutil::t60_of(&hard, sr), testutil::t60_of(&soft, sr));
assert!(th > ts, "{name}: t60 hard {th} vs soft {ts}");
});
}
#[test]
fn dr4_snare_wire_clusters_are_resonant_v2() {
let sr = 44100.0;
let v1 = render_drum_kit(38, 110, 0.2, Kit::V1);
let v2 = render_drum_kit(38, 110, 0.2, Kit::V2);
let (a, b) = (0usize, (0.06 * sr) as usize);
let comb = |s: &[f32]| {
let seg = &s[a..b];
let mean = |fs: &[f32]| {
fs.iter()
.map(|&f| testutil::band_rms(seg, sr, f, 8.0))
.sum::<f32>()
/ fs.len() as f32
};
mean(&[3400.0, 5100.0, 7300.0]) / mean(&[4200.0, 6100.0]).max(1e-9)
};
let (c1, c2) = (comb(&v1), comb(&v2));
println!(
"DR4 wire comb (mean-centre/mean-trough, [0,60ms] Q8): v1={c1:.3} v2={c2:.3} ratio={:.2}x",
c2 / c1
);
assert!(
c2 > 1.35 && c2 > 1.4 * c1,
"v2 wire clusters not resonant vs v1 HP slope: v1={c1:.3} v2={c2:.3}"
);
}
#[test]
fn dr_o10_snare_v2_level_parity() {
let v1 = render_drum_kit(38, 100, 0.6, Kit::V1);
let v2 = render_drum_kit(38, 100, 0.6, Kit::V2);
let (r1, r2) = (testutil::rms(&v1), testutil::rms(&v2));
let db = 20.0 * (r2 / r1.max(1e-12)).log10();
println!(
"DR-O10 snare [0,0.6s] RMS: v1={r1:.5} v2={r2:.5} delta={db:+.2} dB (WIRE_MAKEUP={WIRE_MAKEUP})"
);
assert!(
db.abs() <= 2.0,
"snare v2 level {db:+.2} dB outside ±2 dB of v1 — trim WIRE_MAKEUP"
);
}
#[test]
fn dr4_key40_differentiates_only_in_v2() {
let s38_v1 = render_drum_kit(38, 100, 0.3, Kit::V1);
let s40_v1 = render_drum_kit(40, 100, 0.3, Kit::V1);
assert_eq!(s38_v1, s40_v1, "v1 must render keys 38 and 40 identically");
let s38_v2 = render_drum_kit(38, 100, 0.3, Kit::V2);
let s40_v2 = render_drum_kit(40, 100, 0.3, Kit::V2);
assert!(
s38_v2.iter().any(|&x| x.abs() > 1e-4),
"v2 snare makes sound"
);
assert_ne!(
s38_v2, s40_v2,
"v2 must render electric snare (40) differently from 38"
);
assert_ne!(
s38_v2, s38_v1,
"v2 snare must diverge from v1 (DR4 engaged)"
);
}
#[test]
fn straggler_keys_modeled_in_all_kits() {
for key in [58u8, 67, 68, 71, 72, 73, 74, 78, 79] {
let v1 = render_drum_kit(key, 100, 1.0, Kit::V1);
let v2 = render_drum_kit(key, 100, 1.0, Kit::V2);
let v3 = render_drum_kit(key, 100, 1.0, Kit::V3);
assert!(v1.iter().all(|x| x.is_finite()), "key {key} non-finite");
assert!(testutil::rms(&v1) > 1e-4, "key {key} inaudible");
assert_eq!(v1, v2, "key {key} differs V1 vs V2");
assert_eq!(v1, v3, "key {key} differs V1 vs V3");
}
}
fn env_cv(buf: &[f32], a: f32, b: f32) -> f32 {
let sr = 44100.0f32;
let (ia, ib) = ((a * sr) as usize, (b * sr) as usize);
let mut fast = OnePole::lowpass(200.0, sr);
let mut slow = OnePole::lowpass(4.0, sr);
let mut env = Vec::with_capacity(buf.len());
let mut detr = Vec::with_capacity(buf.len());
for &x in buf {
let e = fast.process(x.abs());
detr.push((e - slow.process(e)) as f64);
env.push(e as f64);
}
let mean = env[ia..ib].iter().sum::<f64>() / (ib - ia) as f64;
let var = detr[ia..ib].iter().map(|&d| d * d).sum::<f64>() / (ib - ia) as f64;
(var.sqrt() / mean.max(1e-12)) as f32
}
#[test]
fn vibraslap_rattles_and_decays() {
let sr = 44100.0;
let vib = render_drum_v1(58, 100, 1.0);
let mut plain = Drum::new(
sr,
7,
&[
(1730.0, 0.45, 0.50, 0.0),
(2470.0, 0.30, 0.42, 0.0),
(3150.0, 0.18, 0.35, 0.0),
],
&[(0.45, 0.45, Biquad::bandpass(2100.0, 1.0, sr))],
0.85,
0.45 * crate::dsp::vel_amp(100),
);
let mut smooth = vec![0f32; sr as usize];
Voice::render(&mut plain, &mut smooth);
let (cv_vib, cv_plain) = (env_cv(&vib, 0.05, 0.40), env_cv(&smooth, 0.05, 0.40));
println!("vibraslap env CV={cv_vib:.3} vs shimmerless CV={cv_plain:.3}");
assert!(
cv_vib > 1.35 * cv_plain,
"vibraslap does not rattle: CV {cv_vib} vs shimmerless {cv_plain}"
);
let life = last_audible(&vib);
assert!((0.5..=0.9).contains(&life), "vibraslap life {life}");
let early = testutil::rms(&vib[..(0.10 * sr) as usize]);
let late = testutil::rms(sec_window(&vib, sr, 0.55, 0.70));
assert!(
late < 0.3 * early,
"vibraslap does not decay: {late} vs {early}"
);
}
#[test]
fn agogo_pair_pitched_and_fast() {
let sr = 44100.0;
let hi = render_drum_v1(67, 100, 0.6);
let lo = render_drum_v1(68, 100, 0.6);
fn live(b: &[f32]) -> &[f32] {
let sr = 44100.0;
&b[(0.005 * sr) as usize..(0.15 * sr) as usize]
}
let f_hi = testutil::peak_locate(live(&hi), sr, 1400.0, 1900.0);
let f_lo = testutil::peak_locate(live(&lo), sr, 1000.0, 1450.0);
println!("agogo pitches: hi={f_hi:.0} Hz lo={f_lo:.0} Hz");
assert!((f_hi - 1650.0).abs() < 150.0, "hi agogo at {f_hi} Hz");
assert!((f_lo - 1220.0).abs() < 120.0, "lo agogo at {f_lo} Hz");
assert!(
f_hi > 1.25 * f_lo,
"agogo pitch order/spread: {f_hi} vs {f_lo}"
);
for (buf, f0) in [(&hi, 1650.0f32), (&lo, 1220.0)] {
let floor = testutil::mag_at(live(buf), sr, f0) * 0.05;
let p = testutil::peak_locate(live(buf), sr, f0 * 1.70 * 0.92, f0 * 1.70 * 1.08);
assert!(
testutil::mag_at(live(buf), sr, p) > floor,
"agogo 1.7x mode missing near {} Hz",
f0 * 1.70
);
}
let early = testutil::rms(&hi[..(0.05 * sr) as usize]);
let late = testutil::rms(sec_window(&hi, sr, 0.35, 0.50));
assert!(
late < 0.10 * early,
"agogo rings too long: {late} vs {early}"
);
}
#[test]
fn whistles_pitched_short_vs_long() {
let sr = 44100.0;
let short = render_drum_v1(71, 100, 0.8);
let long = render_drum_v1(72, 100, 0.8);
for (name, b) in [("short", &short), ("long", &long)] {
let f = testutil::peak_locate(&b[..(0.10 * sr) as usize], sr, 1900.0, 2700.0);
assert!((f - 2350.0).abs() < 200.0, "{name} whistle pitch {f} Hz");
}
let (ls, ll) = (last_audible(&short), last_audible(&long));
println!("whistle lives: short={ls:.3}s long={ll:.3}s");
assert!(ls < 0.25, "short whistle life {ls}");
assert!(ll > 0.30, "long whistle life {ll}");
let hiss = testutil::hp_rms(&long[..(0.05 * sr) as usize], sr, 5000.0);
assert!(hiss > 1e-3, "whistle breath noise missing: {hiss}");
}
#[test]
fn guiros_notched_short_vs_long() {
let short = render_drum_v1(73, 100, 0.8);
let long = render_drum_v1(74, 100, 0.8);
let sr = 44100.0;
let (ps, rs) = testutil::env_autocorr_peak(
&short[..(0.15 * sr) as usize],
sr,
1.0 / 130.0,
1.0 / 60.0,
);
let (pl, rl) =
testutil::env_autocorr_peak(&long[..(0.45 * sr) as usize], sr, 1.0 / 90.0, 1.0 / 40.0);
println!(
"guiro pulse trains: short peak={ps:.3} @{rs:.0} Hz, long peak={pl:.3} @{rl:.0} Hz"
);
assert!(ps > 0.2, "short guiro not notched: peak {ps}");
assert!(pl > 0.2, "long guiro not notched: peak {pl}");
assert!((75.0..=110.0).contains(&rs), "short guiro rate {rs} Hz");
assert!((50.0..=75.0).contains(&rl), "long guiro rate {rl} Hz");
let (ls, ll) = (last_audible(&short), last_audible(&long));
assert!(ls < 0.25, "short guiro life {ls}");
assert!(ll > 0.35, "long guiro life {ll}");
}
#[test]
fn cuicas_glide_opposite_ways() {
let sr = 44100.0;
let mute = render_drum_v1(78, 100, 0.6);
let open = render_drum_v1(79, 100, 0.8);
let peak = |b: &[f32], a: f32, z: f32, lo: f32, hi: f32| {
testutil::peak_locate(&b[(a * sr) as usize..(z * sr) as usize], sr, lo, hi)
};
let m_early = peak(&mute, 0.0, 0.04, 400.0, 900.0);
let m_late = peak(&mute, 0.08, 0.14, 250.0, 500.0);
let o_early = peak(&open, 0.0, 0.06, 300.0, 550.0);
let o_late = peak(&open, 0.25, 0.40, 550.0, 850.0);
println!(
"cuica glides: mute {m_early:.0}->{m_late:.0} Hz, open {o_early:.0}->{o_late:.0} Hz"
);
assert!(
m_early > 1.15 * m_late,
"mute cuica did not glide down: {m_early} -> {m_late}"
);
assert!(
o_late > 1.15 * o_early,
"open cuica did not glide up: {o_early} -> {o_late}"
);
let (lm, lo_life) = (last_audible(&mute), last_audible(&open));
assert!(lm < 0.25, "mute cuica life {lm}");
assert!(lo_life > 0.30, "open cuica life {lo_life}");
assert!(lo_life > lm, "open cuica must out-live mute");
}
const SR12: f32 = 44100.0;
fn seg(buf: &[f32], a: f32, b: f32) -> &[f32] {
&buf[(a * SR12) as usize..(b * SR12) as usize]
}
fn db(a: f32, b: f32) -> f32 {
20.0 * (a / b.max(1e-12)).log10()
}
fn hp_win(b: &[f32], corner: f32, a: f32, z: f32) -> f32 {
let mut hp = Biquad::highpass(corner, 0.7, SR12);
let f: Vec<f32> = b.iter().map(|&x| hp.process(x)).collect();
testutil::rms(seg(&f, a, z))
}
#[test]
fn brush_kit_inert_for_untouched_keys() {
let v1 = render_drum_kit(56, 100, 0.6, Kit::V1);
let v2 = render_drum_kit(56, 100, 0.6, Kit::V2);
let v3 = render_drum_kit(56, 100, 0.6, Kit::V3);
let br = render_drum_kit(56, 100, 0.6, Kit::Brush);
assert!(v1.iter().any(|&x| x.abs() > 1e-4), "cowbell makes sound");
assert_eq!(v1, v2, "cowbell 56 must not branch on kit (V1 vs V2)");
assert_eq!(v2, v3, "cowbell 56 must not branch on kit (V2 vs V3)");
assert_eq!(v3, br, "cowbell 56 must not branch on kit (V3 vs Brush)");
}
#[test]
fn brush_falls_back_to_v3() {
for key in [49u8, 51, 41] {
let v1 = render_drum_kit(key, 100, 1.0, Kit::V1);
let v3 = render_drum_kit(key, 100, 1.0, Kit::V3);
let br = render_drum_kit(key, 100, 1.0, Kit::Brush);
assert_eq!(br, v3, "brush key {key} must fall back to the V3 voice");
assert_ne!(br, v1, "brush key {key} wrongly renders the V1 voice");
}
}
#[test]
fn brush_tap_no_wire_spike() {
let hf_frac = |b: &[f32]| {
let s = &b[..(0.15 * SR12) as usize];
testutil::hp_rms(s, SR12, 4500.0) / testutil::rms(s).max(1e-12)
};
let brush = hf_frac(&render_drum_kit(38, 100, 0.3, Kit::Brush));
let v3 = hf_frac(&render_drum_kit(38, 100, 0.3, Kit::V3));
println!("BR-O1 hf fraction: brush {brush:.4} vs v3 {v3:.4}");
assert!(
brush <= 0.5 * v3,
"brush tap still spikes the wires: {brush:.4} vs v3 {v3:.4}"
);
}
#[test]
fn brush_tap_level_vs_v1_snare() {
let tap = render_drum_kit(38, 100, 0.25, Kit::Brush);
let sn = render_drum_kit(38, 100, 0.25, Kit::V1);
let d = db(
testutil::rms(&tap[..(0.20 * SR12) as usize]),
testutil::rms(&sn[..(0.20 * SR12) as usize]),
);
println!("BR-O2 tap vs v1 snare level: {d:+.2} dB");
assert!(
d.abs() <= 2.0,
"brush tap level {d:+.2} dB off the v1 snare"
);
}
#[test]
fn brush_tap_tones_structural_seam() {
for (i, (bt, st)) in BRUSH_TAP_TONES.iter().zip(SNARE_TONES.iter()).enumerate() {
assert_eq!(bt.0, st.0, "tone {i}: head frequency changed");
assert!(
bt.2 < st.2,
"tone {i}: brush T60 {} not shorter than snare {}",
bt.2,
st.2
);
}
}
#[test]
fn brush_slap_accent_and_double_contact() {
let slap = render_drum_kit(39, 100, 0.3, Kit::Brush);
let tap = render_drum_kit(38, 100, 0.3, Kit::Brush);
let d = db(
testutil::rms(&slap[..(0.20 * SR12) as usize]),
testutil::rms(&tap[..(0.20 * SR12) as usize]),
);
println!("BR-O4 slap vs tap level: {d:+.2} dB");
assert!(d > 0.4, "slap not an accent: {d:+.2} dB vs tap");
let second_contact =
|b: &[f32]| hp_win(b, 800.0, 0.013, 0.022) / hp_win(b, 800.0, 0.004, 0.011).max(1e-12);
let rs = second_contact(&slap);
let rt = second_contact(&tap);
println!("BR-O5 second-contact ratio: slap {rs:.3} vs tap {rt:.3}");
assert!(
rs >= 1.2 * rt,
"slap second contact missing: {rs:.3} vs tap {rt:.3}"
);
}
#[test]
fn brush_slap_reexcite_tracks_velocity() {
let ratio = |vel: u8| {
let s = render_drum_kit(39, vel, 0.3, Kit::Brush);
hp_win(&s, 800.0, 0.013, 0.022) / hp_win(&s, 800.0, 0.004, 0.011).max(1e-12)
};
let ghost = ratio(30);
let loud = ratio(100);
println!("BR-O5b re-strike ratio: ghost(30)={ghost:.3} loud(100)={loud:.3}");
assert!(
ghost <= 1.15 * loud,
"ghost re-strike ratio {ghost:.3} runs away from vel-100 {loud:.3}"
);
}
#[test]
fn absolute_burst_drums_scale_purely_by_gain() {
let c = vel_amp(100) / vel_amp(30);
for key in [39u8, 73, 74] {
let ghost = render_drum_kit(key, 30, 0.3, Kit::V3);
let loud = render_drum_kit(key, 100, 0.3, Kit::V3);
let (mut max_err, mut peak) = (0f32, 1e-9f32);
for (&g, &l) in ghost.iter().zip(loud.iter()) {
max_err = max_err.max((g * c - l).abs());
peak = peak.max(l.abs());
}
let rel = max_err / peak;
println!("key {key}: pure-gain reconstruction rel err {rel:.2e}");
assert!(
rel < 1e-4,
"key {key} burst drum is not pure-gain velocity scaling (rel err {rel:.2e}) \
— a velocity-dependent burst/first-contact ratio (00001 class) may have crept in"
);
}
}
#[test]
fn brush_swirl_swells_not_hits() {
let b = render_drum_kit(40, 100, 1.0, Kit::Brush);
let peak = b.iter().fold(0f32, |m, &x| m.max(x.abs()));
let early = b[..(0.03 * SR12) as usize]
.iter()
.fold(0f32, |m, &x| m.max(x.abs()));
let peak_t = b.iter().position(|&x| x.abs() >= 0.999 * peak).unwrap_or(0) as f32 / SR12;
println!("SW-O1 early peak {early:.4} vs peak {peak:.4} at {peak_t:.3} s");
assert!(
early <= 0.4 * peak,
"swirl hits instead of swelling: {early:.4} vs {peak:.4}"
);
assert!(peak_t >= 0.05, "swirl peaks too early: {peak_t:.3} s");
}
#[test]
fn brush_swirl_sustain_and_return_sweep() {
let b = render_drum_kit(40, 100, 1.0, Kit::Brush);
let sustain = testutil::rms(seg(&b, 0.45, 0.70)) / testutil::rms(seg(&b, 0.10, 0.35));
println!("SW-O2 sustain ratio {sustain:.3}");
assert!(sustain >= 0.5, "swirl dies mid-stroke: {sustain:.3}");
let ret = hp_win(&b, 3700.0, 0.50, 0.70) / hp_win(&b, 3700.0, 0.10, 0.30).max(1e-12);
println!("SW-O2 return-sweep HF ratio {ret:.3}");
assert!(ret >= 1.3, "no return sweep: late/early HF {ret:.3}");
}
#[test]
fn brush_swirl_slow_am_differential() {
let am5 = |mut v: Box<dyn Voice>| {
let mut b = vec![0f32; (1.0 * SR12) as usize];
v.render(&mut b);
let mut lp = OnePole::lowpass(25.0, SR12);
let env: Vec<f32> = b.iter().map(|&x| lp.process(x.abs())).collect();
let s = &env[(0.1 * SR12) as usize..(0.9 * SR12) as usize];
let mean = s.iter().sum::<f32>() / s.len() as f32;
let d: Vec<f32> = s.iter().map(|&x| x - mean).collect();
testutil::mag_at(&d, SR12, BRUSH_SWIRL_AM_RATE_HZ) / mean.max(1e-12)
};
let with = am5(brush_swirl(100, SR12, 7).unwrap());
let without = am5(Box::new(brush_swirl_drum(100, SR12, 7)));
println!("SW-O3 5 Hz AM: with {with:.4} vs without {without:.4}");
assert!(with >= 0.75, "stir AM too shallow: {with:.4}");
assert!(
with >= 1.35 * without,
"stir AM missing: {with:.4} vs {without:.4}"
);
}
#[test]
fn brush_swirl_spectral_seat() {
let b = render_drum_kit(40, 100, 1.0, Kit::Brush);
let c = testutil::centroid(seg(&b, 0.05, 0.85), SR12);
println!("SW-O4 swirl centroid {c:.0} Hz");
assert!(
(1800.0..=4500.0).contains(&c),
"swirl centroid {c:.0} Hz outside its seat"
);
}
#[test]
fn brush_closed_hat_darker_and_level() {
let br = render_drum_kit(42, 100, 0.16, Kit::Brush);
let v1 = render_drum_kit(42, 100, 0.16, Kit::V1);
let cb = testutil::centroid(&br[..(0.14 * SR12) as usize], SR12);
let c1 = testutil::centroid(&v1[..(0.14 * SR12) as usize], SR12);
println!("BH-O1 closed-hat centroid: brush {cb:.0} vs v1 {c1:.0} Hz");
assert!(
cb <= 0.85 * c1,
"brush closed hat not darker: {cb:.0} vs {c1:.0}"
);
let d = db(
testutil::rms(&br[..(0.14 * SR12) as usize]),
testutil::rms(&v1[..(0.14 * SR12) as usize]),
);
println!("BH-O2 closed-hat level: {d:+.2} dB");
assert!(d.abs() <= 2.0, "brush closed hat level {d:+.2} dB off v1");
}
#[test]
fn brush_open_hat_darker_dr3_and_level() {
let br = render_drum_kit(46, 100, 0.95, Kit::Brush);
let v3 = render_drum_kit(46, 100, 0.95, Kit::V3);
let cb = testutil::centroid(seg(&br, 0.0, 0.9), SR12);
let c3 = testutil::centroid(seg(&v3, 0.0, 0.9), SR12);
println!("BH-O3 open-hat centroid: brush {cb:.0} vs v3 {c3:.0} Hz");
assert!(
cb <= 0.85 * c3,
"brush open hat not darker: {cb:.0} vs {c3:.0}"
);
let hf_env_cv = |mut v: Box<dyn Voice>| {
let mut b = vec![0f32; (0.95 * SR12) as usize];
v.render(&mut b);
let mut hp = Biquad::highpass(3000.0, 0.7, SR12);
let mut lp = OnePole::lowpass(90.0, SR12);
let mut slow = OnePole::lowpass(3.0, SR12);
let flat: Vec<f64> = b
.iter()
.map(|&x| {
let e = lp.process(hp.process(x).abs());
(e / slow.process(e).max(1e-9)) as f64
})
.collect();
let s = &flat[(0.1 * SR12) as usize..(0.8 * SR12) as usize];
let mean = s.iter().sum::<f64>() / s.len() as f64;
let var = s.iter().map(|&x| (x - mean) * (x - mean)).sum::<f64>() / s.len() as f64;
var.sqrt() / mean.max(1e-12)
};
let with = hf_env_cv(make(46, 100, SR12, 7, Kit::Brush, false, 0).unwrap());
let mut spec = brush_open_hat_spec();
spec.shimmer = None;
let without = hf_env_cv(cymbal(&spec, SR12, 7, 100).unwrap());
println!("BH-O4 sizzle wobble CV: with {with:.4} vs without {without:.4}");
assert!(
with >= 1.5 * without,
"DR3 sizzle wobble missing: {with:.4} vs {without:.4}"
);
let d = db(
testutil::rms(seg(&br, 0.0, 0.9)),
testutil::rms(seg(&v3, 0.0, 0.9)),
);
println!("BH-O5 open-hat level: {d:+.2} dB");
assert!(d.abs() <= 2.0, "brush open hat level {d:+.2} dB off v3");
}
#[test]
fn brush_rim_woodier_and_level() {
let br = render_drum_kit(37, 100, 0.12, Kit::Brush);
let v1 = render_drum_kit(37, 100, 0.12, Kit::V1);
let cb = testutil::centroid(&br[..(0.08 * SR12) as usize], SR12);
let c1 = testutil::centroid(&v1[..(0.08 * SR12) as usize], SR12);
println!("BR-O6 rim centroid: brush {cb:.0} vs v1 {c1:.0} Hz");
assert!(cb <= 0.8 * c1, "brush rim not woodier: {cb:.0} vs {c1:.0}");
let d = db(
testutil::rms(&br[..(0.10 * SR12) as usize]),
testutil::rms(&v1[..(0.10 * SR12) as usize]),
);
println!("BR-O6 rim level: {d:+.2} dB");
assert!(d.abs() <= 2.0, "brush rim level {d:+.2} dB off v1");
}
#[test]
fn brush_kick_soft_beater_sub_intact() {
let br = render_drum_kit(36, 100, 0.6, Kit::Brush);
let v1 = render_drum_kit(36, 100, 0.6, Kit::V1);
let sub = db(
testutil::band_rms(seg(&br, 0.0, 0.5), SR12, 86.0, 1.0),
testutil::band_rms(seg(&v1, 0.0, 0.5), SR12, 86.0, 1.0),
);
println!("BK-O1 sub band delta: {sub:+.2} dB");
assert!(sub.abs() <= 2.0, "brush kick sub drifted: {sub:+.2} dB");
let hf_frac = |b: &[f32]| {
hp_win(b, 2000.0, 0.001, 0.03) / testutil::rms(&b[..(0.03 * SR12) as usize]).max(1e-12)
};
let hb = hf_frac(&br);
let h1 = hf_frac(&v1);
println!("BK-O2 beater HF fraction: brush {hb:.4} vs v1 {h1:.4}");
assert!(
hb <= 0.5 * h1,
"brush beater not softer: {hb:.4} vs {h1:.4}"
);
let d = db(
testutil::rms(seg(&br, 0.0, 0.5)),
testutil::rms(seg(&v1, 0.0, 0.5)),
);
println!("BK-O3 kick level: {d:+.2} dB");
assert!(d.abs() <= 2.0, "brush kick level {d:+.2} dB off v1");
}
#[test]
fn brush_render_signatures_are_stable() {
let cases = [
(
36,
testutil::RenderSignature {
rms_db: -13.473,
centroid_hz: 168.235,
late_early_db: -7.434,
},
),
(
38,
testutil::RenderSignature {
rms_db: -21.372,
centroid_hz: 474.734,
late_early_db: -40.576,
},
),
(
39,
testutil::RenderSignature {
rms_db: -20.274,
centroid_hz: 552.960,
late_early_db: -36.990,
},
),
(
40,
testutil::RenderSignature {
rms_db: -44.517,
centroid_hz: 2800.437,
late_early_db: 16.597,
},
),
(
42,
testutil::RenderSignature {
rms_db: -35.340,
centroid_hz: 6485.427,
late_early_db: -61.407,
},
),
(
46,
testutil::RenderSignature {
rms_db: -29.344,
centroid_hz: 6471.648,
late_early_db: -4.519,
},
),
];
for (key, expected) in cases {
let rendered = render_drum_kit(key, 100, 1.0, Kit::Brush);
testutil::assert_render_signature(
&format!("brush drum key {key}"),
testutil::render_signature(
&rendered,
44100.0,
(0.0, 0.20),
(0.0, 0.02),
(0.04, 0.08),
),
expected,
);
}
}
}