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

1128 lines
63 KiB
Python

#!/usr/bin/env python3
"""Offline, empirical modal voicing of complete stereo piano renders. See VOICING.md."""
import argparse
import csv
from dataclasses import dataclass
import hashlib
import io
import json
import math
import os
from pathlib import Path
import re
import struct
import sys
import tempfile
import numpy as np
KNOTS = (28, 68, 112)
REPRESENTATIVE_VELOCITIES = (13, 28, 35, 40, 45, 48, 53, 60, 68, 76, 84, 92, 100, 112, 116, 124)
PARTIALS = 240
TIMES = np.array([0.06, 0.35, 0.65, 1.0, 1.4])
WINDOW_SECONDS = np.array([0.12, 0.30, 0.30, 0.30, 0.30])
EARLY_SECONDS = 0.15
GAIN_LIMITS = (-36.0, 24.0)
GAIN_STEP = 12.0
DECAY_LIMITS = (0.1, 4.0)
DECAY_STEP = (0.25, 2.0)
PROBE_DECAY_STEP = (0.5, 2.0)
CSV_FIELDS = ("key", "partial", "pp_db", "mf_db", "ff_db", "decay_scale")
OUTPUTS = ("calibration.csv", "calibration_data.rs", "metadata.json", "summary.json")
def read_wav(path):
"""Read little-endian RIFF PCM16/24/32 or float32, including extensible WAV.
Channels remain separate, as float64 in [-1, 1] for integer PCM. Float
samples are not clipped. Reject damaged/unsupported files instead of guessing.
"""
raw = Path(path).read_bytes()
if len(raw) < 12 or raw[:4] != b"RIFF" or raw[8:12] != b"WAVE":
raise ValueError(f"{path}: expected little-endian RIFF/WAVE")
end = struct.unpack_from("<I", raw, 4)[0] + 8
if end > len(raw):
raise ValueError(f"{path}: truncated RIFF")
fmt, payload = None, None
pos = 12
while pos + 8 <= end:
tag, size = struct.unpack_from("<4sI", raw, pos)
pos += 8
if pos + size > end:
raise ValueError(f"{path}: truncated WAV chunk")
if tag == b"fmt ":
fmt = raw[pos:pos + size]
elif tag == b"data":
if payload is not None:
raise ValueError(f"{path}: multiple data chunks unsupported")
payload = raw[pos:pos + size]
pos += size + (size & 1)
if fmt is None or len(fmt) < 16 or payload is None:
raise ValueError(f"{path}: missing fmt/data chunk")
encoding, channels, rate, byte_rate, align, bits = struct.unpack_from("<HHIIHH", fmt)
if encoding == 0xFFFE:
if len(fmt) < 40 or struct.unpack_from("<H", fmt, 16)[0] < 22:
raise ValueError(f"{path}: truncated extensible format")
valid_bits = struct.unpack_from("<H", fmt, 18)[0]
if valid_bits not in (0, bits) or fmt[28:40] != bytes.fromhex("00001000800000aa00389b71"):
raise ValueError(f"{path}: unsupported extensible PCM layout")
encoding = struct.unpack_from("<I", fmt, 24)[0]
if channels not in (1, 2) or rate <= 0 or bits not in (16, 24, 32):
raise ValueError(f"{path}: unsupported channels/rate/bit depth")
if align != channels * (bits // 8) or byte_rate != rate * align or len(payload) % align:
raise ValueError(f"{path}: invalid PCM block alignment")
if encoding == 3 and bits == 32:
samples = np.frombuffer(payload, dtype="<f4").astype(np.float64)
elif encoding == 1 and bits == 24:
octets = np.frombuffer(payload, dtype=np.uint8).reshape(-1, 3).astype(np.int32)
integers = octets[:, 0] | (octets[:, 1] << 8) | (octets[:, 2] << 16)
samples = ((integers ^ 0x800000) - 0x800000).astype(np.float64) / 8388608.0
elif encoding == 1 and bits in (16, 32):
samples = np.frombuffer(payload, dtype=f"<i{bits // 8}").astype(np.float64) / 2 ** (bits - 1)
else:
raise ValueError(f"{path}: unsupported WAV encoding {encoding}/{bits}")
samples = samples.reshape(-1, channels)
if not samples.size or not np.isfinite(samples).all():
raise ValueError(f"{path}: empty or non-finite audio")
return samples, rate, {"encoding": encoding, "bits": bits, "channels": channels}
def onset_index(samples, rate):
"""First 1 ms stereo RMS block above -40 dB relative to first-0.5s peak."""
block = max(1, round(rate * 0.001))
count = min(len(samples), round(rate * 0.5)) // block
if not count:
raise ValueError("silent onset: audio shorter than one RMS block")
power = np.mean(samples[:count * block].reshape(count, block, -1) ** 2, axis=(1, 2))
if np.max(power) <= 1e-20:
raise ValueError("silent onset: no signal in first 0.5 seconds")
return int(np.flatnonzero(power > np.max(power) * 1e-4)[0] * block)
def power_spectrum(samples, rate, nfft=None):
"""One-sided stereo mean-square POWER per bin, with Parseval/Hann scaling."""
size = len(samples)
if size < 4:
raise ValueError("FFT window too short")
if nfft is None:
nfft = 2 * (1 << (size - 1).bit_length())
if nfft < size:
raise ValueError("FFT size cannot truncate window")
window = np.hanning(size)
spectrum = np.fft.rfft(samples * window[:, None], n=nfft, axis=0)
power = np.mean(np.abs(spectrum) ** 2, axis=1) / (nfft * np.sum(window ** 2))
power[1:-1 if nfft % 2 == 0 else None] *= 2.0
return np.fft.rfftfreq(nfft, 1.0 / rate), power
def midi_frequency(key):
return 440.0 * 2.0 ** ((key - 69) / 12.0)
def partial_frequencies(f0, stiffness, count=PARTIALS):
n = np.arange(1, count + 1, dtype=float)
return f0 * n * np.sqrt(1.0 + stiffness * n * n)
def weighted_median(values, weights):
order = np.argsort(values, kind="stable")
values, weights = np.asarray(values)[order], np.asarray(weights)[order]
cumulative = np.cumsum(weights)
half = np.sum(weights) * 0.5
tolerance = np.sum(weights) * 1e-12
index = int(np.searchsorted(cumulative, half - tolerance))
# Resolve an exactly balanced pair symmetrically, including roundoff. Otherwise
# a constant dB shift can flip which of two slope estimates is the median.
if index + 1 < len(values) and abs(cumulative[index] - half) <= tolerance:
return float((values[index] + values[index + 1]) / 2)
return float(values[index])
def robust_average(values, weights):
values, weights = np.asarray(values), np.asarray(weights)
center = weighted_median(values, weights)
scale = max(0.05, 1.4826 * weighted_median(np.abs(values - center), weights))
adjusted = weights * np.minimum(1.0, 1.5 * scale / np.maximum(np.abs(values - center), 1e-12))
return float(np.sum(values * adjusted) / np.sum(adjusted))
def spectral_peaks(freq, power, duration):
"""Prominent local lines only; noise does not become a candidate harmonic."""
candidates = np.flatnonzero((power[1:-1] > power[:-2]) & (power[1:-1] >= power[2:])) + 1
candidates = candidates[power[candidates] > np.max(power) * 1e-6]
found, strengths = [], []
for index in candidates:
# Exclude the whole close-unison cluster when estimating the floor.
# A median through its center mistakes neighboring strings for noise.
radius = max(14.0 / duration, freq[index] * 0.024)
exclusion = max(4.0 / duration, freq[index] * 0.010)
lo, hi = np.searchsorted(freq, [freq[index] - radius, freq[index] + radius])
background = power[lo:hi][abs(freq[lo:hi] - freq[index]) > exclusion]
if not len(background):
continue
floor = max(float(np.median(background)), np.max(power) * 1e-12, 1e-30)
prominence = 10.0 * np.log10(power[index] / floor)
if prominence < 15.0:
continue
logs = np.log(np.maximum(power[index - 1:index + 2], 1e-30))
denominator = logs[0] - 2.0 * logs[1] + logs[2]
delta = np.clip(0.5 * (logs[0] - logs[2]) / min(denominator, -1e-15), -0.5, 0.5)
found.append(float(freq[index] + delta * (freq[1] - freq[0])))
relative_db = 10.0 * np.log10(power[index] / np.max(power))
strengths.append(float(np.clip((prominence - 15.0) / 15.0, 0, 1)
* np.clip((relative_db + 60.0) / 20.0, 0, 1)))
return np.asarray(found), np.asarray(strengths)
def early_partial_lines(samples, rate, predicted, gaps):
"""Match early local lines to a fixed, independently fitted long-FFT curve."""
segment = samples[:round(EARLY_SECONDS * rate)]
freq, power = power_spectrum(segment, rate)
duration = len(segment) / rate
maximum = max(float(np.max(power)), 1e-30)
peaks = np.flatnonzero((power[1:-1] > power[:-2]) & (power[1:-1] >= power[2:])) + 1
centers, confidence = predicted.copy(), np.zeros_like(predicted)
used = set()
for p, center in enumerate(predicted):
# Disjoint search bands and explicit peak ownership prevent aliases.
tolerance = min(gaps[p] * 0.23, max(2.0 / duration, center * 0.003))
radius = min(gaps[p] * 0.23, 2.5 * tolerance)
if center + radius >= rate * 0.49:
continue
candidates = peaks[abs(freq[peaks] - center) <= radius]
flank = (abs(freq - center) < gaps[p] * 0.45) & (abs(freq - center) > radius * 1.15)
if not len(candidates) or np.count_nonzero(flank) < 4:
continue
floor = max(float(np.median(power[flank])), maximum * 1e-12, 1e-30)
best = None
for index in candidates:
if int(index) in used:
continue
prominence = 10 * math.log10(max(power[index] / floor, 1e-30))
relative = 10 * math.log10(max(power[index] / maximum, 1e-30))
if prominence <= 15 or relative <= -60:
continue
logs = np.log(np.maximum(power[index - 1:index + 2], 1e-30))
denominator = logs[0] - 2 * logs[1] + logs[2]
delta = np.clip(0.5 * (logs[0] - logs[2]) / min(denominator, -1e-15), -0.5, 0.5)
observed = float(freq[index] + delta * (freq[1] - freq[0]))
if abs(observed - center) > radius:
continue
proximity = math.exp(-0.5 * ((observed - center) / tolerance) ** 2)
quality = float(np.clip((prominence - 15) / 15, 0, 1)
* np.clip((relative + 60) / 20, 0, 1) * proximity)
if best is None or quality > best[0]:
best = quality, observed, int(index)
if best is not None:
confidence[p], centers[p], index = best
used.add(index)
return centers, confidence
def identify_partials(samples, rate, key):
"""Independent constrained stiff-string fit, then measured line neighborhoods.
The stiff-string curve identifies harmonic numbers, not production frequencies.
Local measured centers (including an independent p1) are retained for analysis.
"""
segment = samples[round(0.1 * rate):round(1.2 * rate)]
freq, power = power_spectrum(segment, rate)
duration = len(segment) / rate
peaks, strength = spectral_peaks(freq, power, duration)
nominal = midi_frequency(key)
n = np.arange(1, PARTIALS + 1, dtype=float)
def match(predicted):
if not len(peaks):
return np.zeros(predicted.shape, dtype=int), np.zeros_like(predicted)
right = np.clip(np.searchsorted(peaks, predicted), 0, len(peaks) - 1)
left = np.maximum(right - 1, 0)
index = np.where(abs(peaks[left] - predicted) < abs(peaks[right] - predicted), left, right)
tolerance = np.maximum(2.0 / duration, predicted * 0.003)
distance = abs(peaks[index] - predicted) / tolerance
confidence = strength[index] * np.exp(-0.5 * distance ** 2) * (distance < 2.5)
confidence *= predicted < rate * 0.48
return index, confidence
# Deterministic coarse grid avoids silently locking high harmonics to B=0.
b_grid = np.r_[0.0, np.geomspace(1e-7, 0.01, 65)]
f_grid = nominal * 2.0 ** (np.linspace(-45, 45, 37) / 1200.0)
predicted = f_grid[:, None, None] * n * np.sqrt(1.0 + b_grid[None, :, None] * n * n)
_, confidence = match(predicted)
score = np.sum(confidence / np.sqrt(n), axis=-1)
fi, bi = np.unravel_index(np.argmax(score), score.shape)
f0, stiffness = float(f_grid[fi]), float(b_grid[bi])
if not len(peaks):
f0, stiffness = nominal, 0.0
for _ in range(6):
predicted = partial_frequencies(f0, stiffness)
indices, confidence = match(predicted)
valid = confidence > 0.15
if np.count_nonzero(valid) < 3:
break
nv, measured = n[valid], peaks[indices[valid]]
xscale = np.max(nv ** 2)
design = np.column_stack([np.ones(len(nv)), nv ** 2 / xscale])
weights = confidence[valid] / np.sqrt(nv)
target = (measured / nv) ** 2
for _ in range(4):
root = np.sqrt(weights)
coeff = np.linalg.lstsq(design * root[:, None], target * root, rcond=None)[0]
fitted = design @ coeff
residual = abs(target - fitted) / np.maximum(target, 1.0)
weights = confidence[valid] / np.sqrt(nv) * np.minimum(1.0, 0.003 / np.maximum(residual, 1e-12))
f0 = float(np.clip(math.sqrt(max(coeff[0], 1.0)), nominal * 2 ** (-45 / 1200), nominal * 2 ** (45 / 1200)))
stiffness = float(np.clip(coeff[1] / xscale / (f0 * f0), 0.0, 0.01))
predicted = partial_frequencies(f0, stiffness)
indices, confidence = match(predicted)
centers = predicted.copy()
if len(peaks):
centers[confidence > 0.1] = peaks[indices[confidence > 0.1]]
supported = confidence > 0.25
count = int(np.count_nonzero(supported))
residual_cents = (1200 * np.log2(centers[supported] / predicted[supported]))
error = float(np.sqrt(np.mean(residual_cents ** 2))) if count else 0.0
fit_confidence = float(np.clip((count - 2) / 6, 0, 1) * math.exp(-(error / 8) ** 2))
# With too few lines B is unidentified; do not extrapolate dubious upper modes.
confidence[2:] *= 0.35 + 0.65 * fit_confidence
gaps = np.minimum(np.diff(np.r_[0.0, predicted]), np.diff(np.r_[predicted, partial_frequencies(f0, stiffness, PARTIALS + 1)[-1]]))
boundary = bool(stiffness >= 0.0099 or abs(1200 * math.log2(f0 / nominal)) >= 44.9)
long_confidence = confidence.copy()
early_centers, early_confidence = early_partial_lines(samples, rate, predicted, gaps)
# Early peaks supply line evidence, never f0/B evidence. An unidentified or
# boundary-constrained curve cannot assign new high partials reliably.
early_confidence *= fit_confidence if not boundary and fit_confidence >= 0.25 else 0.0
use_early = (confidence <= 0.25) & (early_confidence > confidence)
centers[use_early] = early_centers[use_early]
confidence[use_early] = early_confidence[use_early]
return centers, confidence, gaps, {
"f0_hz": f0, "B": stiffness, "confidence": fit_confidence,
"supported_lines": count, "residual_cents_rms": error,
"constraint_boundary": boundary,
"long_line_confidence": long_confidence.tolist(),
"early_line_confidence": early_confidence.tolist(),
"early_line_used": use_early.tolist(),
}
def robust_decay(times, power, confidence):
"""Positive empirical dB/s loss and confidence; reject rises, floors and beating."""
good = (times >= 0.2) & (confidence > 0.2) & (power > 0)
t, y, w = times[good], 10 * np.log10(np.maximum(power[good], 1e-30)), confidence[good]
if len(t) < 3 or t[-1] - t[0] < 0.7:
return 0.0, 0.0
slopes, weights = [], []
for i in range(len(t)):
for j in range(i + 1, len(t)):
slopes.append((y[j] - y[i]) / (t[j] - t[i]))
weights.append(w[i] * w[j] * (t[j] - t[i]))
slope = weighted_median(slopes, weights)
intercept = weighted_median(y - slope * t, w)
residual = abs(y - (intercept + slope * t))
scatter = weighted_median(abs(np.asarray(slopes) - slope), weights)
if slope >= -0.4 or np.max(np.diff(y)) > 2.0 or np.max(residual) > 3.0:
return 0.0, 0.0
quality = float(np.mean(w) * math.exp(-(float(np.sqrt(np.mean(residual ** 2))) / 1.5) ** 2)
/ (1 + (scatter / (0.3 * abs(slope) + 0.3)) ** 2)
* np.clip((-slope - 0.4) / 1.5, 0, 1))
return float(-slope), quality
@dataclass
class Measurement:
power: np.ndarray
confidence: np.ndarray
power_upper_bound: np.ndarray
location_confidence: np.ndarray
loss: np.ndarray
loss_confidence: np.ndarray
times: np.ndarray
window_samples: np.ndarray
rate: int
report: dict
def analyze(samples, rate, key):
onset = onset_index(samples, rate)
samples = samples[onset:]
sizes = np.rint(WINDOW_SECONDS * rate).astype(int)
starts = np.maximum(0, np.rint(TIMES * rate - sizes / 2).astype(int))
if len(samples) < np.max(starts + sizes):
raise ValueError("audio ends before final 1.4-second measurement window")
centers, line_confidence, gaps, fit = identify_partials(samples, rate, key)
predicted = partial_frequencies(fit["f0_hz"], fit["B"])
location_confidence = np.zeros(PARTIALS)
long_confidence = np.asarray(fit["long_line_confidence"])
supported = np.flatnonzero(long_confidence > 0.25)
if fit["confidence"] >= 0.25 and not fit["constraint_boundary"] and len(supported):
# Interpolate only inside the independently identified harmonic range.
location_confidence[supported[0]:supported[-1] + 1] = fit["confidence"]
# A missing fundamental is located by at least three reliable low modes;
# extrapolating down from p2..p8 is insensitive to high-mode stiffness.
if np.count_nonzero(long_confidence[1:8] > 0.25) >= 3:
location_confidence[0] = fit["confidence"]
# An observed early high line supports its own location only; do not
# extrapolate missing-reference ceilings through the intervening tail.
early_supported = np.asarray(fit["early_line_used"]) & (line_confidence > 0.25)
location_confidence[early_supported] = fit["confidence"] * line_confidence[early_supported]
powers = np.zeros((len(TIMES), PARTIALS))
confidences = np.zeros_like(powers)
upper_bounds = np.zeros_like(powers)
tracks = np.zeros_like(powers)
times = np.zeros(len(TIMES))
for ti, (start, size) in enumerate(zip(starts, sizes)):
times[ti] = (start + (size - 1) / 2) / rate
freq, power = power_spectrum(samples[start:start + size], rate)
total = max(float(np.sum(power)), 1e-30)
for p in range(PARTIALS):
for bound, center in ((True, predicted[p]), (False, centers[p])):
half_width = min(gaps[p] * 0.23, max(2.5 / (size / rate), center * 0.008))
if (not bound and line_confidence[p] <= 0.05) or center + half_width >= rate * 0.49:
continue
band = (freq >= center - half_width) & (freq <= center + half_width)
flank = (abs(freq - center) < gaps[p] * 0.45) & (abs(freq - center) > half_width * 1.15)
if np.count_nonzero(flank) < 4 or not np.any(band):
continue
floor = max(float(np.median(power[flank])), total * 1e-12, 1e-30)
noise = floor * np.count_nonzero(band)
band_total = float(np.sum(power[band]))
if bound:
# Keep all measured energy, including the floor, plus one
# extra local-floor allowance. Never interpret this as a line.
upper_bounds[ti, p] = band_total + noise
continue
signal = max(band_total - noise, 0.0)
snr = 10 * math.log10(max(signal / noise, 1e-30))
relative = 10 * math.log10(max(signal / total, 1e-30))
quality = np.clip((snr - 10) / 15, 0, 1) * np.clip((relative + 60) / 20, 0, 1)
powers[ti, p] = signal
confidences[ti, p] = quality * line_confidence[p]
tracks[ti, p] = freq[band][np.argmax(power[band])]
losses, loss_confidence = np.zeros(PARTIALS), np.zeros(PARTIALS)
for p in range(PARTIALS):
losses[p], loss_confidence[p] = robust_decay(times, powers[:, p], confidences[:, p])
rms_slice = samples[round(0.05 * rate):round(0.45 * rate)]
report = {"onset_samples": onset, "onset_seconds": onset / rate,
"window_centers_seconds": times.tolist(), "window_samples": sizes.tolist(),
"window_start_samples": starts.tolist(), "window_durations_seconds": (sizes / rate).tolist(),
"pitch_fit": fit, "partial_centers_hz": centers.tolist(),
"predicted_centers_hz": predicted.tolist(),
"power_upper_bound": upper_bounds.tolist(),
"location_confidence": location_confidence.tolist(),
"tracked_peaks_hz": tracks.tolist(), "power": powers.tolist(),
"power_confidence": confidences.tolist(), "loss_db_per_s": losses.tolist(),
"loss_confidence": loss_confidence.tolist(),
"early_stereo_rms": float(np.sqrt(np.mean(rms_slice ** 2)))}
return Measurement(powers, confidences, upper_bounds, location_confidence,
losses, loss_confidence, times, sizes, rate, report)
def velocity_weights(velocities):
velocities = np.asarray(velocities, dtype=float).reshape(-1)
result = np.zeros((len(velocities), 3))
for i, velocity in enumerate(velocities):
if velocity <= KNOTS[0]:
result[i, 0] = 1.0
elif velocity >= KNOTS[-1]:
result[i, -1] = 1.0
else:
lo = 0 if velocity < KNOTS[1] else 1
fraction = (velocity - KNOTS[lo]) / (KNOTS[lo + 1] - KNOTS[lo])
result[i, lo:lo + 2] = (1 - fraction, fraction)
return result
def fit_velocity_gains(velocities, corrections, confidence):
design = velocity_weights(velocities)
confidence = np.asarray(confidence)
if not np.any(confidence > 0):
return np.zeros(3)
# Small zero prior stabilizes weak observations and unsupported endpoint knots.
support = design.T @ confidence
ridge = 0.02 + 0.5 * np.maximum(0, 1 - support)
matrix = design.T @ (confidence[:, None] * design) + np.diag(ridge)
# No cross-knot prior: a loud layer must not fill an absent soft-layer partial.
return np.linalg.solve(matrix, design.T @ (confidence * corrections))
def window_decay_bias(loss, measurement, ti):
"""Finite-Hann-window dB bias relative to power at the exact window center."""
size = measurement.window_samples[ti]
t = (np.arange(size) - (size - 1) / 2) / measurement.rate
w2 = np.hanning(size) ** 2
exponent = -math.log(10) / 10 * loss * t
shift = float(np.max(exponent))
return 10 / math.log(10) * (shift + math.log(float(np.sum(w2 * np.exp(exponent - shift)) / np.sum(w2))))
def validate_measurement_windows(measurements):
baseline = measurements[0]
for measurement in measurements:
times, sizes = np.asarray(measurement.times), np.asarray(measurement.window_samples)
if (times.ndim != 1 or len(times) < 2 or sizes.shape != times.shape
or not np.isfinite(times).all() or np.any(np.diff(times) <= 0)
or not np.isfinite(sizes).all() or np.any(sizes < 4) or np.any(sizes != np.rint(sizes))
or measurement.rate <= 0
or any(np.shape(array) != (len(times), PARTIALS) for array in
(measurement.power, measurement.confidence, measurement.power_upper_bound))):
raise ValueError("measurements have invalid time/window shapes")
if not (np.array_equal(times, baseline.times)
and np.array_equal(sizes / measurement.rate, baseline.window_samples / baseline.rate)):
raise ValueError("measurements must use the same time windows and durations")
def fit_note(pairs, reference_offset_db, previous_gain=None, previous_decay=None):
"""Fit residual strike dB and one shared empirical decay ratio per partial."""
for _, model, reference in pairs:
validate_measurement_windows((model, reference))
previous_gain = np.zeros((3, PARTIALS)) if previous_gain is None else previous_gain
previous_decay = np.ones(PARTIALS) if previous_decay is None else previous_decay
log_updates, decay_confidence = np.zeros(PARTIALS), np.zeros(PARTIALS)
for p in range(PARTIALS):
ratios, weights = [], []
for _, model, reference in pairs:
weight = model.loss_confidence[p] * reference.loss_confidence[p]
if weight > 0.05 and model.loss[p] > 0 and reference.loss[p] > 0:
ratios.append(math.log(reference.loss[p] / model.loss[p]))
weights.append(weight)
if weights:
center = robust_average(ratios, weights)
dispersion = weighted_median(abs(np.asarray(ratios) - center), weights)
quality = min(1.0, sum(weights)) / (1 + (dispersion / 0.35) ** 2)
log_updates[p] = center * quality
decay_confidence[p] = quality
# Light smoothing only on already supported, low-confidence decay estimates.
regularized = log_updates.copy()
for p in range(1, PARTIALS - 1):
if 0 < decay_confidence[p] < 0.8:
neighbors = decay_confidence[p - 1:p + 2:2]
if np.sum(neighbors) > 0:
amount = 0.15 * (1 - decay_confidence[p])
regularized[p] = (1 - amount) * log_updates[p] + amount * np.average(log_updates[p - 1:p + 2:2], weights=neighbors)
decay_step = np.clip(np.exp(regularized), *DECAY_STEP)
decay = np.clip(previous_decay * decay_step, *DECAY_LIMITS)
applied_ratio = decay / previous_decay
gain = np.zeros((3, PARTIALS))
partial_report = []
for p in range(PARTIALS):
velocities, corrections, weights = [], [], []
cut_corrections, cut_weights = [], []
for velocity, model, reference in pairs:
values, quality = [], []
cut_values, cut_quality = [], []
ratio = applied_ratio[p]
can_compensate = model.loss_confidence[p] > 0.05
for ti in (0, 1):
weight = model.confidence[ti, p] * reference.confidence[ti, p]
if abs(ratio - 1) > 1e-6 and not can_compensate:
continue
delta = 0.0
if can_compensate:
delta = (-model.loss[p] * (ratio - 1) * model.times[ti]
+ window_decay_bias(model.loss[p] * ratio, model, ti)
- window_decay_bias(model.loss[p], model, ti))
if reference.confidence[ti, p] <= 0.2:
# Censored reference energy can only support attenuation of
# a measured model line at a reliably located reference band.
upper = reference.power_upper_bound[ti, p]
if model.confidence[ti, p] > 0.25 and reference.location_confidence[p] >= 0.25 and upper > 0:
ceiling = 10 * math.log10(upper / model.power[ti, p]) + reference_offset_db
if ceiling < 0 and ceiling - delta < 0:
cut_values.append(ceiling - delta)
cut_quality.append(0.25 * model.confidence[ti, p] * reference.location_confidence[p])
elif weight > 0:
correction = 10 * math.log10(reference.power[ti, p] / model.power[ti, p]) + reference_offset_db - delta
values.append(correction)
quality.append(weight)
velocities.append(velocity)
if quality:
corrections.append(robust_average(values, quality))
weights.append(float(np.mean(quality)))
else:
corrections.append(0.0)
weights.append(0.0)
# Choose the least restrictive eligible early-window bound.
# These are never decay evidence.
cut_corrections.append(max(cut_values) if cut_values else 0.0)
cut_weights.append(float(np.mean(cut_quality)) if cut_quality else 0.0)
velocity_fit = fit_velocity_gains(velocities + velocities,
np.asarray(corrections + cut_corrections), weights + cut_weights)
# An intermediate layer can constrain two knots algebraically. Do not
# let it boost spectral noise at a knot whose own render lacks the mode.
knot_evidence = np.array([weights[velocities.index(v)] if v in velocities else 0.0 for v in KNOTS])
boost_guard = np.clip(knot_evidence / 0.25, 0, 1)
raw_update = np.where(velocity_fit > 0, velocity_fit * boost_guard, velocity_fit)
# Every knot touching a censored observation must remain cut-only. This
# also guards intermediate velocities against boosts from other layers.
design = velocity_weights(velocities)
cut_knots = design.T @ np.asarray(cut_weights) > 0
raw_update[cut_knots] = np.minimum(raw_update[cut_knots], 0.0)
update = np.clip(raw_update, -GAIN_STEP, GAIN_STEP)
gain[:, p] = np.clip(previous_gain[:, p] + update, *GAIN_LIMITS)
applied_by_velocity = design @ (gain[:, p] - previous_gain[:, p])
partial_report.append({
"partial": p + 1, "velocities": velocities,
"residual_db_by_velocity": corrections, "gain_confidence_by_velocity": weights,
"cut_only_upper_db_by_velocity": cut_corrections,
"cut_only_confidence_by_velocity": cut_weights,
"cut_only_residual_db_before": np.maximum(0, -np.asarray(cut_corrections)).tolist(),
"cut_only_residual_db_after": np.where(np.asarray(cut_weights) > 0,
np.maximum(0, applied_by_velocity - cut_corrections), 0).tolist(),
"knot_cut_only": cut_knots.tolist(),
"velocity_fit_update_db": velocity_fit.tolist(),
"knot_boost_evidence_factors": boost_guard.tolist(),
"unclamped_gain_update_db": raw_update.tolist(),
"applied_gain_update_db": (gain[:, p] - previous_gain[:, p]).tolist(),
"gain_clamped": bool(np.any(abs(raw_update - (gain[:, p] - previous_gain[:, p])) > 1e-8)),
"decay_confidence": float(decay_confidence[p]),
"unclamped_decay_ratio": float(np.exp(regularized[p])),
"applied_decay_ratio": float(applied_ratio[p]),
"decay_clamped": bool(abs(regularized[p] - math.log(applied_ratio[p])) > 1e-8),
})
return gain, decay, partial_report
def response_lstsq(design, target, weight, prior):
"""Small ridge fit with 3 dB Huber residual weights (no acoustic model)."""
effective = weight.copy()
update = np.zeros(design.shape[1])
for _ in range(12):
update = np.linalg.solve(design.T @ (effective[:, None] * design) + np.diag(prior),
design.T @ (effective * target))
effective = weight * np.minimum(1, 3 / np.maximum(abs(target - design @ update), 1e-12))
return update, effective
def response_condition(derivative, weight, times):
"""Center within each velocity, so layer differences cannot identify decay."""
centered = np.zeros_like(derivative)
eligible = np.zeros_like(weight)
spans = []
for i, row in enumerate(weight):
good = row > 0.05
span = float(np.ptp(times[i, good])) if np.any(good) else 0.0
if np.count_nonzero(good) >= 3 and span >= 0.7:
eligible[i, good] = row[good]
centered[i] = derivative[i] - np.average(derivative[i, good], weights=row[good])
spans.append(span)
support = float(eligible.sum())
rms = math.sqrt(float(np.sum(eligible * centered ** 2)) / support) if support else 0.0
total = math.sqrt(float(np.sum(eligible * derivative ** 2)) / support) if support else 0.0
fraction = rms / max(total, 1e-12)
identified = rms >= 1.0 and fraction >= 0.05
confidence = min(1, support / 3) * rms ** 2 / (1 + rms ** 2) * min(1, fraction / 0.1) if identified else 0.0
return eligible, {"identified": identified, "observations": int(np.count_nonzero(eligible)),
"time_span_seconds": max(spans, default=0.0),
"centered_derivative_rms_db_per_log_scale": rms,
"centered_derivative_fraction": fraction,
"conditioning_ratio": total / rms if rms > 1e-12 else None,
"confidence": confidence}
def fit_probe_note(triples, reference_offset_db, previous_gain, previous_decay, probe_decay):
"""Fit (velocity, model, probe, reference) Measurements using a local response.
Loss estimates are deliberately unused: stable beating/rises can carry an
informative derivative. Floors may constrain gain, never identify decay.
"""
velocities = [v for v, _, _, _ in triples]
for _, model, probe, reference in triples:
validate_measurement_windows((model, probe, reference))
if len({len(model.times) for _, model, _, _ in triples}) != 1:
raise ValueError("probe measurements have inconsistent time/window shapes")
velocity_design = velocity_weights(velocities)
times = np.array([m.times for _, m, _, _ in triples])
design = np.repeat(velocity_design, times.shape[1], axis=0)
gain, decay = np.array(previous_gain, dtype=float), np.array(previous_decay, dtype=float)
reports = []
for p in range(PARTIALS):
derivative, target, tonal_weight = (np.zeros_like(times) for _ in range(3))
response_weight = np.zeros_like(times)
log_probe = math.log(probe_decay[p] / previous_decay[p])
for i, (_, model, probe, reference) in enumerate(triples):
valid = (model.power[:, p] > 0) & (probe.power[:, p] > 0)
valid &= (model.confidence[:, p] > 0.2) & (probe.confidence[:, p] > 0.2)
if abs(log_probe) >= 1e-4:
derivative[i, valid] = 10 * (np.log10(probe.power[valid, p])
- np.log10(model.power[valid, p])) / log_probe
response_weight[i, valid] = model.confidence[valid, p] * probe.confidence[valid, p]
tonal = (reference.confidence[:, p] > 0.2) & (model.confidence[:, p] > 0)
tonal &= (model.power[:, p] > 0) & (reference.power[:, p] > 0)
target[i, tonal] = 10 * (np.log10(reference.power[tonal, p])
- np.log10(model.power[tonal, p])) + reference_offset_db
tonal_weight[i, tonal] = model.confidence[tonal, p] * reference.confidence[tonal, p]
triple_weight = response_weight * np.array([r.confidence[:, p] for _, _, _, r in triples])
triple_weight *= tonal_weight > 0
eligible, condition = response_condition(derivative, triple_weight, times)
joint = np.zeros(4)
if condition["identified"]:
joint_design = np.column_stack((design, derivative.ravel()))
support = design.T @ eligible.ravel()
prior = np.r_[0.02 + 0.5 * np.maximum(0, 1 - support), 1.0]
joint, robust_weight = response_lstsq(joint_design, target.ravel(), eligible.ravel(), prior)
_, condition = response_condition(derivative, robust_weight.reshape(times.shape), times)
requested_log = float(joint[3]) if condition["identified"] else 0.0
bounded_log = float(np.clip(requested_log, *np.log(PROBE_DECAY_STEP)))
decay[p] = np.clip(previous_decay[p] * math.exp(bounded_log), *DECAY_LIMITS)
applied_log = math.log(decay[p] / previous_decay[p])
compensation = derivative * applied_log
# With a decay update, an unmeasured response cannot be compensated.
gain_weight = tonal_weight.copy()
if abs(applied_log) > 1e-12:
gain_weight *= response_weight > 0
corrected = target - compensation
cut_values, cut_weights = [], []
for i, (_, model, _, reference) in enumerate(triples):
values, weights = [], []
for ti in (0, 1):
if abs(applied_log) > 1e-12 and response_weight[i, ti] <= 0:
continue
upper = reference.power_upper_bound[ti, p]
if (reference.confidence[ti, p] <= 0.2 and model.confidence[ti, p] > 0.25
and reference.location_confidence[p] >= 0.25 and upper > 0 and model.power[ti, p] > 0):
ceiling = 10 * math.log10(upper / model.power[ti, p]) + reference_offset_db
if ceiling < 0 and ceiling - compensation[i, ti] < 0:
values.append(ceiling - compensation[i, ti])
weights.append(0.25 * model.confidence[ti, p] * reference.location_confidence[p])
cut_values.append(max(values) if values else 0.0)
cut_weights.append(float(np.mean(weights)) if weights else 0.0)
cut_values, cut_weights = np.array(cut_values), np.array(cut_weights)
gain_design = np.vstack((design, velocity_design))
gain_target = np.r_[corrected.ravel(), cut_values]
weights = np.r_[gain_weight.ravel(), cut_weights]
support = gain_design.T @ weights
velocity_fit, _ = response_lstsq(gain_design, gain_target, weights,
0.02 + 0.5 * np.maximum(0, 1 - support))
by_velocity = gain_weight.mean(axis=1)
boost_guard = np.clip([by_velocity[velocities.index(v)] / 0.25 if v in velocities else 0 for v in KNOTS], 0, 1)
raw_update = np.where(velocity_fit > 0, velocity_fit * boost_guard, velocity_fit)
cut_knots = velocity_design.T @ cut_weights > 0
raw_update[cut_knots] = np.minimum(raw_update[cut_knots], 0)
gain[:, p] = np.clip(previous_gain[:, p] + np.clip(raw_update, -GAIN_STEP, GAIN_STEP), *GAIN_LIMITS)
applied_gain = gain[:, p] - previous_gain[:, p]
predicted = (design @ applied_gain).reshape(times.shape) + compensation
residual = target - predicted
usable = gain_weight > 0
rms = lambda values: math.sqrt(float(np.sum(gain_weight * values ** 2) / gain_weight.sum())) if np.any(usable) else None
reports.append({
"partial": p + 1, "velocities": velocities,
"gain_confidence_by_velocity": by_velocity.tolist(),
"residual_db_by_velocity": [float(np.average(row, weights=w)) if np.any(w) else 0.0
for row, w in zip(corrected, gain_weight)],
"cut_only_upper_db_by_velocity": cut_values.tolist(),
"cut_only_confidence_by_velocity": cut_weights.tolist(),
"cut_only_residual_db_before": np.maximum(0, -cut_values).tolist(),
"cut_only_residual_db_after": np.where(cut_weights > 0,
np.maximum(0, velocity_design @ applied_gain - cut_values), 0).tolist(),
"knot_cut_only": cut_knots.tolist(), "knot_boost_evidence_factors": boost_guard.tolist(),
"velocity_fit_update_db": velocity_fit.tolist(),
"unclamped_gain_update_db": raw_update.tolist(), "applied_gain_update_db": applied_gain.tolist(),
"gain_clamped": bool(np.any(abs(raw_update - applied_gain) > 1e-8)),
"decay_confidence": condition["confidence"],
"requested_log_decay_update": requested_log, "applied_log_decay_update": applied_log,
"unclamped_decay_ratio": math.exp(requested_log) if requested_log <= math.log(sys.float_info.max) else None,
"applied_decay_ratio": float(decay[p] / previous_decay[p]),
"decay_clamped": abs(requested_log - applied_log) > 1e-8,
"probe_log_scale_delta": log_probe, "joint_gain_update_db": joint[:3].tolist(),
"response_condition": condition,
"derivative_observations": int(np.count_nonzero(response_weight)),
"derivative_db_per_log_scale": [[float(d) if w > 0 else None for d, w in zip(row, weights)]
for row, weights in zip(derivative, response_weight)],
"derivative_confidence": response_weight.tolist(),
"decay_observation_confidence": triple_weight.tolist(),
"predicted_residual_db": [[float(r) if ok else None for r, ok in zip(row, good)]
for row, good in zip(residual, usable)],
"predicted_residual_rms_db_before": rms(target), "predicted_residual_rms_db_after": rms(residual),
})
return gain, decay, reports
@dataclass(frozen=True)
class Region:
key: int
lovel: int
hivel: int
sample: Path
layer: int
def sfz_key(value):
if re.fullmatch(r"-?\d+", value):
return int(value)
match = re.fullmatch(r"([A-Ga-g])([#b]?)(-?\d+)", value)
if not match:
raise ValueError(f"invalid SFZ key {value!r}")
letter, accidental, octave = match.groups()
return (int(octave) + 1) * 12 + {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11}[letter.upper()] + {"": 0, "#": 1, "b": -1}[accidental]
def parse_sfz(corpus):
corpus = Path(corpus).resolve()
sfz = corpus / "SalamanderGrandPianoV3.sfz"
source = sfz.read_text(encoding="utf-8-sig")
source = re.sub(r"/\*.*?\*/|//[^\n]*", "", source, flags=re.S)
if re.search(r"^\s*#", source, re.M):
raise ValueError(f"{sfz}: SFZ preprocessor directives unsupported")
global_values, group, regions = {}, {}, []
for match in re.finditer(r"<([^>]+)>([^<]*)", source):
section, body = match.groups()
values = {m.group(1): m.group(2).strip().strip('"') for m in re.finditer(r"(\w+)\s*=\s*(.*?)(?=\s+\w+\s*=|$)", body, re.S)}
if section == "global":
global_values = values
group = {}
elif section == "group":
group = values
elif section == "region":
values = {**global_values, **group, **values}
name = values.get("sample", "").replace("\\", "/")
native = re.fullmatch(r"([A-Ga-g][#b]?-?\d+)v(\d+)\.wav", Path(name).name, re.I)
if not native or values.get("trigger", "attack") not in ("attack", "first"):
continue
key = sfz_key(values.get("pitch_keycenter", values.get("key", "60")))
low = sfz_key(values.get("lokey", values.get("key", "0")))
high = sfz_key(values.get("hikey", values.get("key", "127")))
if key != sfz_key(native[1]) or not low <= key <= high:
raise ValueError(f"{sfz}: non-native keycenter for {name}")
if float(values.get("tune", 0)) or float(values.get("transpose", 0)):
raise ValueError(f"{sfz}: retuned region unsupported: {name}")
sample = (sfz.parent / name).resolve()
if not sample.is_relative_to(corpus):
raise ValueError(f"{sfz}: sample escapes corpus: {name}")
region = Region(key, int(values.get("lovel", 1)), int(values.get("hivel", 127)), sample, int(native[2]))
if not (0 <= key <= 127 and 1 <= region.lovel <= region.hivel <= 127 and 1 <= region.layer <= 16):
raise ValueError(f"{sfz}: invalid region: {name}")
regions.append(region)
if not regions:
raise ValueError(f"{sfz}: no native attack regions")
return sfz, regions
def select_region(regions, key, velocity):
matches = [r for r in regions if r.key == key and r.lovel <= velocity <= r.hivel]
if len(matches) != 1:
raise ValueError(f"native key {key}, velocity {velocity}: expected one SFZ region, found {len(matches)}")
return matches[0]
def sha256(path):
digest = hashlib.sha256()
with Path(path).open("rb") as source:
for block in iter(lambda: source.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def read_previous(path):
return read_calibration(Path(path).read_text(), str(path))
def read_calibration(text, label):
notes, seen = {}, set()
with io.StringIO(text, newline="") as source:
reader = csv.DictReader(source)
if reader.fieldnames != list(CSV_FIELDS):
raise ValueError(f"{label}: expected CSV columns {','.join(CSV_FIELDS)}")
for row in reader:
if None in row or any(value is None for value in row.values()):
raise ValueError(f"{label}: expected six CSV fields per row")
key, partial = int(row["key"]), int(row["partial"])
values = np.array([float(row[field]) for field in CSV_FIELDS[2:]])
if not (0 <= key <= 127 and 1 <= partial <= PARTIALS) or (key, partial) in seen:
raise ValueError(f"{label}: invalid/duplicate key or partial")
if not np.isfinite(values).all() or np.any(values[:3] < GAIN_LIMITS[0]) or np.any(values[:3] > GAIN_LIMITS[1]) or not DECAY_LIMITS[0] <= values[3] <= DECAY_LIMITS[1]:
raise ValueError(f"{label}: non-finite/out-of-range calibration")
seen.add((key, partial))
gains, decay = notes.setdefault(key, (np.zeros((3, PARTIALS)), np.ones(PARTIALS)))
gains[:, partial - 1], decay[partial - 1] = values[:3], values[3]
if not notes or any(sum(k == key for k, _ in seen) != PARTIALS for key in notes):
raise ValueError(f"{label}: each key must contain exactly {PARTIALS} partials (legacy 64-row tables are unsupported)")
return notes
def validate_probe(renders, baseline, probe_dir, probe, previous, factor):
"""Require matching renderer settings and complete embedded calibration tables."""
if not math.isfinite(factor) or factor <= 0 or factor == 1:
raise ValueError("--probe-decay-factor must be finite, positive and != 1")
settings, tables = [], []
required = {"schema", "mode", "rate_hz", "seconds", "notes", "velocities", "dry", "effects",
"note_on_sample", "note_off", "block_frames", "design_defaults_plus_overrides"}
for label, root, info, multiplier in (("baseline", renders, baseline, 1.0),
("probe", probe_dir, probe, factor)):
if not isinstance(info, dict) or not required <= info.keys():
raise ValueError(f"{label}: incomplete renderer settings for decay probe")
json.dumps(info, allow_nan=False)
if (info["schema"] != 1 or info["mode"] != "calibration" or info["rate_hz"] != 48000
or not isinstance(info["seconds"], (int, float)) or info["seconds"] < 4
or type(info["dry"]) is not bool or not isinstance(info["effects"], str)
or not isinstance(info["design_defaults_plus_overrides"], dict)
or info["note_on_sample"] != 0 or info["note_off"] is not None):
raise ValueError(f"{label}: expected held 48 kHz calibration renders with explicit effects")
calibration = info.get("calibration")
if not isinstance(calibration, dict) or not isinstance(calibration.get("csv"), str):
raise ValueError(f"{label}: render.json must embed calibration.csv text")
table = read_calibration(calibration["csv"], label)
if table.keys() != previous.keys():
raise ValueError(f"{label}: calibration keys differ from --previous")
for key, (gain, decay) in table.items():
expected_gain, previous_decay = previous[key]
# Six-place CSV rounding plus f32 parsing/multiplication precision.
expected_decay = np.clip(previous_decay * multiplier, *DECAY_LIMITS)
if (not np.allclose(gain, expected_gain, rtol=2e-7, atol=5.1e-7)
or not np.allclose(decay, expected_decay, rtol=2e-7, atol=5.1e-7)):
raise ValueError(f"{label}: calibration does not match expected gains/decay for key {key}")
comparable = {k: v for k, v in info.items() if k != "calibration"}
# The source path and CSV necessarily differ; any other voicing metadata must match.
comparable["calibration"] = {k: v for k, v in calibration.items() if k not in ("path", "csv")}
for field in ("notes", "velocities"):
values = info[field]
if (not isinstance(values, list) or not values
or any(type(v) is not int or not 1 <= v <= 127 for v in values)
or len(set(values)) != len(values)):
raise ValueError(f"{label}: invalid manifest {field}")
comparable[field] = sorted(values)
expected_files = {f"note_{key:03}_vel_{velocity:03}.wav"
for key in info["notes"] for velocity in info["velocities"]}
actual_files = {p.name for p in root.glob("note_*_vel_*.wav") if p.is_file()}
if actual_files != expected_files:
raise ValueError(f"{label}: WAV notes/velocities do not match render.json")
settings.append(comparable)
tables.append(table)
if settings[0] != settings[1]:
raise ValueError("baseline/probe renderer settings differ (notes, velocities, voicing or effects)")
return tables[1]
def write_outputs(out, notes, metadata, summary):
out = Path(out)
for key, (gain, decay) in notes.items():
if not 0 <= key <= 127 or gain.shape != (3, PARTIALS) or decay.shape != (PARTIALS,):
raise ValueError("invalid calibration shape")
if not np.isfinite(gain).all() or not np.isfinite(decay).all() or np.any(gain < GAIN_LIMITS[0]) or np.any(gain > GAIN_LIMITS[1]) or np.any(decay < DECAY_LIMITS[0]) or np.any(decay > DECAY_LIMITS[1]):
raise ValueError("invalid calibration values")
out.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix=".fit-", dir=out) as staging:
staging = Path(staging)
with (staging / OUTPUTS[0]).open("w", newline="") as destination:
writer = csv.writer(destination, lineterminator="\n")
writer.writerow(CSV_FIELDS)
for key, (gain, decay) in sorted(notes.items()):
for p in range(PARTIALS):
writer.writerow([key, p + 1, *(f"{v:.6f}" for v in gain[:, p]), f"{decay[p]:.6f}"])
rust = ["// Generated empirical modal voicing; closed-loop validation required.",
"// Reference: Salamander Grand Piano V3, Alexander Holm, CC BY 3.0.",
"// Source SHA256 manifest and settings: metadata.json.",
"use super::CalibrationNote;", "", "pub const DEFAULT_CALIBRATION: &[CalibrationNote] = &["]
for key, (gain, decay) in sorted(notes.items()):
rust.extend([" CalibrationNote {", f" key: {key},", " gain_db: ["])
rust.extend(" [" + ", ".join(f"{v:.6f}" for v in row) + "]," for row in gain)
rust.extend([" ],", " decay_scale: [" + ", ".join(f"{v:.6f}" for v in decay) + "],", " },"])
rust.append("];\n")
(staging / OUTPUTS[1]).write_text("\n".join(rust))
metadata = {**metadata, "generated_sha256": {name: sha256(staging / name) for name in OUTPUTS[:2]}}
for name, value in ((OUTPUTS[2], metadata), (OUTPUTS[3], summary)):
(staging / name).write_text(json.dumps(value, indent=2, sort_keys=True, allow_nan=False) + "\n")
for name in OUTPUTS:
os.replace(staging / name, out / name)
def midi_list(value):
try:
values = sorted(set(int(v) for v in value.split(",")))
except ValueError as error:
raise argparse.ArgumentTypeError("use comma-separated MIDI integers") from error
if not values or min(values) < 1 or max(values) > 127:
raise argparse.ArgumentTypeError("MIDI values must be in 1..127")
return values
def run(args):
renders, corpus, out = (Path(p).resolve() for p in (args.renders, args.corpus, args.out))
probe_dir = Path(args.decay_probe).resolve() if args.decay_probe else None
if (probe_dir is None) != (args.probe_decay_factor is None) or (probe_dir and not args.previous):
raise ValueError("--decay-probe and --probe-decay-factor require each other and --previous")
if out.is_relative_to(renders) or out.is_relative_to(corpus) or (probe_dir and out.is_relative_to(probe_dir)):
raise ValueError("--out must be outside renders, probe and corpus to preserve sources")
previous_path = Path(args.previous).resolve() if args.previous else None
if previous_path and previous_path in [(out / name).resolve() for name in OUTPUTS]:
raise ValueError("--out must not overwrite --previous")
render_json = renders / "render.json"
render_info = json.loads(render_json.read_text())
if not isinstance(render_info, dict):
raise ValueError("render.json must contain a JSON object")
sfz, regions = parse_sfz(corpus)
native_keys = sorted({region.key for region in regions})
keys = args.notes or native_keys
if not args.notes and len(native_keys) != 30:
raise ValueError(f"expected 30 native keys, found {len(native_keys)}; use --notes for a pilot")
if any(key not in native_keys for key in keys):
raise ValueError("--notes contains a non-native SFZ keycenter")
expected = sorted(set(KNOTS) | set(args.velocities))
inputs = {}
for key in keys:
velocities = set(expected)
for path in renders.glob(f"note_{key:03}_vel_*.wav"):
match = re.fullmatch(r"note_\d{3}_vel_(\d{3})\.wav", path.name)
if not match or not 1 <= int(match[1]) <= 127:
raise ValueError(f"invalid render filename: {path.name}")
velocities.add(int(match[1]))
for velocity in sorted(velocities):
region = select_region(regions, key, velocity)
inputs[key, velocity] = (renders / f"note_{key:03}_vel_{velocity:03}.wav", region)
previous = read_previous(previous_path) if previous_path else {}
if previous and any(key not in previous for key in keys):
raise ValueError("--previous must contain every fitted key")
probe_info, probe_table = None, None
if probe_dir:
probe_info = json.loads((probe_dir / "render.json").read_text())
probe_table = validate_probe(renders, render_info, probe_dir, probe_info, previous, args.probe_decay_factor)
anchor = {"method": "fixed", "offset_db": args.reference_offset_db}
if previous_path and (args.reference_offset_db is None or (probe_dir and (previous_path.parent / "metadata.json").is_file())):
previous_metadata = previous_path.parent / "metadata.json"
prior = json.loads(previous_metadata.read_text())
if prior.get("generated_sha256", {}).get("calibration.csv") != sha256(previous_path):
if probe_dir:
raise ValueError("previous metadata does not identify this CSV; decay probe requires the original anchor provenance")
raise ValueError("previous metadata does not identify this CSV; supply --reference-offset-db explicitly")
if probe_dir and args.reference_offset_db is not None and not math.isclose(args.reference_offset_db, prior["anchor"]["offset_db"], abs_tol=1e-9, rel_tol=0):
raise ValueError("decay probe must reuse the previous global reference offset")
anchor = {**prior["anchor"], "reused_from_previous": True}
elif args.reference_offset_db is None:
region = select_region(regions, 60, 68)
inputs.setdefault((60, 68), (renders / "note_060_vel_068.wav", region))
anchor = {"method": "C4_68_stereo_rms_0.05_to_0.45s", "key": 60, "velocity": 68, "offset_db": None}
if (probe_dir and anchor["offset_db"] is None) or (anchor["offset_db"] is not None and not math.isfinite(anchor["offset_db"])):
raise ValueError("reference offset must be finite")
missing = [str(path) for model, region in inputs.values() for path in (model, region.sample) if not path.is_file()]
if missing:
raise ValueError("missing required inputs (no pitch/layer substitution):\n" + "\n".join(sorted(set(missing))))
source_files = {sfz, *(region.sample for _, region in inputs.values())}
if (corpus / "README").is_file():
source_files.add(corpus / "README")
manifest = lambda paths, root: [{"path": str(path.relative_to(root)), "sha256": sha256(path), "bytes": path.stat().st_size} for path in sorted(paths)]
source_manifest = manifest(source_files, corpus)
render_manifest = manifest({render_json, *(model for model, _ in inputs.values())}, renders)
probe_manifest = manifest({probe_dir / "render.json", *(probe_dir / model.name for model, _ in inputs.values())}, probe_dir) if probe_dir else None
def measure(path, key, model):
samples, rate, fmt = read_wav(path)
if rate != 48000 or fmt["channels"] != 2 or fmt["encoding"] != (3 if model else 1) or fmt["bits"] != (32 if model else 24):
raise ValueError(f"{path}: expected stereo 48 kHz {'float32 render' if model else 'PCM24 reference'}")
if model and len(samples) < rate * 4 - 1:
raise ValueError(f"{path}: expected held render of at least 4 seconds")
return analyze(samples, rate, key)
cached_anchor = None
if anchor["offset_db"] is None:
model_path, region = inputs[60, 68]
model, reference = measure(model_path, 60, True), measure(region.sample, 60, False)
anchor["offset_db"] = 20 * math.log10(model.report["early_stereo_rms"] / reference.report["early_stereo_rms"])
anchor["model_rms"], anchor["reference_rms"] = model.report["early_stereo_rms"], reference.report["early_stereo_rms"]
anchor["original_model_sha256"] = sha256(model_path)
anchor["original_reference_sha256"] = sha256(region.sample)
cached_anchor = (model, reference)
notes, reports = {}, []
for key in keys:
pairs, triples, observations, reference_cache = [], [], [], {}
for (input_key, velocity), (model_path, region) in sorted(inputs.items()):
if input_key != key:
continue
if (key, velocity) == (60, 68) and cached_anchor is not None:
model, reference = cached_anchor
else:
model = measure(model_path, key, True)
if region.sample not in reference_cache:
reference_cache[region.sample] = measure(region.sample, key, False)
reference = reference_cache[region.sample]
pairs.append((velocity, model, reference))
observations.append({"velocity": velocity, "layer": region.layer,
"reference": str(region.sample.relative_to(corpus)),
"model": model.report, "reference_measurement": reference.report})
if probe_dir:
probe = measure(probe_dir / model_path.name, key, True)
triples.append((velocity, model, probe, reference))
observations[-1]["probe_measurement"] = probe.report
if probe_dir:
gain, decay, partial_report = fit_probe_note(triples, anchor["offset_db"], *previous[key], probe_table[key][1])
else:
gain, decay, partial_report = fit_note(pairs, anchor["offset_db"], *previous.get(key, (None, None)))
notes[key] = gain, decay
reports.append({"key": key, "inputs": observations, "partials": partial_report})
print(f"fitted native key {key}: {len(pairs)} velocities", file=sys.stderr)
metadata = {
"schema_version": 1, "algorithm": "empirical-stereo-modal-voicing-v1",
"script_sha256": sha256(__file__), "numpy_version": np.__version__, "python_version": sys.version,
"reference_attribution": {"title": "Salamander Grand Piano V3", "author": "Alexander Holm", "license": "CC BY 3.0", "license_url": "https://creativecommons.org/licenses/by/3.0/"},
"source_manifest": source_manifest, "render_manifest": render_manifest,
"render_metadata": render_info, "anchor": anchor, "keys": keys,
"expected_velocities": expected, "velocity_knots": KNOTS, "partials": PARTIALS,
"windows_seconds": TIMES.tolist(), "window_durations_seconds": WINDOW_SECONDS.tolist(),
"gain_limits_db": GAIN_LIMITS, "gain_step_limit_db": GAIN_STEP,
"decay_limits": DECAY_LIMITS, "decay_step_limits": DECAY_STEP,
"measurement_settings": {
"onset_block_seconds": 0.001, "onset_peak_search_seconds": 0.5,
"onset_relative_threshold_db": -40, "identification_seconds": [0.1, 1.2],
"early_identification_seconds": [0, EARLY_SECONDS],
"early_match_radius": "min(0.23 * harmonic gap, 2.5 * max(2 / early duration, 0.003 * predicted Hz))",
"f0_search_cents": [-45, 45], "B_search": [0, 0.01],
"line_prominence_min_db": 15, "band_snr_fade_db": [10, 25],
"relative_band_power_fade_db": [-60, -40],
"band_half_width": "min(0.23 * harmonic gap, max(2.5 / window duration, 0.008 * center Hz))",
"gain_velocity_zero_prior": "0.02 + 0.5 * max(0, 1 - knot support)",
"positive_gain_knot_evidence_full_at": 0.25,
"power_upper_bound": "predicted harmonic band total power plus one local-floor allowance",
"cut_only_weight_scale": 0.25,
"cut_only_location_confidence_min": 0.25,
"cut_only_model_confidence_min": 0.25,
"cut_only_reference_confidence_max": 0.2,
"decay_neighborhood_strength_max": 0.15,
},
"previous_sha256": sha256(previous_path) if previous_path else None,
"reference_target": "native recorded PCM; SFZ used for key/layer selection, no SFZ gain or retuning",
"acceptance": "unvalidated; requires held-out and closed-loop rerender measurement",
}
if probe_dir:
metadata.update({"algorithm": "empirical-stereo-modal-voicing-probe-v1",
"probe_manifest": probe_manifest, "probe_metadata": probe_info,
"probe_decay_factor": args.probe_decay_factor, "decay_step_limits": PROBE_DECAY_STEP})
metadata["measurement_settings"].update({
"response": "(probe_power_db - model_power_db) / log(actual_probe_scale / previous_scale)",
"probe_log_scale_delta_min": 1e-4, "response_tonal_confidence_min": 0.2,
"response_time_support_weight_min": 0.05,
"response_min_windows_per_velocity": 3, "response_min_time_span_seconds": 0.7,
"response_centered_rms_min_db_per_log_scale": 1.0, "response_centered_fraction_min": 0.05,
"response_huber_db": 3.0, "response_irls_iterations": 12, "response_log_decay_zero_prior": 1.0,
"decay_neighborhood_strength_max": 0.0,
})
all_partials = [p for report in reports for p in report["partials"]]
summary = {"acceptance": metadata["acceptance"], "anchor": anchor,
"fitted_keys": len(notes), "fitted_inputs": sum(len(r["inputs"]) for r in reports),
"gain_clamped_partials": sum(p["gain_clamped"] for p in all_partials),
"decay_clamped_partials": sum(p["decay_clamped"] for p in all_partials),
"unsupported_partials": sum(not any(p["gain_confidence_by_velocity"] + p["cut_only_confidence_by_velocity"]) for p in all_partials),
"notes": reports}
write_outputs(out, notes, metadata, summary)
print(f"Wrote {len(notes)} keys / {len(notes) * PARTIALS} rows to {out}; closed-loop validation required.")
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--renders", required=True, type=Path)
parser.add_argument("--corpus", required=True, type=Path)
parser.add_argument("--out", required=True, type=Path, help="explicit output directory; only four named outputs may be replaced")
parser.add_argument("--previous", type=Path)
parser.add_argument("--decay-probe", type=Path, help="matched renders with previous decay scales perturbed; requires --previous and --probe-decay-factor")
parser.add_argument("--probe-decay-factor", type=float, help="positive non-unit multiplier used for every probe decay scale before clamping (typically 0.7)")
parser.add_argument("--velocities", type=midi_list, default=list(KNOTS), help="comma-separated required velocities; knots are always required; available extra layers also contribute")
parser.add_argument("--notes", type=midi_list, help="comma-separated native MIDI keys for a pilot; default all 30")
parser.add_argument("--reference-offset-db", type=float, help="fixed shared dB offset added to reference; otherwise anchor C4/68, or reuse previous metadata")
args = parser.parse_args(argv)
try:
run(args)
except (OSError, ValueError, KeyError, struct.error) as error:
parser.exit(2, f"fit_voicing: {error}\n")
if __name__ == "__main__":
main()