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 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-30 19:50:48 +01:00
1 parent 3b002059e9
commit 3db87c0317
19 files changed
+1016 -252

No files matched your search

+157 -38
View File
@@ -1,13 +1,58 @@
// Fit quality of an alignment: how well warped MIDI onsets land on onsets picked from the raw recording.
// 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).
export interface FitWindow { startSec: number; matchRate: number | null; medianResidualMs: number | null; flagged: boolean }
export interface FitReport { matchRate: number | null; medianResidualMs: number | null; windows: FitWindow[] }
/** 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;
const FLAG_MIN_RATE = 0.5;
const FLAG_MAX_RESIDUAL_MS = 60;
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 {
@@ -16,52 +61,126 @@ function median(xs: number[]): number | null {
return s.length % 2 ? s[m]! : (s[m - 1]! + s[m]!) / 2;
}
/** |residual| in seconds to the nearest recording onset, or null when none lies within ±70 ms. */
function nearest(sorted: number[], t: number): number | null {
/** |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 <= MATCH_TOL_SEC + 1e-9 ? best : null;
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;
}
function summarise(residuals: (number | null)[]): { matchRate: number | null; medianResidualMs: number | null } {
if (!residuals.length) return { matchRate: null, medianResidualMs: null };
const hits = residuals.filter((r): r is number => r !== null);
const med = median(hits);
return { matchRate: hits.length / residuals.length, medianResidualMs: med === null ? null : med * 1000 };
/** 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 } : {}) };
}
/**
* Match each unique MIDI onset (already warped to audio time) to the nearest recording onset within ±70 ms
* (non-exclusive). Reports match rate and median |residual| overall and per 30 s window (by MIDI onset time).
* A window is flagged when its match rate < 0.5 or its median residual > 60 ms; empty windows are null, unflagged.
* 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 fitQuality(audioOnsets: number[], midiOnsets: number[], durSec: number): FitReport {
const audio = [...audioOnsets].sort((a, b) => a - b);
const midi: number[] = [];
for (const t of [...midiOnsets].sort((a, b) => a - b)) if (!midi.length || t - midi[midi.length - 1]! > UNIQUE_EPS) midi.push(t);
const res = midi.map((t) => nearest(audio, t));
const lastOnset = midi.length ? midi[midi.length - 1]! : 0;
const nWin = Math.max(1, Math.ceil(Math.max(durSec, lastOnset + 1e-9) / WINDOW_SEC));
const buckets: (number | null)[][] = Array.from({ length: nWin }, () => []);
midi.forEach((t, k) => buckets[Math.min(nWin - 1, Math.max(0, Math.floor(t / WINDOW_SEC)))]!.push(res[k]!));
const windows = buckets.map((b, w): FitWindow => {
const s = summarise(b);
const flagged = s.matchRate !== null && (s.matchRate < FLAG_MIN_RATE || (s.medianResidualMs ?? 0) > FLAG_MAX_RESIDUAL_MS);
return { startSec: w * WINDOW_SEC, ...s, flagged };
});
return { ...summarise(res), windows };
}
/** One-line summary, e.g. "82.3% of MIDI onsets matched · median residual 14.2 ms · 2/9 windows flagged (0:30, 2:00)". */
export function fitSummary(f: FitReport): string {
if (f.matchRate === null) return "no scored MIDI onsets";
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 list = flagged.length ? ` (${flagged.map((w) => mmss(w.startSec)).join(", ")})` : "";
return `${(f.matchRate * 100).toFixed(1)}% of MIDI onsets matched · median residual ${med} · ${flagged.length}/${f.windows.length} windows flagged${list}`;
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}`;
}