makepad/libs/piano_model/tools/VOICING.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

418 lines
26 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Offline measured modal voicing
`fit_voicing.py` proposes numeric strike gains and empirical decay corrections
from **complete radiated stereo model renders** and native Salamander recordings.
It does not use nominal `radiativity()`, fit waveform phase, tune frequencies,
optimize unrelated model parameters, or establish perceptual acceptance. A fit is
an input to a subsequent rerender and independent benchmark/listening review.
Only Python 3.10+ and NumPy are required; no audio device, GPU, network, or package
installation is used.
## Inputs and invocation
Run from the checkout, choosing a new output directory for each iteration:
```sh
python3 libs/piano_model/tools/fit_voicing.py \
--renders local/piano-renders/raw \
--corpus local/score-corpus/salamander/SalamanderGrandPianoV3_48khz24bit \
--out local/piano-voicing/pass1
```
Required render files are `note_{key:03}_vel_{velocity:03}.wav`, stereo IEEE
float32 at 48 kHz, held for at least four seconds, and a `render.json` JSON object.
The fitter reads the final renderer output, including its radiation, unisons,
soundboard and any effects actually present. Keep renderer settings fixed between
iterations. The manifest hashes `render.json`; the default analytic fitter preserves
its contents without assuming a renderer-specific schema. The optional decay probe
below requires and validates the renderer's calibration schema. WAV headers and
duration are checked, but the fitter cannot prove the renderer held the key or
used a claimed calibration.
The default discovers all 30 native keycenters from
`SalamanderGrandPianoV3.sfz` (the original, non-retuned SFZ). `--notes 48,60,72`
selects a pilot. Every selected key requires velocities **28, 68, 112**. Additional
velocities listed by `--velocities` are also required at every selected key.
Every additional render with a valid filename at a selected key is included,
even when not explicitly requested; extras need not exist at every other key.
Unselected/non-native keys are outside the fit. Missing required renders,
native regions, or selected reference WAVs fail before writing outputs. There is
no neighboring-pitch or nearest-available-layer substitution.
To require all 16 representative layers:
```sh
python3 libs/piano_model/tools/fit_voicing.py \
--renders local/piano-renders/all-layers \
--corpus local/score-corpus/salamander/SalamanderGrandPianoV3_48khz24bit \
--out local/piano-voicing/all-layers-pass1 \
--velocities 13,28,35,40,45,48,53,60,68,76,84,92,100,112,116,124
```
SFZ velocity boundaries select the native attack recording: **28 → layer 2,
68 → layer 9, 112 → layer 14**. The reader respects global/group region
inheritance, recognizes native note filenames, verifies their pitch keycenters,
and excludes release, pedal and resonance samples. It rejects tuning offsets,
ambiguous regions, and unsupported preprocessor directives. This is deliberately
a parser for this corpus, not a general SFZ synthesizer. Targets are the recorded
PCM24 layer amplitudes: SFZ `amp_veltrack`, envelopes and playback gain are not
applied. Two velocities selecting the same layer still contribute two separate
model observations against that native recording.
`--out` is mandatory. The four named output files may be replaced only in that
explicit directory, after all input analysis and serialization succeed. Files
are staged then individually atomically replaced; the set of four is not a
filesystem transaction. Unrelated output files are untouched. Output must be
outside the corpus and render directories and must not overwrite `--previous`.
Source WAVs are never modified. Use a new output directory to retain each pass.
## Measurement and confidence
1. Decode little-endian RIFF PCM24 references and float32 renders without folding
channels. The core WAV reader also accepts PCM16/32 and PCM/float extensible
headers, and rejects damaged or non-finite audio. Onset is the first 1 ms
block whose stereo RMS exceeds 40 dB relative to the largest block RMS in
the first 0.5 seconds. The block start defines time zero reproducibly.
2. Independently identify each recording's lines in an onset-relative
0.11.2 second Hann FFT. Fit `f_n = n f0 sqrt(1 + B n²)` using a deterministic
coarse search and iterative robust weighted regression of `(f_n/n)²` against
`n²`. Search `f0` within ±45 cents of MIDI pitch and `B` in `[0, 0.01]`.
A zero-B boundary represents unresolved stiffness; it is not negative B.
Local peak prominence must exceed 15 dB and line power must exceed 60 dB
relative to the strongest line. Noise estimation excludes the close-unison
cluster. The model and reference never share fitted `f0` or `B`.
3. Retain measured centers, including the independently observed first-partial
cluster. The fitted curve assigns partial numbers; it does not force observed
peaks onto its frequencies. Report curve confidence, supported-line count,
residual cents, and boundary hits. With fewer than three useful lines, `B`
is unidentified and upper-mode confidence is reduced. A boundary hit or poor
fit needs review; it is not evidence of precise physical parameters.
Supplement weak/absent long-window lines (confidence at most 0.25) with an
**onset-relative 00.15 second Hann FFT**, without changing `f0`, `B`, or their
reported fit confidence/support. Match early local maxima to this recording's
own predicted stiff-string frequencies. Search radius is
`min(0.23 * nearest harmonic gap, 2.5 * max(2 / 0.15, 0.003 * predicted Hz))`.
The local floor is the median between 1.15 search radii and 0.45 harmonic gaps
from the predicted center; require at least four flank bins. Require peak
prominence above 15 dB and peak power above 60 dB relative to the strongest
early peak. Confidence fades over 1530 dB prominence and 60 to 40 dB
relative power, multiplied by Gaussian proximity with scale
`min(0.23 * gap, max(2 / 0.15, 0.003 * predicted Hz))` and long-fit confidence.
Disjoint search bands and unique peak assignment prevent one peak from
representing adjacent modes. Early evidence requires long-fit confidence at
least 0.25 and no constraint boundary hit. It replaces a weak long line only
when stronger; already reliable long centers/confidence are retained.
4. Measure power in a **00.12 second Hann window (center 0.06 s)**, followed by
**0.30 second Hann windows centered at 0.35, 0.65, 1.0, and 1.4 seconds**.
Their onset-relative intervals are `[0, 0.12)`, `[0.20, 0.50)`, `[0.50, 0.80)`,
`[0.85, 1.15)`, and `[1.25, 1.55)` seconds. Window lengths are explicit, and
model/reference/probe use identical windows. Reports retain sample counts,
onset-relative start samples, actual sample-center times and durations;
the center of a sampled Hann is half a sample before the nominal center.
At A0 the short window spans only about three cycles and can leave low modes
unresolved; the later windows span about eight cycles. Zero padding
interpolates bins but does not improve actual resolution.
One-sided FFT-bin power is
`mean_channels(|FFT(x Hann)|²) * one_sided_factor / (Nfft sum(Hann²))`.
Summing bins therefore gives window-weighted stereo mean-square power,
independent of padding. Anti-phase left/right audio retains its power.
5. Each fixed peak neighborhood has half-width
`min(0.23 * nearest predicted harmonic gap, max(2.5 / window_duration, 0.008 * frequency))`
in Hz. This includes close unison lines and their main lobes while excluding
adjacent harmonics. Track the strongest bin inside this same neighborhood
at each time, rather than jumping to unrelated transient peaks. Subtract the
median local flank noise power times the band bin count. Confidence fades
between 1025 dB band SNR and 60 to 40 dB band/total power; it is also
weighted by the retained long/early line confidence. Unresolved, absent and near-Nyquist
partials have zero line confidence. No two-sided gain ratio or decay estimate
is formed from zero-confidence bands. Width is fixed for each duration and
partial; within that neighborhood the peak may move between windows.
6. Estimate positive loss in dB/s from the 0.351.4 second windows using a
weighted median of pair slopes. Require three reliable windows spanning at
least 0.7 seconds. Reject loss below 0.4 dB/s, rises exceeding 2 dB between
windows, or residual excursions over 3 dB. Residual scatter, disagreement
among slopes and noise confidence further reduce decay confidence. This
protects against beat dips, rising components and late noise floors. It
deliberately leaves many ambiguous decays unchanged.
Alongside line power, retain a **total-power upper bound** at every valid
independently predicted `f_n` neighborhood, even if no line was identified there.
Use the same harmonic-gap-limited width and flank floor as above, but keep the
entire band sum (including noise) and **add one further flank-floor power times
the band bin count**. This is a conservative measured energy ceiling for the
windowed band, not a recovered partial amplitude or a statistical coverage claim.
Broadband energy and leakage raise this ceiling, making a cut harder to justify.
Zero means the neighborhood could not be measured, not that its power is zero.
Using the ceiling requires reference location confidence of at least 0.25, no
pitch/stiffness constraint boundary hit, and a partial inside the range of
reliably identified **long-window** harmonics. An independently observed early
line outside that range supports only its own location, weighted by its line
and curve confidence; it does not extend ceilings through missing high modes
between or beyond the observations. The fundamental alone may be extrapolated below
that range if at least three of partials 28 are reliable: these low modes locate
the key and fundamental without relying on uncertain high-mode stiffness. The
ceiling's center always comes from the reference's own fitted curve, never the
model's frequencies. The measurement report retains `predicted_centers_hz`,
`power_upper_bound`, and the separate per-partial `location_confidence`; none of
these grants line or decay confidence to a missing component.
The early spectrum can recover short-lived high lines, but broad/noisy energy,
insufficiently prominent peaks and unresolved narrow gaps remain unsupported.
Neither windowed power nor a stiff-string approximation uniquely identifies a
physical pole in a coupled radiating piano. Reported losses are empirical band
losses, and all decay corrections need a new render to assess their effect.
## Shared level, gain and decay updates
One common reference dB offset is computed from onset-relative stereo RMS over
0.050.45 seconds of **C4 (MIDI 60), velocity 68**:
```text
reference_offset_db = 20 log10(model_anchor_RMS / reference_anchor_RMS)
target_band_db = reference_band_db + reference_offset_db
```
This anchor pair is required even for a pilot omitting C4, unless
`--reference-offset-db NUMBER` supplies an explicit fixed common offset. The
offset, raw anchor RMS values, and source hashes are recorded. There is no
per-note normalization or additional per-note gain. Relative note levels already
contribute through measured partial powers. Unmodeled broadband energy and
uncorrected modes mean total note RMS is not guaranteed to match after a fit.
For each partial, robustly average reliable log reference/model loss ratios over
all velocities. Confidence shrinks the proposed log ratio toward zero. A small
neighbor regularizer (at most 0.15) applies only to already supported,
low-confidence decay corrections; absent partials never inherit a neighbor's
correction. Bound the **iteration decay multiplier to `[0.25, 2]`**, multiply the
previous calibration's scale, then bound the **absolute scale to `[0.1, 4]`**.
Compute strike gain corrections from both early windows (0.06 and 0.35 seconds),
using power ratios in dB. Subtract the analytically predicted effect of the
*actually applied* decay change before fitting gain. With measured model loss
`L` dB/s, applied decay ratio `r`, time `t`, and finite-window bias `C` evaluated
with that observation's actual Hann length `N`:
```text
C(L, N) = 10 log10(sum(Hann_N² exp(-ln(10)/10 * L * centered_time_N)) / sum(Hann_N²))
predicted_decay_change_db = -L (r - 1) t + C(r L, N) - C(L, N)
strike_update_db = target_band_db - model_band_db - predicted_decay_change_db
```
This removes the first-order double count between strike amplitude and decay,
including finite-window averaging for an exponential. It does not assert that
the runtime band loss is a pole sigma or that scaling sigma produces this exact
change. If decay changes but that velocity has no reliable model loss estimate,
its gain observation is withheld because this compensation cannot be made.
Fit gain observations at all available velocities by weighted least squares
using exactly the runtime interpolation weights: piecewise linear **in dB** at
knots `[28, 68, 112]`, clamped outside. The diagonal zero-update prior is
`0.02 + 0.5 * max(0, 1 - weighted knot support)`. Weak evidence thus fades toward
zero residual correction. There is no cross-knot smoothing. An intermediate
velocity can algebraically constrain both neighboring knots, so positive updates
also require evidence in that knot's own required render: multiply a boost by
`min(1, knot_observation_confidence / 0.25)`. An absent soft partial cannot inherit
a boost from an intermediate or loud layer. This conservative projection may
increase the least-squares residual; its factors are recorded. Bound each **iteration gain update to
±12 dB**, add the previous gain, then bound **absolute gain to `[36, +24] dB**.
No supported source band means no boost, even if the reference has a strong
unrelated line. The fit never invents energy to fill deep model zeros.
When reference line confidence is at most 0.2, its upper bound can instead
contribute a **cut-only observation**. Require model band confidence above 0.25
and the reference location gate above. The measured model power must exceed
`reference_upper_bound * 10^(reference_offset_db / 10)`; the correction must also
remain negative after the same actually applied decay compensation. This uses
the shared offset, with no special bass curve or per-note normalization. Use the
least restrictive eligible early-window ceiling and weight it by
`0.25 * model_band_confidence * reference_location_confidence`. Weak-reference
windows supply no two-sided observation. Upper bounds never enter decay fitting;
an absent reference fundamental therefore retains its previous decay scale
unless other velocities supply genuine decay measurements.
These negative-only observations enter the same velocity fit, separately from
identified-line observations, and supply **no boost evidence**. Every knot with
nonzero interpolation weight at a censored observation is projected to a
nonpositive update. Thus even strong positive observations at other velocities
cannot turn that observation into a boost, including after iteration/absolute
clamping. This conservative projection can suppress a supported boost at an
adjacent velocity. Reports separate the cut ceilings, weights and guarded knots;
their before/after residual is the one-sided violation
`max(0, applied_gain_update_db - cut_only_upper_db)`. A cut beyond the ceiling has
zero violation, not an incentive to boost back up. Bounds are not equality
measurements, and a residual can remain when confidence, competing observations
or the gain limits prevent a full correction.
For a subsequent pass, render using the previous calibration and pass its CSV:
```sh
python3 libs/piano_model/tools/fit_voicing.py \
--renders local/piano-renders/pass1 \
--corpus local/score-corpus/salamander/SalamanderGrandPianoV3_48khz24bit \
--previous local/piano-voicing/pass1/calibration.csv \
--out local/piano-voicing/pass2
```
The previous CSV must have exactly 240 unique bounded finite rows for every fitted
key; legacy 64-row tables are rejected explicitly. By default the shared anchor
is **reused** from the sibling `metadata.json`,
whose generated CSV hash must match; it is not re-pinned to the changed render.
An explicit `--reference-offset-db` can supply the original fixed anchor when
metadata is unavailable. Ensure that this value and the rendered calibration
are correct. Missing evidence preserves the previous calibration: its new
residual correction is zero. A first raw pass starts at gain 0 and decay 1.
## Optional measured decay response
Use a matched baseline/probe pair to refine bands whose power beats or rises, so
the analytic positive-loss gate cannot estimate a correction:
```sh
python3 libs/piano_model/tools/fit_voicing.py \
--renders local/piano-renders/pass1-baseline \
--decay-probe local/piano-renders/pass1-probe --probe-decay-factor 0.7 \
--corpus local/score-corpus/salamander/SalamanderGrandPianoV3_48khz24bit \
--previous local/piano-voicing/pass1/calibration.csv \
--out local/piano-voicing/pass2
```
Both options and `--previous` are required together. Render the baseline with
the previous CSV. Create the probe CSV with **identical keys and gains**, replacing
every decay scale by `clamp(previous_scale * factor, 0.1, 4)`, including rows
outside a pilot selection. The factor must be finite, positive and unequal to 1;
0.7 is a typical perturbation. Both manifests must use `mode: "calibration"`
and embed the CSV text. The fitter compares every embedded row with the expected
table (absolute tolerance `5.1e-7`, relative tolerance `2e-7`, allowing six-place
CSV and float32 precision). It uses the **actual embedded probe scale** in the
derivative denominator, including clamping and rounding.
Both render directories must have the same notes and velocities, with WAV
inventories matching their manifests. Schema, rate, held-note timing, block size,
dry/effects and all other renderer/voicing settings must match; only calibration
path and CSV text may differ. This mode requires held stereo 48 kHz float32
renders of at least four seconds. It reuses the previous global amplitude anchor,
never measures a new one from the baseline or probe. An explicit offset must
match the prior metadata when present; if that metadata is absent, supply the
original fixed offset explicitly. A present metadata file with a mismatched CSV
hash is rejected even with an explicit offset.
Analyze each probe independently with the same line identification, frequency,
band-power and confidence machinery. Validate the power/confidence array shapes,
per-window sample lengths and matching model/probe/reference times and durations.
For each partial, velocity and time window:
```text
D = (probe_power_db - model_power_db) / log(actual_probe_scale / previous_scale)
target_db = reference_power_db + fixed_offset_db - model_power_db
predicted_change_db = velocity_weights · gain_updates_db + D * log_decay_update
```
Jointly fit the three gain knot updates and one shared log-decay update across
the five windows and available velocities. Only positive-power tonal observations
with model, probe and reference confidence above 0.2 identify decay; empirical
positive-loss confidence is unused. Require at least three windows spanning 0.7 s
within a velocity, each with combined confidence (including robust residual
weight) above 0.05. Center derivatives **within each velocity** before testing
variation: their weighted centered RMS must reach 1 dB per unit log scale and
at least 5% of their uncentered RMS. Constant response, no response, an absolute
log perturbation below `1e-4`, or insufficient time support gives zero decay
update. Reference upper bounds and noise floors never identify decay.
Weighted least squares uses the existing gain zero prior and a log-decay zero
prior of 1, with twelve iterations of 3 dB Huber residual reweighting. Recheck
identifiability after reweighting. Bound the local decay factor to **`[0.5, 2]`**
and the absolute scale to `[0.1, 4]`, then refit gains using the measured effect
of that **applied** decay update. Gain fitting uses all five supported windows;
with an applied decay update, omit windows without a confident model/probe
derivative. Preserve the knot boost evidence guards, least restrictive eligible
early-window reference cut ceilings, cut-only knot projection, ±12 dB gain step
and `[36, +24]` absolute gain bounds. Unsupported partials retain their previous
values. No neighboring decay regularization is used in this mode.
The four output filenames and CSV/Rust numeric formats are unchanged. Metadata
adds the probe factor, embedded metadata and hashes/sizes of its manifest and
used WAVs. Summary adds independent probe measurements, per-window derivatives
and confidence, time coverage and conditioning, requested/applied log-decay and
gain updates, and predicted residuals after both clamps and guards. Conditioning
and residuals describe this local two-render approximation, not acoustic success.
An unrepresentably large requested decay ratio is `null`; its log update remains
reported and the applied scale is bounded.
Rerender the candidate and check held-out velocities before accepting it; the
coupled response may change outside the measured perturbation.
## Outputs and review
- `calibration.csv`: sorted MIDI keys, 240 rows per key, partials 1240; columns
`key,partial,pp_db,mf_db,ff_db,decay_scale`.
- `calibration_data.rs`: `use super::CalibrationNote;` and
`pub const DEFAULT_CALIBRATION: &[CalibrationNote] = &[...]`, with
`gain_db: [[f32; 240]; 3]` in pp/mf/ff order and `decay_scale: [f32; 240]`.
It is intended as a public child module of the module defining `CalibrationNote`;
the constant stays public so external integration tests can import it.
This lane does not install it into runtime. Both numeric outputs use six
decimal places and are validated for shapes, finiteness and absolute limits.
- `metadata.json`: algorithm/settings (including separate identification intervals,
nominal `windows_seconds` centers and `window_durations_seconds`), script SHA256,
Python/NumPy versions,
original shared anchor, prior CSV hash, render metadata, generated data hashes,
attribution and SHA256/byte-size manifests for the SFZ, available README and
every **used** native WAV/render (including a separate anchor when needed).
Unused corpus WAVs are not hashed; no PCM or derived sample data is emitted.
- `summary.json`: per-input onsets, exact sample windows, independent pitch fits,
long/early line confidence and early-line selection, band powers, tracked
peaks, confidence and empirical losses; per-partial residual observations,
unclamped/applied corrections and clamp flags; aggregate unsupported/clamp
counts. Its status always requires closed-loop validation.
Outputs have no timestamps, random choices or embedded recordings. With identical
inputs, settings and numerical environment they are reproducible. Different
NumPy/FFT platforms may differ at floating-point precision. Keep manifests beside
the chosen data and preserve raw renders to make comparisons auditable.
Before integrating numeric data, rerender the held notes with it and compare
against the same independent reference frequencies and fixed amplitude anchor.
Review first-partial/unison-cluster energy, both early and sustained bands,
whole-note RMS, velocity continuity, decay confidence, boundary/clamp counts and
deep zeros. Include velocities between knots and notes between native samples
to check runtime interpolation. The runtime interpolates dB and log-decay in
MIDI pitch and tapers correction back to neutral over future partials 241256;
this fitter emits all 240 entries. A lower fit residual on input data is not an
acceptance test, and a saturated first pass is not a reason for automatic unlimited passes.
## Reference attribution and verification
Reference recordings: **Salamander Grand Piano V3**, **Alexander Holm**,
**Creative Commons Attribution 3.0**:
<https://creativecommons.org/licenses/by/3.0/>. The seeded corpus README supplies
this attribution and license; its heading still says V2 and its changelog
describes V3. Preserve attribution and the source manifest with derived numeric
calibration. Production receives numeric modal gains/decay only, never PCM or
derived playback samples. No claim of author endorsement is made.
Synthetic tests require no real corpus and create/remove temporary fixtures
inside the checkout:
```sh
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover \
-s libs/piano_model/tools -p test_fit_voicing.py -v
cargo check --release --offline -p makepad-piano-model
cargo test --release --offline -p makepad-piano-model
```
Tests cover WAV24/float/extensible decoding, anti-phase stereo and FFT padding
normalization, onset, independent known `f0/B`, known gain/decay recovery,
early-only A0 p64/p80/p100 attenuation, broadband attack rejection, early-peak
proximity/uniqueness, mixed-window analytic recovery, unison clusters,
missing/noisy partials, cut-only missing/weak fundamentals and
their one-sided residuals, location/shared-offset gates, censored velocity guards,
unreliable decay rejection, iteration
composition/clamps, velocity interpolation and use of extra layers, native SFZ
mapping, shared level/anchor reuse, missing-input failures, source preservation,
and deterministic 240-row CSV/Rust shape/order/finite values and legacy-table rejection.
Probe tests additionally cover non-monotone envelope recovery, robust residuals,
constant/unresponsive/unperturbed decay rejection, time support, bounded response
compensation, noise/censoring guards, full embedded CSV and renderer provenance,
fixed anchor reuse and deterministic probe outputs. The original response
fixtures retain their independent 0.15 s / 0.30 s first-window definition;
additional tests cover mixed-length recovery and mismatched window rejection.