makepad/libs/drumkit_phys/tests/common/mod.rs
Admin f2b02531b7 drumkit: a sample-based kit for the score preview; the physical model parked as drumkit_phys
The kit that plays drum scores is now a sample player over the Salamander Drumkit (velocity layers with round-robins, derived toms, a synthesised clap), loaded off the audio thread and swapped in atomically. The physically modelled kit stays as makepad-drumkit-phys for a later pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 02:52:12 +02:00

265 lines
9.1 KiB
Rust

// Shared numerical analysis for the drum kit tests: rendering helpers, a
// radix-2 FFT, band energies, spectral centroid, peak picking. Written
// from first principles (no dependencies), f64 accumulation.
#![allow(dead_code)]
use makepad_drumkit_phys::{DrumKit, DrumVoice};
pub const FS: f32 = 48000.0;
/// A trigger at an absolute sample.
#[derive(Clone, Copy)]
pub struct Hit {
pub at: usize,
pub voice: DrumVoice,
pub velocity: f32,
}
/// Renders `total` frames in blocks of `block`, firing hits at their exact
/// block boundary (hits must fall on multiples of every block size used).
pub fn render(kit: &mut DrumKit, hits: &[Hit], total: usize, block: usize) -> Vec<[f32; 2]> {
let mut out = vec![[0.0f32; 2]; total];
let mut pos = 0;
while pos < total {
let n = block.min(total - pos);
for h in hits {
if h.at == pos {
kit.trigger(h.voice, h.velocity);
}
}
kit.process(&mut out[pos..pos + n]);
pos += n;
}
out
}
/// One hit rendered until the voice goes quiet or `max_s`.
pub fn render_hit(fs: f32, voice: DrumVoice, velocity: f32, max_s: f32) -> (Vec<f32>, f32) {
let mut kit = DrumKit::new(fs);
kit.trigger(voice, velocity);
let mut out: Vec<f32> = Vec::new();
let mut block = [[0.0f32; 2]; 256];
let max = (max_s * fs) as usize;
while kit.active() && out.len() < max {
block.iter_mut().for_each(|f| *f = [0.0; 2]);
kit.process(&mut block);
out.extend(block.iter().map(|f| 0.5 * (f[0] + f[1])));
}
let life = out.len() as f32 / fs;
(out, life)
}
pub fn mono(x: &[[f32; 2]]) -> Vec<f32> {
x.iter().map(|f| 0.5 * (f[0] + f[1])).collect()
}
pub fn peak(x: &[f32]) -> f32 {
x.iter().fold(0.0f32, |a, v| a.max(v.abs()))
}
pub fn energy(x: &[f32]) -> f64 {
x.iter().map(|&v| (v as f64) * (v as f64)).sum()
}
pub fn db(x: f64) -> f64 {
10.0 * x.max(1e-30).log10()
}
/// In-place radix-2 complex FFT (n a power of two).
pub fn fft(re: &mut [f64], im: &mut [f64]) {
let n = re.len();
assert!(n.is_power_of_two() && im.len() == n);
let mut j = 0;
for i in 1..n {
let mut bit = n >> 1;
while j & bit != 0 {
j ^= bit;
bit >>= 1;
}
j |= bit;
if i < j {
re.swap(i, j);
im.swap(i, j);
}
}
let mut len = 2;
while len <= n {
let ang = -2.0 * std::f64::consts::PI / len as f64;
let (wr, wi) = (ang.cos(), ang.sin());
let mut i = 0;
while i < n {
let (mut cr, mut ci) = (1.0, 0.0);
for k in 0..len / 2 {
let (ur, ui) = (re[i + k], im[i + k]);
let (vr, vi) = (re[i + k + len / 2] * cr - im[i + k + len / 2] * ci, re[i + k + len / 2] * ci + im[i + k + len / 2] * cr);
re[i + k] = ur + vr;
im[i + k] = ui + vi;
re[i + k + len / 2] = ur - vr;
im[i + k + len / 2] = ui - vi;
let ncr = cr * wr - ci * wi;
ci = cr * wi + ci * wr;
cr = ncr;
}
i += len;
}
len <<= 1;
}
}
/// Power spectrum (Hann window) of x[a..b], zero-padded to a power of two.
/// Returns (bin frequencies, power).
pub fn spectrum(x: &[f32], fs: f32, a: usize, b: usize) -> (Vec<f64>, Vec<f64>) {
let b = b.min(x.len());
let seg = &x[a.min(b)..b];
let n = seg.len().next_power_of_two().max(64);
let mut re = vec![0.0f64; n];
let mut im = vec![0.0f64; n];
let m = seg.len();
for (i, &v) in seg.iter().enumerate() {
let w = 0.5 - 0.5 * (2.0 * std::f64::consts::PI * i as f64 / m as f64).cos();
re[i] = v as f64 * w;
}
fft(&mut re, &mut im);
let half = n / 2;
let freqs: Vec<f64> = (0..half).map(|k| k as f64 * fs as f64 / n as f64).collect();
let pw: Vec<f64> = (0..half).map(|k| re[k] * re[k] + im[k] * im[k]).collect();
(freqs, pw)
}
/// x filtered to [f_lo, f_hi) Hz by FFT masking (zero phase, brickwall),
/// same length as x.
pub fn band_signal(x: &[f32], fs: f32, f_lo: f64, f_hi: f64) -> Vec<f32> {
let n = x.len().next_power_of_two().max(64);
let mut re = vec![0.0f64; n];
let mut im = vec![0.0f64; n];
for (i, &v) in x.iter().enumerate() {
re[i] = v as f64;
}
fft(&mut re, &mut im);
let df = fs as f64 / n as f64;
for k in 0..n {
let f = if k <= n / 2 { k as f64 * df } else { (n - k) as f64 * df };
if f < f_lo || f >= f_hi {
re[k] = 0.0;
im[k] = 0.0;
}
}
// inverse via conjugation
for v in im.iter_mut() {
*v = -*v;
}
fft(&mut re, &mut im);
re.iter().take(x.len()).map(|v| (*v / n as f64) as f32).collect()
}
/// Energy of the [f_lo, f_hi) band of x in the window [t0, t1) seconds
/// (time-domain sum of the band-filtered signal, as the reference harness).
pub fn band_energy(x: &[f32], fs: f32, t0: f32, t1: f32, f_lo: f64, f_hi: f64) -> f64 {
let b = band_signal(x, fs, f_lo, f_hi);
let (a, e) = ((t0 * fs) as usize, ((t1 * fs) as usize).min(b.len()));
energy(&b[a.min(e)..e])
}
/// Spectral centroid (Hz) of x in [t0, t1) above 20 Hz.
pub fn centroid(x: &[f32], fs: f32, t0: f32, t1: f32) -> f64 {
let (f, p) = spectrum(x, fs, (t0 * fs) as usize, (t1 * fs) as usize);
let (mut num, mut den) = (0.0, 0.0);
for (fr, v) in f.iter().zip(&p) {
if *fr >= 20.0 {
num += fr * v;
den += v;
}
}
num / den.max(1e-30)
}
/// Strongest spectral peak (Hz) below f_max in [t0, t1), with parabolic
/// interpolation.
pub fn strongest_partial(x: &[f32], fs: f32, t0: f32, t1: f32, f_max: f64) -> f64 {
let (f, p) = spectrum(x, fs, (t0 * fs) as usize, (t1 * fs) as usize);
let lim = f.iter().position(|v| *v > f_max).unwrap_or(f.len());
let mut best = 1;
for i in 1..lim.saturating_sub(1) {
if p[i] > p[best] {
best = i;
}
}
let (y0, y1, y2) = (p[best - 1].max(1e-30).ln(), p[best].max(1e-30).ln(), p[best + 1].max(1e-30).ln());
let d = 0.5 * (y0 - y2) / (y0 - 2.0 * y1 + y2 + 1e-30);
f[best] + d * (f[1] - f[0])
}
/// Mean instantaneous frequency (Hz) from positive-going zero crossings of
/// the < f_max band of x within [t0, t1) — the reference harness's tracker.
pub fn zc_frequency(x: &[f32], fs: f32, f_min: f64, f_max: f64, t0: f32, t1: f32) -> f64 {
let lp = band_signal(x, fs, f_min, f_max);
let (a, b) = ((t0 * fs) as usize, ((t1 * fs) as usize).min(lp.len()));
let mut crossings = Vec::new();
for i in a.max(1)..b {
if lp[i - 1] < 0.0 && lp[i] >= 0.0 {
let frac = -lp[i - 1] / (lp[i] - lp[i - 1]);
crossings.push(i as f64 - 1.0 + frac as f64);
}
}
if crossings.len() < 2 {
return f64::NAN;
}
let periods = crossings.len() as f64 - 1.0;
fs as f64 * periods / (crossings[crossings.len() - 1] - crossings[0])
}
/// Cowbell-ness of a cymbal: in the [t0, t1) window, spectral peaks in
/// [f_lo, f_hi] with > 3 dB prominence over a 40 Hz neighbourhood; returns
/// (number of peaks, max over peaks of level minus the median level of its
/// 6 nearest peaks, in dB). A fixed-ratio cluster scores > 45 dB on the
/// second number; the reference cymbals score 22-31 dB.
pub fn peakiness(x: &[f32], fs: f32, t0: f32, t1: f32, f_lo: f64, f_hi: f64) -> (usize, f64) {
let (f, p) = spectrum(x, fs, (t0 * fs) as usize, (t1 * fs) as usize);
let s: Vec<f64> = p.iter().map(|v| db(*v)).collect();
let df = f[1] - f[0];
let w = (40.0 / df).max(1.0) as usize;
let mut peaks: Vec<(f64, f64)> = Vec::new();
for i in (w + 1)..(s.len() - w - 1) {
if f[i] < f_lo || f[i] > f_hi {
continue;
}
if s[i] > s[i - 1] && s[i] >= s[i + 1] {
let lo = s[i - w..i].iter().cloned().fold(f64::INFINITY, f64::min);
let hi = s[i + 1..i + 1 + w].iter().cloned().fold(f64::INFINITY, f64::min);
if s[i] - lo.max(hi) > 3.0 {
peaks.push((f[i], s[i]));
}
}
}
if peaks.len() < 8 {
return (peaks.len(), f64::NAN);
}
let mut worst = f64::MIN;
for k in 0..peaks.len() {
let mut d: Vec<(f64, f64)> = peaks.iter().enumerate().filter(|(j, _)| *j != k).map(|(_, q)| ((q.0 - peaks[k].0).abs(), q.1)).collect();
d.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
let mut lv: Vec<f64> = d.iter().take(6).map(|q| q.1).collect();
lv.sort_by(|a, b| a.partial_cmp(b).unwrap());
let med = 0.5 * (lv[2] + lv[3]);
worst = worst.max(peaks[k].1 - med);
}
(peaks.len(), worst)
}
/// Time (s) of the peak of the > f_lo Hz energy, in `win` s windows.
pub fn high_band_peak_time(x: &[f32], fs: f32, f_lo: f64, win: f32, total: f32) -> f32 {
let mut best = (0.0f32, -1.0f64);
let mut t = 0.0f32;
while t + win <= total {
let e = band_energy(x, fs, t, t + win, f_lo, 24000.0);
if e > best.1 {
best = (t, e);
}
t += win;
}
best.0 + 0.5 * win
}
pub fn all_finite(x: &[[f32; 2]]) -> bool {
x.iter().all(|f| f[0].is_finite() && f[1].is_finite())
}