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]>
187 lines
10 KiB
TypeScript
187 lines
10 KiB
TypeScript
// 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}`;
|
||
}
|