makepad/libs/piano_model/tests/common/mod.rs
Admin 145d0b1fe2 score: the notation suite — engraving, layout, playback, midi and musicxml import, the physical piano model, and the score app
Squashed from work:
- score: a headless music engraving, playback and notation engine
- score: the notation app — pianist mode, editing, playback
- piano_model: it was a plucked string by construction, and 20 voicings
- score: one document you can pan, zoom and navigate
- piano_model: the body tap was a click, and the objective was rewarding noise
- score: add the sound panel and library modules
- piano_model: a second engine, and the attack that finally sounded right
- score: two instruments, reverb and brightness — and the rest of the panel gone
- score model: a note remembers how it was struck, and the score remembers the pedal
- score import: keep the velocities and the pedal the file was carrying
- score playback: play the performance, not a flattened copy of it
- score ui: the music list moves to the sidebar, and the view stops fighting itself
- score: the application ships its font and eight performances
- piano_model: a limiter that rides the music, so the knee stops shaping chords
- piano_model: the forte bell was the treble's dynamic slope, and the bass was dying at its own prompt rate
- piano_model: the bridge decides each partial's decay, and a fixed multiplier cannot say that
- piano_model: each partial gets its own two coupled modes, from the eigen algebra
- piano_model: a median that fell between the peaks made every bass partial a drain
- score-ai: LocalBroker — the seam's in-process implementation over the session engine (aicore P8)
- client + chat dispatcher: the dead wire comes out (aicore P7/P8)
- score_pdf: the score model grew a pedal map — the pdf importer initialises it
- libs: the zero-warning sweep — stitch casts say what they mean, xatlas keeps upstream's surface quietly
- score app: the shipped-piece test speaks the PERFORMANCES table
- zero-warning sweep, round three — the model lanes and the deep examples
2026-09-01 16:46:32 +02:00

265 lines
8.2 KiB
Rust

// Shared numerical analysis for the verification tests: rendering helpers,
// windowed DFT / FFT, partial-peak measurement, decay fitting. All written
// here from first principles (no dependencies), all f64 accumulation.
#![allow(dead_code)]
use makepad_piano_model::{Piano, PianoEvent, TimedEvent};
pub const FS: f32 = 48000.0;
/// An absolute-time event script.
#[derive(Clone, Copy)]
pub struct Ev {
pub at: u64, // absolute sample
pub ev: PianoEvent,
}
pub fn ev(at_sec: f64, ev: PianoEvent) -> Ev {
Ev { at: (at_sec * FS as f64).round() as u64, ev }
}
/// Renders `total` samples in blocks of `block`, feeding events at their
/// exact in-block offsets. Returns (left, right).
pub fn render(p: &mut Piano, script: &[Ev], total: usize, block: usize) -> (Vec<f32>, Vec<f32>) {
let mut l = vec![0.0f32; total];
let mut r = vec![0.0f32; total];
let mut te: Vec<TimedEvent> = Vec::new();
let mut pos = 0usize;
while pos < total {
let n = block.min(total - pos);
te.clear();
for e in script {
if e.at >= pos as u64 && e.at < (pos + n) as u64 {
te.push(TimedEvent { offset: (e.at - pos as u64) as u32, event: e.ev });
}
}
p.process(&te, &mut l[pos..pos + n], &mut r[pos..pos + n]);
pos += n;
}
(l, r)
}
/// Same, but through the multicore path.
pub fn render_mt(p: &mut Piano, script: &[Ev], total: usize, block: usize, workers: usize) -> (Vec<f32>, Vec<f32>) {
let mut l = vec![0.0f32; total];
let mut r = vec![0.0f32; total];
let mut te: Vec<TimedEvent> = Vec::new();
let mut pos = 0usize;
while pos < total {
let n = block.min(total - pos);
te.clear();
for e in script {
if e.at >= pos as u64 && e.at < (pos + n) as u64 {
te.push(TimedEvent { offset: (e.at - pos as u64) as u32, event: e.ev });
}
}
p.process_multicore(&te, &mut l[pos..pos + n], &mut r[pos..pos + n], workers);
pos += n;
}
(l, r)
}
/// A piano with the output niceties defeated, for physics measurements on
/// the raw instrument.
pub fn dry_piano() -> Piano {
let mut p = Piano::new(FS);
p.set_reverb_mix(0.0);
p.set_early_reflection_level(0.0);
p.set_soft_clip(false);
p
}
pub fn mono(l: &[f32], r: &[f32]) -> Vec<f32> {
l.iter().zip(r).map(|(a, b)| 0.5 * (a + b)).collect()
}
pub fn rms(x: &[f32]) -> f64 {
if x.is_empty() {
return 0.0;
}
(x.iter().map(|&v| (v as f64) * (v as f64)).sum::<f64>() / x.len() as f64).sqrt()
}
pub fn peak(x: &[f32]) -> f64 {
x.iter().fold(0.0f64, |m, &v| m.max((v as f64).abs()))
}
pub fn sec(x: &[f32], t0: f64, t1: f64) -> &[f32] {
let a = ((t0 * FS as f64) as usize).min(x.len());
let b = ((t1 * FS as f64) as usize).min(x.len());
&x[a..b]
}
/// Hann-windowed single-frequency DFT magnitude (normalized by window sum).
pub fn dft_mag(x: &[f32], f: f64) -> f64 {
let n = x.len();
let mut re = 0.0f64;
let mut im = 0.0f64;
let mut wsum = 0.0f64;
let w0 = std::f64::consts::TAU * f / FS as f64;
for (k, &v) in x.iter().enumerate() {
let w = 0.5 - 0.5 * (std::f64::consts::TAU * k as f64 / n as f64).cos();
wsum += w;
let ph = w0 * k as f64;
re += w * v as f64 * ph.cos();
im -= w * v as f64 * ph.sin();
}
2.0 * (re * re + im * im).sqrt() / wsum
}
/// Finds the strongest spectral peak within [guess-half, guess+half] by
/// coarse grid + parabolic refinement. Returns (freq, magnitude).
pub fn peak_near(x: &[f32], guess: f64, half: f64) -> (f64, f64) {
let step = (half / 30.0).max(0.05);
let mut best_f = guess;
let mut best_m = -1.0f64;
let mut f = guess - half;
while f <= guess + half {
if f > 5.0 {
let m = dft_mag(x, f);
if m > best_m {
best_m = m;
best_f = f;
}
}
f += step;
}
// parabolic refine on log-magnitude
let m0 = dft_mag(x, best_f - step).max(1e-30).ln();
let m1 = best_m.max(1e-30).ln();
let m2 = dft_mag(x, best_f + step).max(1e-30).ln();
let denom = m0 - 2.0 * m1 + m2;
let d = if denom.abs() > 1e-12 { 0.5 * (m0 - m2) / denom } else { 0.0 };
let f_ref = best_f + d.clamp(-1.0, 1.0) * step;
(f_ref, dft_mag(x, f_ref))
}
/// Exponential decay rate sigma (1/s) of the component at frequency `f`,
/// fitted by linear regression of log magnitude over sliding windows in
/// [t0, t1]. Window 80 ms, hop 40 ms.
pub fn decay_sigma(x: &[f32], f: f64, t0: f64, t1: f64) -> f64 {
let win = (0.080 * FS as f64) as usize;
let hop = win / 2;
let mut ts: Vec<f64> = Vec::new();
let mut ms: Vec<f64> = Vec::new();
let mut a = (t0 * FS as f64) as usize;
let end = ((t1 * FS as f64) as usize).min(x.len());
while a + win <= end {
let m = dft_mag(&x[a..a + win], f);
if m > 1e-12 {
ts.push((a + win / 2) as f64 / FS as f64);
ms.push(m.ln());
}
a += hop;
}
linreg_slope(&ts, &ms).map(|s| -s).unwrap_or(0.0)
}
/// Least-squares slope of y over x; None if degenerate.
pub fn linreg_slope(x: &[f64], y: &[f64]) -> Option<f64> {
if x.len() < 3 {
return None;
}
let n = x.len() as f64;
let sx: f64 = x.iter().sum();
let sy: f64 = y.iter().sum();
let sxx: f64 = x.iter().map(|v| v * v).sum();
let sxy: f64 = x.iter().zip(y).map(|(a, b)| a * b).sum();
let d = n * sxx - sx * sx;
if d.abs() < 1e-12 {
return None;
}
Some((n * sxy - sx * sy) / d)
}
/// Radix-2 FFT (in-place, f64). Length must be 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);
// bit reversal
let mut j = 0usize;
for i in 0..n {
if i < j {
re.swap(i, j);
im.swap(i, j);
}
let mut m = n >> 1;
while m >= 1 && j & m != 0 {
j ^= m;
m >>= 1;
}
j |= m;
}
let mut len = 2;
while len <= n {
let ang = -std::f64::consts::TAU / len as f64;
let (wr, wi) = (ang.cos(), ang.sin());
let mut i = 0;
while i < n {
let mut cr = 1.0f64;
let mut ci = 0.0f64;
for k in 0..len / 2 {
let (ar, ai) = (re[i + k], im[i + k]);
let (br, bi) = (re[i + k + len / 2], im[i + k + len / 2]);
let tr = br * cr - bi * ci;
let ti = br * ci + bi * cr;
re[i + k] = ar + tr;
im[i + k] = ai + ti;
re[i + k + len / 2] = ar - tr;
im[i + k + len / 2] = ai - ti;
let ncr = cr * wr - ci * wi;
ci = cr * wi + ci * wr;
cr = ncr;
}
i += len;
}
len <<= 1;
}
}
/// Hann-windowed power spectrum of x (zero-padded to the next power of two).
/// Returns (bin_hz, power-per-bin).
pub fn power_spectrum(x: &[f32]) -> (f64, Vec<f64>) {
let n = x.len().next_power_of_two();
let mut re = vec![0.0f64; n];
let mut im = vec![0.0f64; n];
for (k, &v) in x.iter().enumerate() {
let w = 0.5 - 0.5 * (std::f64::consts::TAU * k as f64 / x.len() as f64).cos();
re[k] = w * v as f64;
}
fft(&mut re, &mut im);
let bin = FS as f64 / n as f64;
let ps: Vec<f64> = (0..n / 2).map(|k| re[k] * re[k] + im[k] * im[k]).collect();
(bin, ps)
}
pub fn band_power(bin: f64, ps: &[f64], lo: f64, hi: f64) -> f64 {
let a = (lo / bin).ceil() as usize;
let b = ((hi / bin).floor() as usize).min(ps.len().saturating_sub(1));
if a >= b {
return 0.0;
}
ps[a..=b].iter().sum()
}
pub fn spectral_centroid(bin: f64, ps: &[f64], lo: f64, hi: f64) -> f64 {
let a = (lo / bin).ceil() as usize;
let b = ((hi / bin).floor() as usize).min(ps.len().saturating_sub(1));
let mut num = 0.0;
let mut den = 0.0;
for k in a..=b {
num += k as f64 * bin * ps[k];
den += ps[k];
}
if den > 0.0 {
num / den
} else {
0.0
}
}
pub fn assert_all_finite(x: &[f32]) {
for (i, &v) in x.iter().enumerate() {
assert!(v.is_finite(), "non-finite sample {v} at index {i}");
}
}