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 Winther
2026-10-30 19:50:48 +01:00
co-authored by Claude Opus 5.5
parent 3b002059e9
commit 3db87c0317
19 changed files with 1016 additions and 252 deletions
+17 -8
View File
@@ -108,7 +108,11 @@ export function features(audio: Audio, rate: number): Features {
return { rate, onset, chroma };
}
/** Peak-pick an onset-strength curve (adaptive threshold median + k·MAD, 50 ms minimum gap). Times in seconds. */
/**
* Peak-pick an onset-strength curve (adaptive threshold median + k·MAD, 50 ms minimum gap, the stronger peak wins).
* Peak times are refined to sub-frame precision by parabolic interpolation over the peak and its two neighbours.
* Times in seconds.
*/
export function pickOnsets(onset: Float32Array, rate: number, k = 4): number[] {
const n = onset.length;
if (n === 0) return [];
@@ -118,15 +122,20 @@ export function pickOnsets(onset: Float32Array, rate: number, k = 4): number[] {
const mad = dev[n >> 1]! * 1.4826;
const thr = med + k * Math.max(mad, 1e-3);
const minGap = 0.05;
const picked: number[] = [];
const frames: number[] = [];
for (let i = 0; i < n; i++) {
const v = onset[i]!;
if (v <= thr || v < (onset[i - 1] ?? -Infinity) || v <= (onset[i + 1] ?? -Infinity)) continue;
const t = i / rate;
const last = picked.length - 1;
if (last >= 0 && t - picked[last]! < minGap - 1e-9) {
if (v > onset[Math.round(picked[last]! * rate)]!) picked[last] = t;
} else picked.push(t);
const last = frames.length - 1;
if (last >= 0 && (i - frames[last]!) / rate < minGap - 1e-9) {
if (v > onset[frames[last]!]!) frames[last] = i;
} else frames.push(i);
}
return picked;
return frames.map((i) => {
if (i === 0 || i === n - 1) return i / rate;
const a = onset[i - 1]!, b = onset[i]!, c = onset[i + 1]!;
const den = a - 2 * b + c;
const d = den < 0 ? Math.max(-0.5, Math.min(0.5, (0.5 * (a - c)) / den)) : 0;
return (i + d) / rate;
});
}