#!/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(" 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(" 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()