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
311 lines
14 KiB
Rust
311 lines
14 KiB
Rust
// Verification of the learned (PianoForte-derived) engine: network parse
|
|
// sanity, the real-time contract (allocation-free, block-size determinism,
|
|
// scalar/SIMD agreement, bounded output), level calibration against the
|
|
// physical engine, and the perf measurement. See src/learned.rs for the
|
|
// provenance of the algorithm and the network.
|
|
|
|
use makepad_piano_model::learned::{EngineKind, LearnedPiano, PianoEngine};
|
|
use makepad_piano_model::{Instrument, Piano, PianoEvent, TimedEvent, PIANO_PRESETS};
|
|
|
|
const FS: f32 = 48000.0;
|
|
|
|
/// An absolute-time event script rendered through any Instrument in blocks.
|
|
fn render<I: Instrument>(p: &mut I, script: &[(u64, PianoEvent)], 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 &(at, ev) in script {
|
|
if at >= pos as u64 && at < (pos + n) as u64 {
|
|
te.push(TimedEvent { offset: (at - pos as u64) as u32, event: ev });
|
|
}
|
|
}
|
|
p.process(&te, &mut l[pos..pos + n], &mut r[pos..pos + n]);
|
|
pos += n;
|
|
}
|
|
(l, r)
|
|
}
|
|
|
|
fn rms(x: &[f32]) -> f64 {
|
|
(x.iter().map(|&v| (v as f64) * (v as f64)).sum::<f64>() / x.len().max(1) as f64).sqrt()
|
|
}
|
|
|
|
fn db(x: f64) -> f64 {
|
|
20.0 * x.max(1e-30).log10()
|
|
}
|
|
|
|
fn sec(at: f64) -> u64 {
|
|
(at * FS as f64) as u64
|
|
}
|
|
|
|
#[test]
|
|
fn net_parses_and_outputs_are_sane() {
|
|
let p = LearnedPiano::new(FS);
|
|
let mut out = [0.0f32; 30];
|
|
// Amplitudes must be finite and inside the tanh-mapped output range for
|
|
// the whole input cube, and must actually vary with every input.
|
|
let mut lo = f32::MAX;
|
|
let mut hi = f32::MIN;
|
|
for key in [21u8, 40, 60, 80, 108] {
|
|
for vel in [1u8, 64, 127] {
|
|
for t in [0.0, 0.1, 1.0, 10.0] {
|
|
p.learned_partial_amps(key, vel, t, &mut out);
|
|
for &a in &out {
|
|
assert!(a.is_finite() && (0.0..=1.0).contains(&a), "amp {a} out of range");
|
|
lo = lo.min(a);
|
|
hi = hi.max(a);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
assert!(hi - lo > 0.2, "network output barely varies ({lo}..{hi}) — parse suspect");
|
|
// The network output is a NORMALISED spectral shape (it sums to ~1 at
|
|
// every time; the absolute decay is the analytic envelope). Assert
|
|
// both facts: near-unit sum, and a shape that moves with time.
|
|
let mut early = [0.0f32; 30];
|
|
let mut late = [0.0f32; 30];
|
|
p.learned_partial_amps(60, 100, 0.05, &mut early);
|
|
p.learned_partial_amps(60, 100, 3.0, &mut late);
|
|
let se: f32 = early.iter().sum();
|
|
let sl: f32 = late.iter().sum();
|
|
assert!((0.5..=1.6).contains(&se) && (0.5..=1.6).contains(&sl), "ladder sums stray from ~1: {se}, {sl}");
|
|
let shape_d: f32 = early.iter().zip(&late).map(|(a, b)| (a - b).abs()).sum();
|
|
assert!(shape_d > 0.1, "learned ladder ignores the time coordinate");
|
|
let env_early = p.learned_envelope(60, 0.05);
|
|
let env_late = p.learned_envelope(60, 3.0);
|
|
assert!(env_late < 0.5 * env_early, "analytic envelope does not decay: {env_early} -> {env_late}");
|
|
// Velocity must matter.
|
|
let mut soft = [0.0f32; 30];
|
|
let mut loud = [0.0f32; 30];
|
|
p.learned_partial_amps(60, 30, 0.05, &mut soft);
|
|
p.learned_partial_amps(60, 120, 0.05, &mut loud);
|
|
let ds: f32 = soft.iter().zip(&loud).map(|(a, b)| (a - b).abs()).sum();
|
|
assert!(ds > 0.05, "learned ladder ignores velocity");
|
|
}
|
|
|
|
#[test]
|
|
fn deterministic_across_block_sizes() {
|
|
let script = vec![
|
|
(0, PianoEvent::NoteOn { key: 36, velocity: 100 }),
|
|
(sec(0.2), PianoEvent::NoteOn { key: 60, velocity: 80 }),
|
|
(sec(0.5), PianoEvent::Sustain { value: 1.0 }),
|
|
(sec(0.7), PianoEvent::NoteOn { key: 84, velocity: 120 }),
|
|
(sec(0.9), PianoEvent::NoteOff { key: 60 }),
|
|
(sec(1.1), PianoEvent::NoteOn { key: 60, velocity: 90 }),
|
|
(sec(1.3), PianoEvent::Sustain { value: 0.0 }),
|
|
];
|
|
let total = (2.0 * FS) as usize;
|
|
let mut a = LearnedPiano::new(FS);
|
|
let (l1, r1) = render(&mut a, &script, total, 512);
|
|
let mut b = LearnedPiano::new(FS);
|
|
let (l2, r2) = render(&mut b, &script, total, 61);
|
|
let mut c = LearnedPiano::new(FS);
|
|
let (l3, _) = render(&mut c, &script, total, 4096);
|
|
assert_eq!(l1, l2, "block 512 vs 61 differ");
|
|
assert_eq!(r1, r2, "block 512 vs 61 differ (right)");
|
|
assert_eq!(l1, l3, "block 512 vs 4096 differ");
|
|
}
|
|
|
|
#[test]
|
|
fn scalar_and_simd_agree() {
|
|
let script = vec![
|
|
(0, PianoEvent::NoteOn { key: 24, velocity: 110 }),
|
|
(sec(0.1), PianoEvent::NoteOn { key: 60, velocity: 90 }),
|
|
(sec(0.2), PianoEvent::NoteOn { key: 96, velocity: 70 }),
|
|
];
|
|
let total = (1.0 * FS) as usize;
|
|
let mut a = LearnedPiano::new(FS);
|
|
a.set_force_scalar(true);
|
|
let (ls, _) = render(&mut a, &script, total, 512);
|
|
let mut b = LearnedPiano::new(FS);
|
|
let (lv, _) = render(&mut b, &script, total, 512);
|
|
let mut max_d = 0.0f64;
|
|
let mut ref_pk = 0.0f64;
|
|
for k in 0..total {
|
|
max_d = max_d.max((ls[k] as f64 - lv[k] as f64).abs());
|
|
ref_pk = ref_pk.max((ls[k] as f64).abs());
|
|
}
|
|
assert!(max_d < 1e-3 * ref_pk.max(1e-6), "scalar vs simd diverge: {max_d} (peak {ref_pk})");
|
|
}
|
|
|
|
#[test]
|
|
fn output_is_finite_and_decays_to_silence() {
|
|
let mut p = LearnedPiano::new(FS);
|
|
let script = vec![
|
|
(0, PianoEvent::NoteOn { key: 21, velocity: 127 }),
|
|
(0, PianoEvent::NoteOn { key: 108, velocity: 127 }),
|
|
(sec(0.5), PianoEvent::NoteOff { key: 21 }),
|
|
(sec(0.5), PianoEvent::NoteOff { key: 108 }),
|
|
];
|
|
let total = (4.0 * FS) as usize;
|
|
let (l, r) = render(&mut p, &script, total, 512);
|
|
for (i, &v) in l.iter().chain(r.iter()).enumerate() {
|
|
assert!(v.is_finite(), "non-finite sample at {i}");
|
|
assert!(v.abs() <= 1.5, "runaway sample {v} at {i}");
|
|
}
|
|
let early = rms(&l[(0.1 * FS) as usize..(0.4 * FS) as usize]);
|
|
let late = rms(&l[(3.5 * FS) as usize..]);
|
|
assert!(early > 1e-4, "engine is silent when struck (rms {early:.6})");
|
|
assert!(late < early * 0.02, "release does not decay: early {early:.5}, late {late:.5}");
|
|
}
|
|
|
|
/// Same material, both engines: the learned engine must land within a few
|
|
/// dB of the physical engine's calibrated loudness so an engine swap is not
|
|
/// a level jump. (LEARNED_MASTER in learned.rs is tuned to hold this.)
|
|
#[test]
|
|
fn level_matches_physical_engine() {
|
|
let mut script = Vec::new();
|
|
// A moderate two-hand texture across the compass at mezzo velocities.
|
|
let keys = [36u8, 48, 55, 60, 64, 67, 72, 76];
|
|
for (n, &k) in keys.iter().enumerate() {
|
|
let at = sec(0.25 * n as f64);
|
|
script.push((at, PianoEvent::NoteOn { key: k, velocity: 72 }));
|
|
script.push((at + sec(1.2), PianoEvent::NoteOff { key: k }));
|
|
}
|
|
let total = (3.5 * FS) as usize;
|
|
let mut phys = Piano::new(FS);
|
|
phys.set_reverb_mix(0.0);
|
|
phys.set_early_reflection_level(0.0);
|
|
let (pl, pr) = render(&mut phys, &script, total, 512);
|
|
let mut learned = LearnedPiano::new(FS);
|
|
learned.set_reverb_mix(0.0);
|
|
learned.set_early_reflection_level(0.0);
|
|
let (ll, lr) = render(&mut learned, &script, total, 512);
|
|
let p_rms = db(0.5 * (rms(&pl) + rms(&pr)));
|
|
let l_rms = db(0.5 * (rms(&ll) + rms(&lr)));
|
|
println!("physical {p_rms:.1} dBFS rms, learned {l_rms:.1} dBFS rms");
|
|
assert!((p_rms - l_rms).abs() < 3.5, "engine swap is a level jump: physical {p_rms:.1} dB, learned {l_rms:.1} dB");
|
|
}
|
|
|
|
#[test]
|
|
fn engine_wrapper_forwards_and_swaps() {
|
|
let preset = &PIANO_PRESETS[0];
|
|
for kind in EngineKind::ALL {
|
|
let mut e = PianoEngine::new(kind, FS, preset);
|
|
assert_eq!(e.kind(), kind);
|
|
assert_eq!(e.sample_rate(), FS);
|
|
e.set_master_gain(0.8);
|
|
assert!((e.master_gain() - 0.8).abs() < 1e-6);
|
|
e.set_tone(2.0, -1.0);
|
|
assert_eq!(e.tone(), (2.0, -1.0));
|
|
let script = vec![(0, PianoEvent::NoteOn { key: 60, velocity: 90 })];
|
|
let (l, _) = render(&mut e, &script, (0.5 * FS) as usize, 256);
|
|
assert!(rms(&l) > 1e-5, "{kind:?} engine silent through the wrapper");
|
|
e.reset();
|
|
}
|
|
}
|
|
|
|
/// Cost measurement, reported beside the physical engine's numbers:
|
|
/// cargo test -p makepad-piano-model --release --test learned -- --ignored perf_ --nocapture
|
|
#[test]
|
|
#[ignore]
|
|
fn perf_learned_polyphony() {
|
|
use std::time::Instant;
|
|
let seconds = 10.0;
|
|
for (name, scalar) in [("simd", false), ("scalar", true)] {
|
|
let mut p = LearnedPiano::new(FS);
|
|
p.set_force_scalar(scalar);
|
|
let mut script = vec![(0u64, PianoEvent::Sustain { value: 1.0 })];
|
|
let mut t = 0.0;
|
|
while t < seconds - 0.1 {
|
|
for key in 21..=108u8 {
|
|
script.push((sec(t + (key as f64 - 21.0) * 0.0001), PianoEvent::NoteOn { key, velocity: 110 }));
|
|
}
|
|
t += 1.5;
|
|
}
|
|
let total = (seconds * FS as f64) as usize;
|
|
let start = Instant::now();
|
|
let (l, _) = render(&mut p, &script, total, 512);
|
|
let wall = start.elapsed().as_secs_f64();
|
|
assert!(l[total - 1].is_finite());
|
|
println!(
|
|
"learned {name}: 88 keys re-struck under pedal (176 slots) + full fx: {wall:.3} s wall for {seconds} s = {:.1}x realtime ({:.1}% of one core)",
|
|
seconds / wall,
|
|
100.0 * wall / seconds
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The experimental hybrid hook (Piano::debug_shape_partials) must actually
|
|
/// shape what it claims: a partial's output gain scales its measured level,
|
|
/// and a sigma_scale above 1 shortens its ring. (The learned-hybrid
|
|
/// experiments in tests/learned_targets.rs and the offline listening pack
|
|
/// build on this hook; this pins its semantics.)
|
|
#[test]
|
|
fn shape_partials_hook_scales_gain_and_decay() {
|
|
let key = 60u8;
|
|
let render_mono = |p: &mut Piano| -> Vec<f32> {
|
|
p.set_reverb_mix(0.0);
|
|
p.set_early_reflection_level(0.0);
|
|
p.set_soft_clip(false);
|
|
let script = vec![(0u64, PianoEvent::NoteOn { key, velocity: 100 })];
|
|
let (l, r) = render(p, &script, (1.2 * FS) as usize, 512);
|
|
l.iter().zip(&r).map(|(a, b)| 0.5 * (a + b)).collect()
|
|
};
|
|
let dft = |x: &[f32], f: f64, t0: f64| -> f64 {
|
|
let win = (0.046 * FS as f64) as usize;
|
|
let a = (t0 * FS as f64) as usize;
|
|
let seg = &x[a..a + win];
|
|
let (mut re, mut im) = (0.0f64, 0.0f64);
|
|
let w0 = std::f64::consts::TAU * f / FS as f64;
|
|
for (k, &v) in seg.iter().enumerate() {
|
|
let w = 0.5 - 0.5 * (std::f64::consts::TAU * k as f64 / seg.len() as f64).cos();
|
|
re += w * v as f64 * (w0 * k as f64).cos();
|
|
im -= w * v as f64 * (w0 * k as f64).sin();
|
|
}
|
|
(re * re + im * im).sqrt()
|
|
};
|
|
// pol_det = 0: with the polarisation false-beat on, a single
|
|
// fixed-instant DFT window lands on different phases of the beat in
|
|
// the two renders and the sigma probe stops being monotone (a
|
|
// doubled-sigma render once measured +3 dB at 0.8 s purely from
|
|
// beat phase). The hook under test is orthogonal to the beat.
|
|
let no_beat = {
|
|
let mut dp = makepad_piano_model::DesignParams::default();
|
|
dp.pol_det = 0.0;
|
|
dp.scatter = 0.0;
|
|
// the held key's own sympathetic bank shadows its partials with
|
|
// UNSCALED decays and holds the 0.8 s level after the scaled
|
|
// string has died — silence the resonance beds for the probe
|
|
dp.sym_out = 0.0;
|
|
dp.sym_damped = 0.0;
|
|
dp.duplex_gain = 0.0;
|
|
dp
|
|
};
|
|
let mut base = Piano::new_with_params(FS, &no_beat);
|
|
let f0 = base.key_info(key).unwrap().f0 as f64;
|
|
let b = base.key_info(key).unwrap().b_coeff as f64;
|
|
let f2 = 2.0 * f0 * (1.0 + b * 4.0).sqrt();
|
|
let xb = render_mono(&mut base);
|
|
// Gain: partial 2 cut 12 dB, others untouched.
|
|
let mut cut = Piano::new_with_params(FS, &no_beat);
|
|
cut.debug_shape_partials(key, &[1.0, 0.25, 1.0, 1.0], &[]);
|
|
let xc = render_mono(&mut cut);
|
|
let drop = 20.0 * (dft(&xc, f2, 0.1) / dft(&xb, f2, 0.1)).log10();
|
|
let keep = 20.0 * (dft(&xc, f0, 0.1) / dft(&xb, f0, 0.1)).log10();
|
|
assert!((drop + 12.0).abs() < 2.0, "partial 2 moved {drop:.1} dB, wanted -12");
|
|
assert!(keep.abs() < 1.0, "partial 1 moved {keep:.1} dB, wanted 0");
|
|
// Decay: sigma doubled on every partial -> the 0.8 s level falls well
|
|
// below the untouched instrument's while the onset stays put.
|
|
let mut fast = Piano::new_with_params(FS, &no_beat);
|
|
fast.debug_shape_partials(key, &[], &[2.0; 24]);
|
|
let xf = render_mono(&mut fast);
|
|
// Broadband RMS, not a single-frequency DFT: a partial is now a set
|
|
// of coupled modes at (nearly) one frequency, and their coherent sum
|
|
// sweeps through interference nulls as the fast member dies — a
|
|
// fixed-instant single-line probe measured +0.2 dB for doubled sigma
|
|
// purely because base and scaled renders sat on opposite sides of a
|
|
// null. Energy across the band is monotone in sigma.
|
|
let band_rms = |x: &[f32], t0: f64, t1: f64| -> f64 {
|
|
let a = (t0 * FS as f64) as usize;
|
|
let b = ((t1 * FS as f64) as usize).min(x.len());
|
|
(x[a..b].iter().map(|v| (*v as f64) * (*v as f64)).sum::<f64>() / (b - a) as f64).sqrt()
|
|
};
|
|
let late = 20.0 * (band_rms(&xf, 0.6, 1.1) / band_rms(&xb, 0.6, 1.1)).log10();
|
|
let onset = 20.0 * (band_rms(&xf, 0.03, 0.08) / band_rms(&xb, 0.03, 0.08)).log10();
|
|
assert!(late < -2.5, "doubled sigma only moved the 0.6-1.1 s energy {late:.1} dB");
|
|
assert!(onset > -4.5, "doubled sigma should barely touch the onset, moved {onset:.1} dB");
|
|
}
|