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:
1 parent
41e38cb03f
commit
8cad85e103
6 files changed
+497
-11
No files matched your search
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user