feat(align): automatic alignment and fit quality

Coarse (10 Hz) DTW per stretch between manual anchors, refined at 50 Hz in a
band that widens 0.5 -> 1 -> 2 s on unreachable end cells (coarse path as last
resort); one auto anchor per bar from median offsets of diagonal path steps.
Fit quality from raw-audio onset peaks vs warped scored-role MIDI onsets
(+-70 ms, 30 s windows, flagged on rate < 0.5 or median > 60 ms).
trackKeys moves to src/align/roles.ts (pure) and is re-exported by manifest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-30 06:52:10 +01:00
1 parent 41e38cb03f
commit 8cad85e103
6 files changed
+497 -11

No files matched your search

+57
View File
@@ -0,0 +1,57 @@
// Fit quality of an alignment: how well warped MIDI onsets land on onsets picked from the raw recording.
// Pure: no I/O. Recording onsets must come from the audio itself (onset flux), never from transcription.
export interface FitWindow { startSec: number; matchRate: number | null; medianResidualMs: number | null; flagged: boolean }
export interface FitReport { matchRate: number | null; medianResidualMs: number | null; windows: FitWindow[] }
export const MATCH_TOL_SEC = 0.07;
export const WINDOW_SEC = 30;
const FLAG_MIN_RATE = 0.5;
const FLAG_MAX_RESIDUAL_MS = 60;
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;
}
/** |residual| in seconds to the nearest recording onset, or null when none lies within ±70 ms. */
function nearest(sorted: number[], t: number): number | null {
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;
}
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 };
}
/**
* 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.
*/
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 };
}