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.
234 lines
14 KiB
Markdown
234 lines
14 KiB
Markdown
# 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:
|
||
|
||
```sh
|
||
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:
|
||
|
||
```sh
|
||
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.
|
||
|
||
```sh
|
||
./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:
|
||
|
||
```csv
|
||
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.
|
||
|
||
```sh
|
||
./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:
|
||
|
||
```sh
|
||
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:
|
||
|
||
```sh
|
||
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
|
||
|
||
```sh
|
||
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.
|