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. |
||
|---|---|---|
| .. | ||
| .gitignore | ||
| acoustic.py | ||
| fit_voicing.py | ||
| README.md | ||
| test_acoustic.py | ||
| test_fit_voicing.py | ||
| VOICING.md | ||
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, ¬es) 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 (27–34), 68 → layer 9 (65–72), and
112 → layer 14 (105–112). 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 | 500–2000 and 2000–8000 Hz power / 20–20000 Hz power; 50–100 ms and 1–2 s; 10 log10, dB | 6 dB |
| Fundamental / cluster | P1 / max(P2…P6), 10 log10, dB; 50–300 ms and 1–2 s; broad (n ± 0.4) × equal-tempered f0 bands |
6 dB |
| Register RMS | Stereo RMS 0–2 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.1–0.9 s at 50 ms hops; bands 20–500, 500–2000, 2000–8000 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 0–2 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 20–500 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 50–300 ms | 20 | 72 / 112 | −23.330955 dB |
| P1/cluster 1–2 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.