makepad/libs/piano_model/tools/README.md
Admin b49e2e5060 piano-model: calibrate partial gains and decay against recordings
Apply immutable pitch/velocity-interpolated modal calibration while preserving an explicit uncalibrated constructor. Add offline acoustic tooling, reference measurements and regression coverage.

Validation: 16 calibration/acoustic release tests passed, one ignored.
2026-09-05 01:42:39 +02:00

14 KiB
Raw Permalink Blame History

Offline acoustic benchmark

This measures the current library against native Salamander recordings without an audio device, GUI, worker threads, resampling, or a runtime dependency. Python 3.11+ and NumPy are required for analysis; RIFF decoding uses stdlib parsing and NumPy integer conversion (PCM16, PCM24, float32, including matching extensible formats). Rust tests reuse only the existing dependency-free FFT and regression.

Calibrated physical piano

Piano::new and the physical presets now use the measured modal calibration: 30 native pitch anchors, three velocity knots (28, 68, 112), and corrections for all 240 possible string partials. Pitch and velocity interpolate gain in dB; decay corrections interpolate in log space. The table changes string excitation and decay while retaining the hammer, soundboard, pedals and room. Piano::new_uncalibrated preserves the raw instrument for comparisons.

The fit improves bass sustained body, early partial balance, register loudness and selected treble brightness measurements against Salamander Grand Piano V3 (Alexander Holm, CC BY 3.0). Six active tests in acoustic_reference.rs protect those improvements, C3 attack and C4 velocity dynamics. sound.rs additionally checks C7's early second-partial balance against the native recording while keeping the old FluidR3 bounds as an explicit raw-model regression. These targeted checks do not imply that every note matches the recorded piano.

Run the complete offline verification with:

cargo test --offline --release -p makepad-piano-model --all-targets
python3 -m unittest discover -s libs/piano_model/tools -p 'test_*.py'

From the repository root:

cargo build --offline --release --example render_acoustic -p makepad-piano-model
mkdir -p libs/piano_model/tools/runs
./target/release/examples/render_acoustic --out libs/piano_model/tools/runs/baseline --rate 48000 --notes 21,24,30,33,36,45,48,60,69,72,84,96 --velocities 28,68,112 --seconds 4
python3 libs/piano_model/tools/acoustic.py --baseline libs/piano_model/tools/runs/baseline --out libs/piano_model/tools/runs/baseline-report.json

The renderer defaults to --stock (Piano::new, including stock calibration). --raw uses Piano::new_with_params(rate, &DesignParams::default()), equivalent to Piano::new_uncalibrated. Custom raw designs use, for example, --raw --design rad_hp1=90,rad_hp2=40. A design override requires raw mode. All modes retain constructor output effects unless --dry is given, with no preset, pedal, or note-off. Each pair starts a fresh instrument, receives NoteOn at sample zero, and writes stereo float32 note_021_vel_028.wav-style files without additional clipping by the WAV writer. render.json records the mode, parameters, timing, block size, dry flag and actual effects selection after successful completion. Raw does not mean effects bypassed.

--voicing name=value,... starts from Voicing::default() and applies the overrides with set_voicing on every fresh instrument before NoteOn. It works with stock, raw (including design overrides), custom calibration, and --dry. The fields are body_tap, knock, roughness, phantoms, attack_noise, attack_body, and sympathetic. Values must be finite within 0..2.5 inclusive, except attack_body, which is limited to 0..1. Unknown fields, malformed values, and values outside these bounds fail before creating the output directory. Supply --voicing once; repeated fields within its list use the last value. Omitted fields retain their defaults (1 for every field except attack_body, which is 0); omitting the option preserves the default sound. Every render.json includes all seven effective values in voicing, including defaults, with the actual f32 values widened to JSON numbers so their precision is retained.

./target/release/examples/render_acoustic --out libs/piano_model/tools/runs/fit-no-knock --calibration libs/piano_model/tools/runs/fit.csv --dry --voicing knock=0

Use --dry for fitting: it independently sets reverb mix and early reflection level to zero and disables soft clipping (which also bypasses the limiter), before processing any samples. The full modelled soundboard radiation remains. Without --dry, constructor effects remain unchanged (reverb mix 0.3, early reflection level 0.7, limiter/soft clipping on). Preserve existing default-effect baseline WAVs for stock A/B; render fitting baselines and candidates into new directories with --dry.

--calibration FILE.csv builds every fresh instrument with Piano::new_with_calibration(rate, &notes) using an explicit construction-time table, such as local fitter output. It replaces the stock table; default stock mode or explicit --stock is allowed, but --raw and --design conflict. The CSV must start with exactly:

key,partial,pp_db,mf_db,ff_db,decay_scale

Supply at least one MIDI key (21..108), with keys in strictly increasing groups of exactly 240 rows (CALIBRATION_PARTIALS). Each group must contain partials 1..240 exactly once (in any order), covering every mode of the lowest register. Gain arrays at each velocity and the decay array each have 240 entries; no current mode uses a repeated last entry or a taper. Legacy 64-row groups are rejected before output creation and must be regenerated from the full raw baseline. The pp/mf/ff gain columns correspond to velocities 28/68/112 and must be finite within -36..24 dB; decay scales must be finite within 0.1..4. Missing, empty, malformed, duplicate or out-of-range tables fail before creating the output directory. Parsing and instrument construction happen outside audio processing. render.json uses mode calibration and embeds both the supplied path and the exact CSV text in calibration.path / calibration.csv with JSON escaping; provenance survives changes to the source file. Other modes record calibration: null. No production calibration or voicing is changed.

./target/release/examples/render_acoustic --out libs/piano_model/tools/runs/raw-dry --raw --dry
./target/release/examples/render_acoustic --out libs/piano_model/tools/runs/fit-dry --calibration libs/piano_model/tools/runs/fit.csv --dry

Output directories must not exist; JSON/TSV output paths also refuse overwrite. Keep a baseline directory, rebuild the example after runtime changes, and render to a new candidate directory with the same CLI selection. Compare with:

python3 libs/piano_model/tools/acoustic.py --baseline libs/piano_model/tools/runs/baseline --candidate libs/piano_model/tools/runs/candidate --out libs/piano_model/tools/runs/comparison.json

The JSON contains per-pair measurements, SHA256s, signed model-reference deltas, provisional deviations, candidate-baseline deltas, and changes in absolute reference error (negative means closer). It never writes to model directories. Include MIDI 60 at every velocity in model renders for C4 normalization, even when comparing a subset. Its source hashes remain in subset reports. Successful analysis exits zero even when a model differs: this is a diagnostic, not an acceptance command. Missing corpus, model WAVs, completed manifests, unsupported encodings, silence, or insufficient post-onset audio fail explicitly.

Reference and fixture

The default corpus root is local/score-corpus/salamander/SalamanderGrandPianoV3_48khz24bit; override with --reference-root. Alexander Holm, Yamaha C5, CC BY 3.0, as specified in its README: http://creativecommons.org/licenses/by/3.0/. The archive is V3 although its README heading and SFZ comment still say V2.

Only native attack regions whose pitch center matches the MIDI note are used. The actual SFZ selects 28 → layer 2 (2734), 68 → layer 9 (6572), and 112 → layer 14 (105112). C4 omits pitch_keycenter, using SFZ's default 60; the filename pitch is checked too. No transposed keys or guessed layer numbers. The parser deliberately supports this corpus's simple numeric-key SFZ layout, not the entire SFZ language. It does not apply SFZ gain/velocity tracking, envelopes, loops, or release/noise regions to the source PCM.

tests/data/salamander_v3.tsv contains 36 reference-only rows. It records native filenames, full WAV SHA256s, velocity bounds/layers, onset sample indices, Hz, unwindowed RMS, and metrics. Metadata includes author/license, SFZ/README hashes, and the analysis specification. No reference PCM is checked in. Reproduce into a fresh file and compare byte-for-byte:

python3 libs/piano_model/tools/acoustic.py --fixture-out libs/piano_model/tools/runs/reference-regenerated.tsv
cmp libs/piano_model/tests/data/salamander_v3.tsv libs/piano_model/tools/runs/reference-regenerated.tsv

Fixture generation rejects model arguments, and automatically includes C4 anchors for subset selections. The checked-in fixture uses the full default set.

Units and provisional tolerances

L/R powers are computed independently, then averaged; an anti-phase signal does not cancel. Every signal aligns to the first nonoverlapping 1 ms RMS frame above 40 dB relative to its largest such frame in the first 0.5 s. Windows below are relative to that frame's start. Spectra use periodic Hann windows, padding to the next power of two, and one-sided window-energy normalization. Frequency bands include their lower edge and exclude their upper edge.

Metric Definition Provisional absolute difference
Early / late mid, high shares 5002000 and 20008000 Hz power / 2020000 Hz power; 50100 ms and 12 s; 10 log10, dB 6 dB
Fundamental / cluster P1 / max(P2…P6), 10 log10, dB; 50300 ms and 12 s; broad (n ± 0.4) × equal-tempered f0 bands 6 dB
Register RMS Stereo RMS 02 s / same-set C4 RMS at the same velocity; 20 log10, dB 6 dB
Onset energy Unwindowed sum of squares in first 5 ms / first 50 ms, fraction 0.15
Low, mid, high decay Negative least-squares slope of 10 log10 band power, dB/s; 100 ms windows starting 0.10.9 s at 50 ms hops; bands 20500, 5002000, 20008000 Hz 8 dB/s

The JSON additionally reports raw RMS and RMS dB relative to reference C4 at the same velocity. That absolute capture-gain-dependent comparison is not gated; register RMS removes the model/reference C4 gain difference. Positive decay means falling energy, negative means growth. Ratio floors are 150 dB; decay power floors are relative to each signal's 02 s mean square.

These tolerances were selected before measuring the current model: 6 dB allows a factor-four power-ratio error, 0.15 allows a 15-percentage-point onset error, and 8 dB/s allows 7.2 dB divergence across 0.9 s. They are explicit diagnostic budgets, not psychophysical acceptance limits or a fit to current errors.

The measurements characterize one piano/microphone setup. Weak bands can be dominated by room noise (especially 20500 Hz on high notes); no noise subtraction or confidence filter is applied. Broad partial bands are not a fitted inharmonicity estimate, and can include leakage/noise. Late treble can approach the recording noise floor. Unless --dry is given, the renderer keeps its artificial room and output processing. Native source recording gain and SFZ layer velocity tracking are not a common loudness calibration. Do not interpret every deviation as a voicing instruction. No final acceptance is claimed.

Validation and baseline errors

python3 -m unittest discover -s libs/piano_model/tools -p test_acoustic.py -v
cargo test --offline --release -p makepad-piano-model --test acoustic_reference --test reference
cargo test --offline --release -p makepad-piano-model --test acoustic_reference stock_matches_native_acoustic_reference -- --ignored --nocapture
# Historical FluidR3 comparisons remain available, without any threshold edits:
cargo test --offline --release -p makepad-piano-model --test reference -- --ignored

At runtime source revision 4546edb0c734d15195dfb34c858461397e67ba38, the release build, six Python tests and three normal Rust acoustic checks passed. The explicit acoustic comparison failed 182 of 396 metric checks. Rust and Python identified the identical 182 deviations; printed values agreed within 5e-7. Six fresh raw renders with reversed note/velocity ordering were byte-identical to stock. A raw design-override render also completed. These checks verify the benchmark, not the instrument's acoustic acceptance.

Metric Outside budget / 36 Worst pair (MIDI / velocity) Model reference
Early mid share 9 96 / 68 16.713428 dB
Early high share 17 30 / 28 +24.956064 dB
Late mid share 15 21 / 68 19.051525 dB
Late high share 24 36 / 112 27.952017 dB
P1/cluster 50300 ms 20 72 / 112 23.330955 dB
P1/cluster 12 s 28 48 / 28 +36.253043 dB
Register RMS 14 21 / 28 +12.687532 dB
Onset 5/50 energy 1 96 / 28 +0.156294
Low-band decay 15 96 / 28 +38.531435 dB/s
Mid-band decay 15 96 / 68 +17.456822 dB/s
High-band decay 24 30 / 28 +20.032005 dB/s

For the diagnosed bass, A0 at velocity 112 has late mid-band share 16.849 dB relative to reference and register RMS +8.155 dB; C1 has late P1/cluster +18.164 dB. At C5/112 the early P1/cluster is 7.512524 dB versus reference +15.818431 dB, and high-band decay is 12.978 dB/s slower. Brightness errors vary by note/window/velocity: C6/112 early high-band share is actually 7.376 dB low. The complete baseline error list is in local runs/baseline-final-report.json and runs/acoustic-rust.log (ignored outputs, reproducible with the commands above). Those results describe the raw baseline. The broad all-metrics diagnostic remains ignored; the six targeted calibrated gates and native C7 check above are active in the normal release suite.