// 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}`; }