Files
STSheets/src/align/fit.ts
T
Lucas WintherandClaude Opus 5.5 3db87c0317 fix(align): calibrated fit (precision, lift, beat-shift check, baseline) and aligner guards (A7, A8)
Fit flags on precision (<0.6) and chance-corrected lift (<0.25), null with <8
recording onsets; old match rate kept as audibleRate (info). Symbolic-chroma
beat/bar-shift check per window. Constant-offset baseline (-2..+2 s) replaces
DTW without manual anchors unless DTW precision is better by >=0.02. Stretch
warning counts in the fit summary. DTW: slope-limited steps, open begin/end
options; per-bar outlier rejection (1/4 beat, Theil-Sen-transported +-3 bar
median); no anchor in the final partial bar. bench real: saved/computed
source, baseline + method, corrupt alignment.json skipped. Parabolic onset
interpolation; warp extrapolation documented; spec amended.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 19:50:48 +01:00

187 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Fit quality of an alignment: how well onsets picked from the raw recording land on the warped MIDI onsets.
// Pure: no I/O. Recording onsets must come from the audio itself (onset flux), never from transcription.
//
// Calibrated on real mixes (ruling A7, spec "Fit quality", amended 2026-10-30): the onset picker finds ~1.4 attacks/s
// in a full mix while a scored MIDI has ~13 unique onsets/s, so the share of MIDI onsets that are heard ("audible
// rate", the old match rate) is capped near 40% even when perfectly aligned and carries no information. The flag
// metric is therefore *precision*: the share of recording onsets with a warped MIDI onset within ±70 ms. Because a
// dense score matches almost anything, precision is chance-corrected by the precision at off-grid shifts (lift), and a
// symbolic chroma check catches whole-beat / whole-bar shifts that onset metrics cannot see (they alias onto the grid).
/** A beat/bar shift that explains the recording's chroma better than the current alignment. */
export interface BeatShift { label: string; gain: number }
export interface FitWindow {
startSec: number;
/** Recording onsets in this window (by recording time). */
recOnsets: number;
/** Share of this window's recording onsets with a warped MIDI onset within ±70 ms; null with < 8 recording onsets. */
precision: number | null;
/** precision − mean precision at the off-grid shifts (chance-corrected); null when precision is null. */
lift: number | null;
/** Information only (JSON `audibleRate`): share of unique MIDI onsets (in this window by warped time) heard as attacks. */
audibleRate: number | null;
/** Median |recording onset − nearest warped MIDI onset| over matched recording onsets (ms). */
medianResidualMs: number | null;
beatShift: BeatShift | null;
flagged: boolean;
/** Why the window is flagged, e.g. "precision 41%", "lift 0.12", "beat shift suspected: +1 beat". */
reasons: string[];
}
export interface StretchCounts { total: number; warned: number; over15: number }
export interface FitReport {
precision: number | null;
lift: number | null;
/** Information only: share of unique MIDI onsets heard as attacks ("MIDI onsets audible as attacks"). */
audibleRate: number | null;
medianResidualMs: number | null;
windows: FitWindow[];
/** Stretches of the alignment: total, warned (> 5% tempo change) and with |tempo change| > 15%. */
stretches?: StretchCounts;
}
/** Symbolic-chroma inputs for the beat/bar-shift check: frames at `rate` Hz in recording time, 12-d (any scale). */
export interface ShiftInputs { rate: number; recording: Float32Array[]; current: Float32Array[]; shifts: { label: string; chroma: Float32Array[] }[] }
export const MATCH_TOL_SEC = 0.07;
export const WINDOW_SEC = 30;
export const FLAG_MIN_PRECISION = 0.6;
export const FLAG_MIN_LIFT = 0.25;
export const MIN_REC_ONSETS = 8;
/** Off-grid shifts (s) of the warped MIDI used as the chance level of precision. */
export const CHANCE_SHIFTS_SEC = [0.11, 0.23, 0.37];
/** A shifted alignment must beat the current one by this much mean chroma cosine to flag the window. */
export const SHIFT_MARGIN = 0.05;
/** The current symbolic chroma must cover at least this share of the window's sounding frames for the check to run. */
const SHIFT_MIN_COVER = 0.25;
const UNIQUE_EPS = 1e-6;
function median(xs: number[]): number | null {
if (!xs.length) return null;
const s = [...xs].sort((a, b) => a - b), m = s.length >> 1;
return s.length % 2 ? s[m]! : (s[m - 1]! + s[m]!) / 2;
}
/** |distance| from `t` to the nearest value in `sorted` (Infinity when empty). */
function nearestDist(sorted: number[], t: number): number {
let lo = 0, hi = sorted.length;
while (lo < hi) { const m = (lo + hi) >> 1; if (sorted[m]! < t) lo = m + 1; else hi = m; }
let best = Infinity;
if (lo < sorted.length) best = Math.min(best, Math.abs(sorted[lo]! - t));
if (lo > 0) best = Math.min(best, Math.abs(sorted[lo - 1]! - t));
return best;
}
const hit = (d: number) => d <= MATCH_TOL_SEC + 1e-9;
/** Sorted, de-duplicated copy. */
export function uniqueSorted(xs: number[]): number[] {
const out: number[] = [];
for (const t of [...xs].sort((a, b) => a - b)) if (!out.length || t - out[out.length - 1]! > UNIQUE_EPS) out.push(t);
return out;
}
/** Share of `rec` (any order) with a value of sorted `midi` + `shift` within ±70 ms; null when `rec` or `midi` is empty. */
export function precisionOf(rec: number[], midi: number[], shift = 0): number | null {
if (!rec.length || !midi.length) return null;
let n = 0;
for (const t of rec) if (hit(nearestDist(midi, t - shift))) n++;
return n / rec.length;
}
function cos(a: Float32Array, b: Float32Array): number {
let d = 0, na = 0, nb = 0;
for (let q = 0; q < 12; q++) { d += a[q]! * b[q]!; na += a[q]! * a[q]!; nb += b[q]! * b[q]!; }
return na > 1e-12 && nb > 1e-12 ? d / Math.sqrt(na * nb) : 0;
}
/** Mean chroma cosine over the sounding recording frames of [f0, f1); null when the candidate covers too little. */
export function meanCosine(rec: Float32Array[], cand: Float32Array[], f0: number, f1: number, minCover = SHIFT_MIN_COVER): number | null {
let sum = 0, sounding = 0, covered = 0;
for (let t = f0; t < Math.min(f1, rec.length); t++) {
const r = rec[t]!;
if (!r.some((v) => v !== 0)) continue;
sounding++;
const c = cand[t];
if (!c || !c.some((v) => v !== 0)) continue;
covered++;
sum += cos(r, c);
}
if (!sounding || covered / sounding < minCover) return null;
return sum / sounding;
}
function shiftCheck(s: ShiftInputs, f0: number, f1: number): BeatShift | null {
const cur = meanCosine(s.recording, s.current, f0, f1);
if (cur === null) return null;
let best: BeatShift | null = null;
for (const c of s.shifts) {
const v = meanCosine(s.recording, c.chroma, f0, f1, 0);
if (v !== null && v - cur > SHIFT_MARGIN && (!best || v - cur > best.gain)) best = { label: c.label, gain: v - cur };
}
return best;
}
const pct = (x: number) => `${Math.round(x * 100)}%`;
/**
* Recording onsets are matched to the nearest warped MIDI onset within ±70 ms (non-exclusive). Overall and per 30 s
* window (by recording onset time): precision, lift (precision − mean precision with the MIDI shifted by +0.11, +0.23,
* +0.37 s), median residual; plus the audible rate (info) and, with `extra.shift`, the beat/bar-shift chroma check.
* A window with < 8 recording onsets is null and unflagged; otherwise it is flagged when precision < 0.6, lift < 0.25,
* or a shifted alignment beats the current one by > 0.05 mean chroma cosine.
*/
export function fitQuality(audioOnsets: number[], midiOnsets: number[], durSec: number, extra: { shift?: ShiftInputs; stretches?: StretchCounts } = {}): FitReport {
const rec = [...audioOnsets].sort((a, b) => a - b);
const midi = uniqueSorted(midiOnsets);
const nWin = Math.max(1, Math.ceil(Math.max(durSec, (rec[rec.length - 1] ?? 0) + 1e-9) / WINDOW_SEC));
const winOf = (t: number) => Math.min(nWin - 1, Math.max(0, Math.floor(t / WINDOW_SEC)));
const recWin: number[][] = Array.from({ length: nWin }, () => []);
for (const t of rec) recWin[winOf(t)]!.push(t);
const midiHit = midi.map((t) => hit(nearestDist(rec, t)));
const stats = (r: number[]) => {
const precision = midi.length ? precisionOf(r, midi) : null;
if (precision === null) return { precision: null, lift: null, medianResidualMs: null };
const chance = CHANCE_SHIFTS_SEC.map((s) => precisionOf(r, midi, s)!);
const dists = r.map((t) => nearestDist(midi, t)).filter(hit);
const med = median(dists);
return { precision, lift: precision - chance.reduce((a, b) => a + b, 0) / chance.length, medianResidualMs: med === null ? null : med * 1000 };
};
const audible = (lo: number, hi: number) => {
let n = 0, h = 0;
midi.forEach((t, k) => { if (t >= lo && t < hi) { n++; if (midiHit[k]) h++; } });
return n ? h / n : null;
};
const windows = recWin.map((r, w): FitWindow => {
const startSec = w * WINDOW_SEC;
const lo = w === 0 ? -Infinity : startSec, hi = w === nWin - 1 ? Infinity : startSec + WINDOW_SEC;
const s = r.length >= MIN_REC_ONSETS ? stats(r) : { precision: null, lift: null, medianResidualMs: null };
const beatShift = extra.shift && s.precision !== null
? shiftCheck(extra.shift, Math.round(startSec * extra.shift.rate), w === nWin - 1 ? Infinity : Math.round((startSec + WINDOW_SEC) * extra.shift.rate))
: null;
const reasons: string[] = [];
if (s.precision !== null && s.precision < FLAG_MIN_PRECISION) reasons.push(`precision ${pct(s.precision)}`);
if (s.lift !== null && s.lift < FLAG_MIN_LIFT) reasons.push(`lift ${s.lift.toFixed(2)}`);
if (beatShift) reasons.push(`beat shift suspected: ${beatShift.label}`);
return { startSec, recOnsets: r.length, ...s, audibleRate: audible(lo, hi), beatShift, flagged: reasons.length > 0, reasons };
});
const all = rec.length && midi.length ? stats(rec) : { precision: null, lift: null, medianResidualMs: null };
return { ...all, audibleRate: audible(-Infinity, Infinity), windows, ...(extra.stretches ? { stretches: extra.stretches } : {}) };
}
/**
* One line, e.g. "precision 91.2% (lift +0.70) · MIDI onsets audible as attacks 38.1% · median residual 14.2 ms ·
* 1/8 windows flagged (0:30 beat shift suspected: +1 beat) · stretches: 3/40 warned, 1 > 15%".
*/
export function fitSummary(f: FitReport): string {
if (f.precision === null) return f.audibleRate === null ? "no scored MIDI onsets" : "no recording onsets";
const mmss = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
const flagged = f.windows.filter((w) => w.flagged);
const med = f.medianResidualMs === null ? "n/a" : `${f.medianResidualMs.toFixed(1)} ms`;
const lift = f.lift === null ? "" : ` (lift ${f.lift >= 0 ? "+" : "−"}${Math.abs(f.lift).toFixed(2)})`;
const aud = f.audibleRate === null ? "n/a" : `${(f.audibleRate * 100).toFixed(1)}%`;
const list = flagged.length ? ` (${flagged.map((w) => `${mmss(w.startSec)} ${w.reasons.join(", ")}`).join("; ")})` : "";
const st = f.stretches ? ` · stretches: ${f.stretches.warned}/${f.stretches.total} warned, ${f.stretches.over15} > 15%` : "";
return `precision ${(f.precision * 100).toFixed(1)}%${lift} · MIDI onsets audible as attacks ${aud} · median residual ${med} · ${flagged.length}/${f.windows.length} windows flagged${list}${st}`;
}