Files
STSheets/tests/align/auto.test.ts
Lucas WintherandClaude Opus 5.5 3db87c0317 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 <[email protected]>
2026-10-30 19:50:48 +01:00

290 lines
19 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, expect, beforeAll } from "bun:test";
import { alignmentFit, autoAlign, bandedWithRetry, barTimes, beatTimes, constantOffsetBaseline, fitFromCues, meterGrid, recordingCues, rejectOffsetOutliers } from "../../src/align/auto.ts";
import { makeWarp, type Anchor, type Warp } from "../../src/align/warp.ts";
import type { RefTrack, Smf } from "../../src/align/smf.ts";
import type { Audio, Role, TruthTrack } from "../../src/core/types.ts";
import { buildSong, loadSongDir } from "../../src/bench/dataset.ts";
const SF2 = new Uint8Array(await Bun.file("node_modules/@coderline/alphatab/dist/soundfont/sonivox.sf2").arrayBuffer());
const BIG = 60_000;
let song: Awaited<ReturnType<typeof loadSongDir>>;
beforeAll(async () => { song = await loadSongDir(await buildSong("t1c", 1)); }, BIG);
/** In-memory Smf from truth tracks: `map` turns truth (= recording) time into MIDI time; `reps` tiles the song. */
function smfFrom(truth: TruthTrack[], programs: number[], bpm: number, map: (t: number) => number, reps = 1, period = 0): Smf {
const tracks: RefTrack[] = truth.map((t, i) => ({
index: i + 1, name: t.name, program: programs[i]!, isDrums: t.role === "drums",
notes: Array.from({ length: reps }, (_, r) => t.notes.map((n) => ({
onset: map(n.onset + r * period), offset: map(n.offset + r * period), pitch: n.pitch, velocity: n.velocity ?? 96,
channel: t.role === "drums" ? 9 : i,
}))).flat(),
}));
const durationSec = Math.max(...tracks.flatMap((t) => t.notes.map((n) => n.offset)));
return { durationSec, tracks, tempoMap: [{ sec: 0, bpm }], timeSigs: [{ sec: 0, num: 4, den: 4 }] };
}
const rolesOf = (truth: TruthTrack[]): Record<string, Role | null> => Object.fromEntries(truth.map((t) => [t.name, t.role]));
const durOf = (a: Audio) => a.channels[0]!.length / a.sampleRate;
const median = (xs: number[]) => { const s = [...xs].sort((a, b) => a - b); return s[s.length >> 1]!; };
const p90 = (xs: number[]) => { const s = [...xs].sort((a, b) => a - b); return s[Math.floor(s.length * 0.9)]!; };
const debug = (msg: string) => { if (process.env.ALIGN_DEBUG) console.log(msg); };
const strictlyMonotone = (A: Anchor[]) => A.every((a, k) => k === 0 || (a.midiSec > A[k - 1]!.midiSec && a.audioSec > A[k - 1]!.audioSec));
/** Known distortion recording time -> MIDI time: +0.4 s, then bars 4–10 at ×1.06 tempo, then ≈ −0.8 s (continuous). */
function distortion(bpm: number): Warp {
const bar = (4 * 60) / bpm, b1 = 4 * bar, b2 = 10 * bar;
const m2 = b1 + 0.4 + (b2 - b1) / 1.06;
return makeWarp([
{ midiSec: 0, audioSec: 0.4, kind: "manual" },
{ midiSec: b1, audioSec: b1 + 0.4, kind: "manual" },
{ midiSec: b2, audioSec: m2, kind: "manual" },
{ midiSec: b2 + 100, audioSec: m2 + 100, kind: "manual" },
]); // toAudio: recording -> MIDI, toMidi: MIDI -> recording (the truth the aligner must recover)
}
function recoveryErrors(smf: Smf, got: Anchor[], trueMidiToAudio: (m: number) => number, audioDur: number): number[] {
const w = makeWarp(got);
const onsets = [...new Set(smf.tracks.flatMap((t) => t.notes.map((n) => n.onset)))];
return onsets.filter((m) => { const a = trueMidiToAudio(m); return a >= 0 && a <= audioDur; })
.map((m) => Math.abs(w.toAudio(m) - trueMidiToAudio(m)));
}
test("recovers a known piecewise warp: median error < 40 ms, fit precision ≥ 0.8, DTW beats the constant baseline", () => {
const D = distortion(song.song.bpm);
const smf = smfFrom(song.truth.tracks, song.song.tracks.map((t) => t.program), song.song.bpm, D.toAudio);
const t0 = performance.now();
const { anchors, fit, baseline, chosen } = autoAlign(song.mix, smf, rolesOf(song.truth.tracks), [], SF2);
const ms = performance.now() - t0;
const err = recoveryErrors(smf, anchors, D.toMidi, durOf(song.mix));
debug(`synthetic warp: ${anchors.length} anchors, median err ${(median(err) * 1000).toFixed(1)} ms, p90 ${(p90(err) * 1000).toFixed(1)} ms, fit ${fit.precision?.toFixed(3)} lift ${fit.lift?.toFixed(3)} / ${fit.medianResidualMs?.toFixed(1)} ms, baseline ${baseline.offsetSec} s ${baseline.precision?.toFixed(3)}, ${ms.toFixed(0)} ms`);
expect(chosen).toBe("dtw");
expect(anchors.every((a) => a.kind === "auto")).toBe(true);
expect(median(err)).toBeLessThan(0.04);
expect(p90(err)).toBeLessThan(0.08);
expect(fit.precision!).toBeGreaterThanOrEqual(0.8);
expect(fit.precision!).toBeGreaterThanOrEqual(baseline.precision! + 0.02);
expect(fit.windows.some((w) => w.flagged)).toBe(false);
expect(fit.stretches!.total).toBe(anchors.length + 1);
expect(fit.windows.length).toBe(2);
expect(anchors.every((a) => a.audioSec > 0 || a.midiSec === 0)).toBe(true);
}, BIG);
test("a correct manual anchor mid-song is honoured exactly", () => {
const D = distortion(song.song.bpm);
const smf = smfFrom(song.truth.tracks, song.song.tracks.map((t) => t.program), song.song.bpm, D.toAudio);
const manual: Anchor = { midiSec: D.toAudio(30), audioSec: 30, kind: "manual" };
const { anchors } = autoAlign(song.mix, smf, rolesOf(song.truth.tracks), [manual], SF2);
expect(anchors.filter((a) => a.kind === "manual")).toEqual([manual]);
expect(makeWarp(anchors).toAudio(manual.midiSec)).toBe(30);
// no auto anchor within 1 s (MIDI time) of the manual one
expect(anchors.some((a) => a.kind === "auto" && Math.abs(a.midiSec - manual.midiSec) < 1)).toBe(false);
expect(anchors.length).toBeGreaterThan(5);
const err = recoveryErrors(smf, anchors, D.toMidi, durOf(song.mix));
expect(median(err)).toBeLessThan(0.04);
}, BIG);
test("a silent intro in the recording does not drag the path", () => {
const pad = 4 * song.mix.sampleRate;
const mix: Audio = { sampleRate: song.mix.sampleRate, channels: song.mix.channels.map((c) => { const o = new Float32Array(c.length + pad); o.set(c, pad); return o; }) };
const smf = smfFrom(song.truth.tracks, song.song.tracks.map((t) => t.program), song.song.bpm, (t) => t);
const { anchors, fit } = autoAlign(mix, smf, rolesOf(song.truth.tracks), [], SF2);
const err = recoveryErrors(smf, anchors, (m) => m + 4, durOf(mix));
expect(median(err)).toBeLessThan(0.04);
expect(fit.precision!).toBeGreaterThanOrEqual(0.8);
}, BIG);
test("5-minute input aligns in < 15 s", () => {
const reps = 5, period = durOf(song.mix);
const mix: Audio = { sampleRate: song.mix.sampleRate, channels: song.mix.channels.map((c) => { const o = new Float32Array(c.length * reps); for (let r = 0; r < reps; r++) o.set(c, r * c.length); return o; }) };
const smf = smfFrom(song.truth.tracks, song.song.tracks.map((t) => t.program), song.song.bpm, (t) => t + 0.3, reps, period);
const t0 = performance.now();
const { anchors, fit, baseline, chosen } = autoAlign(mix, smf, rolesOf(song.truth.tracks), [], SF2);
const sec = (performance.now() - t0) / 1000;
debug(`5-min (${durOf(mix).toFixed(0)} s) input: ${sec.toFixed(2)} s, ${anchors.length} anchors, fit ${fit.precision?.toFixed(3)}, ${chosen}, baseline ${baseline.offsetSec} ${baseline.precision?.toFixed(3)}`);
expect(durOf(mix)).toBeGreaterThan(290);
expect(sec).toBeLessThan(15);
expect(fit.windows.length).toBe(10);
// a constant offset: DTW cannot beat the baseline by 0.02, so the single-anchor baseline is returned
expect(baseline.offsetSec).toBeCloseTo(-0.3, 1);
expect(chosen).toBe("baseline");
expect(anchors).toHaveLength(1);
expect(anchors[0]!.kind).toBe("auto");
expect(anchors[0]!.audioSec - anchors[0]!.midiSec).toBeCloseTo(baseline.offsetSec, 6);
}, BIG);
test("barTimes follows tempo and time-signature changes", () => {
const smf: Smf = {
durationSec: 10, tracks: [],
tempoMap: [{ sec: 0, bpm: 120 }, { sec: 4, bpm: 60 }],
timeSigs: [{ sec: 0, num: 4, den: 4 }, { sec: 4, num: 3, den: 4 }],
};
// 4/4 @120: bars of 2 s at 0, 2; then 3/4 @60: bars of 3 s at 4, 7
expect(barTimes(smf)).toEqual([0, 2, 4, 7]);
expect(barTimes({ durationSec: 3, tracks: [], tempoMap: [{ sec: 0, bpm: 120 }], timeSigs: [] })).toEqual([0, 2]); // default 4/4
expect(barTimes({ durationSec: 4, tracks: [], tempoMap: [{ sec: 0, bpm: 120 }], timeSigs: [{ sec: 0, num: 6, den: 8 }] })).toEqual([0, 1.5, 3]);
});
test("fine pass widens the band when the end cell is unreachable, then gives up with null", () => {
const cost = (i: number, j: number) => Math.abs(i - j);
// centre jumps by 120 frames mid-way: ±25 and ±50 leave a gap between rows, ±100 bridges it
const jump = (i: number) => (i < 200 ? i : i + 120);
const p = bandedWithRetry(cost, 400, 600, jump);
expect(p).not.toBeNull();
expect(p![p!.length - 1]).toEqual([399, 599]);
// a 400-frame jump defeats even ±2 s (±100 frames)
expect(bandedWithRetry(cost, 400, 1000, (i) => (i < 200 ? i : i + 400))).toBeNull();
});
test("fit exposes a bad alignment instead of hiding it", () => {
const D = distortion(song.song.bpm);
const smf = smfFrom(song.truth.tracks, song.song.tracks.map((t) => t.program), song.song.bpm, D.toAudio);
const roles = rolesOf(song.truth.tracks);
const bad = alignmentFit(song.mix, smf, roles, []); // identity: off by 0.4–0.8 s throughout
expect(bad.precision!).toBeLessThan(0.6);
// the −0.8 s section is exposed; the +0.4 s section is not, as 0.4 s is ≈ 40 ms from two 16ths at 68 bpm (grid alias)
expect(bad.windows[1]!.flagged).toBe(true);
const good = alignmentFit(song.mix, smf, roles, autoAlign(song.mix, smf, roles, [], SF2).anchors);
expect(good.windows.some((w) => w.flagged)).toBe(false);
// unmapped tracks are excluded: with no scored roles there is nothing to measure
expect(alignmentFit(song.mix, smf, Object.fromEntries(Object.keys(roles).map((k) => [k, null])), []).precision).toBeNull();
}, BIG);
test("a deliberately wrong manual anchor (+0.3 s) is kept exactly and the result stays monotonic", () => {
const D = distortion(song.song.bpm);
const smf = smfFrom(song.truth.tracks, song.song.tracks.map((t) => t.program), song.song.bpm, D.toAudio);
const manual: Anchor = { midiSec: D.toAudio(30), audioSec: 30.3, kind: "manual" };
const { anchors } = autoAlign(song.mix, smf, rolesOf(song.truth.tracks), [manual], SF2);
expect(anchors.filter((a) => a.kind === "manual")).toEqual([manual]);
expect(makeWarp(anchors).toAudio(manual.midiSec)).toBe(30.3);
expect(strictlyMonotone(anchors)).toBe(true);
expect(anchors.every((a) => a.audioSec > 0 || a.midiSec === 0)).toBe(true);
// away from the bad anchor (> 4 s), the autos still follow the truth
const far = anchors.filter((a) => a.kind === "auto" && Math.abs(a.audioSec - 30) > 4);
expect(far.length).toBeGreaterThan(5);
expect(median(far.map((a) => Math.abs(a.audioSec - D.toMidi(a.midiSec))))).toBeLessThan(0.04);
}, BIG);
test("when every fine band fails, the coarse-path fallback still yields auto anchors", () => {
const D = distortion(song.song.bpm);
const smf = smfFrom(song.truth.tracks, song.song.tracks.map((t) => t.program), song.song.bpm, D.toAudio);
const manual: Anchor = { midiSec: D.toAudio(30), audioSec: 30, kind: "manual" };
const { anchors, fit, chosen } = autoAlign(song.mix, smf, rolesOf(song.truth.tracks), [manual], SF2, { fineBandsSec: [] });
expect(chosen).toBe("dtw"); // manual anchors present: never substituted by the baseline
const autos = anchors.filter((a) => a.kind === "auto");
// both segments (before and after the manual anchor) get anchors
expect(autos.filter((a) => a.audioSec < 30).length).toBeGreaterThan(3);
expect(autos.filter((a) => a.audioSec > 30).length).toBeGreaterThan(3);
expect(strictlyMonotone(anchors)).toBe(true);
const err = recoveryErrors(smf, anchors, D.toMidi, durOf(song.mix));
debug(`coarse fallback: ${autos.length} autos, median err ${(median(err) * 1000).toFixed(1)} ms, p90 ${(p90(err) * 1000).toFixed(1)} ms, fit ${fit.precision?.toFixed(3)}`);
expect(median(err)).toBeLessThan(0.06); // 10 Hz resolution: coarser than the fine pass, still hand-correctable
expect(p90(err)).toBeLessThan(0.12);
}, BIG);
test("fitFromCues with cached recordingCues equals alignmentFit", () => {
const smf = smfFrom(song.truth.tracks, song.song.tracks.map((t) => t.program), song.song.bpm, (t) => t);
const roles = rolesOf(song.truth.tracks);
const anchors: Anchor[] = [{ midiSec: 1, audioSec: 1.05, kind: "manual" }, { midiSec: 20, audioSec: 20.1, kind: "manual" }];
const cues = recordingCues(song.mix);
expect(cues.onsets.length).toBeGreaterThan(10);
expect(cues.chroma.length).toBeGreaterThan(cues.durSec * cues.chromaRate - 2);
expect(fitFromCues(cues, smf, roles, anchors)).toEqual(alignmentFit(song.mix, smf, roles, anchors));
});
// ---- ruling A7: calibrated fit, beat-shift check, constant-offset baseline ----
test("fit precision: correct alignment ≥ 0.8, a 0.12 s off-grid shift < 0.5 (and flagged)", () => {
const smf = smfFrom(song.truth.tracks, song.song.tracks.map((t) => t.program), song.song.bpm, (t) => t);
const roles = rolesOf(song.truth.tracks);
const good = alignmentFit(song.mix, smf, roles, []);
const off = alignmentFit(song.mix, smf, roles, [{ midiSec: 10, audioSec: 10.12, kind: "manual" }]);
debug(`precision: correct ${good.precision?.toFixed(3)} (lift ${good.lift?.toFixed(3)}, audible ${good.audibleRate?.toFixed(3)}), +0.12 s ${off.precision?.toFixed(3)} (lift ${off.lift?.toFixed(3)})`);
expect(good.precision!).toBeGreaterThanOrEqual(0.8);
expect(good.lift!).toBeGreaterThanOrEqual(0.25);
expect(good.windows.some((w) => w.flagged)).toBe(false);
expect(off.precision!).toBeLessThan(0.5);
expect(off.windows.filter((w) => w.precision !== null).every((w) => w.flagged)).toBe(true);
});
test("beat-shift check flags an alignment that is one beat late, though onset precision cannot see it", () => {
const smf = smfFrom(song.truth.tracks, song.song.tracks.map((t) => t.program), song.song.bpm, (t) => t);
const roles = rolesOf(song.truth.tracks);
const beat = 60 / song.song.bpm;
const late = alignmentFit(song.mix, smf, roles, [{ midiSec: 10, audioSec: 10 + beat, kind: "manual" }]);
const good = alignmentFit(song.mix, smf, roles, []);
debug(`beat shift: late precision ${late.precision?.toFixed(3)}, windows ${JSON.stringify(late.windows.map((w) => [w.precision?.toFixed(2), w.lift?.toFixed(2), w.beatShift]))}`);
expect(good.windows.some((w) => w.beatShift)).toBe(false);
const shifted = late.windows.filter((w) => w.beatShift);
expect(shifted.length).toBeGreaterThan(0);
expect(shifted.every((w) => w.flagged && w.reasons.some((r) => r.startsWith("beat shift suspected: −")))).toBe(true);
});
test("constantOffsetBaseline grid-searches −2..+2 s for the offset with the best precision", () => {
const smf = smfFrom(song.truth.tracks, song.song.tracks.map((t) => t.program), song.song.bpm, (t) => t + 0.27);
const b = constantOffsetBaseline(recordingCues(song.mix), smf, rolesOf(song.truth.tracks));
expect(b.offsetSec).toBeCloseTo(-0.27, 1);
expect(Math.round(b.offsetSec * 100)).toBe(b.offsetSec * 100); // 10 ms grid
expect(b.precision!).toBeGreaterThanOrEqual(0.8);
});
// ---- ruling A8: aligner guards ----
test("rejectOffsetOutliers drops bars > ¼ beat from the robust median of ±3 neighbours (slope-transported), iterating until stable", () => {
const bars = Array.from({ length: 20 }, (_, k) => ({ midiSec: k * 2, off: 0.1 + k * 0.004, beatSec: 0.5 }));
bars[5]!.off = 0.6; // a beat out
bars[6]!.off = 0.55; // its neighbour, also out
bars[15]!.off = -0.3;
const kept = rejectOffsetOutliers(bars).map((b) => b.midiSec / 2);
expect(kept).toEqual(Array.from({ length: 20 }, (_, k) => k).filter((k) => k !== 5 && k !== 6 && k !== 15));
// a smooth ramp (genuine tempo change) is kept whole
const ramp = Array.from({ length: 20 }, (_, k) => ({ midiSec: k * 2, off: k * 0.05, beatSec: 0.5 }));
expect(rejectOffsetOutliers(ramp)).toHaveLength(20);
// ... also a steep one (10% tempo difference: 0.2 s per 2 s bar), whose ends a plain one-sided median would erode
// bar by bar; neighbours are transported along their median (Theil–Sen) slope
const steep = Array.from({ length: 20 }, (_, k) => ({ midiSec: k * 2, off: k * 0.2, beatSec: 0.5 }));
expect(rejectOffsetOutliers(steep)).toHaveLength(20);
// a beat jump inside the steep ramp is still rejected
steep[10]!.off += 0.5;
expect(rejectOffsetOutliers(steep).map((b) => b.midiSec / 2)).toEqual(Array.from({ length: 20 }, (_, k) => k).filter((k) => k !== 10));
});
test("meterGrid: beats and bars from tempo/time signatures; the final bar is partial when the MIDI ends mid-bar", () => {
const smf: Smf = { durationSec: 9, tracks: [], tempoMap: [{ sec: 0, bpm: 120 }, { sec: 4, bpm: 60 }], timeSigs: [{ sec: 0, num: 4, den: 4 }, { sec: 4, num: 3, den: 4 }] };
const g = meterGrid(smf);
expect(g.bars).toEqual([0, 2, 4, 7]);
expect(g.beats).toEqual([0, 0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 5, 6, 7, 8, 9]);
expect(g.lastBarPartial).toBe(true); // bar 7..10 ends after 9
expect(meterGrid({ ...smf, durationSec: 10 }).lastBarPartial).toBe(false);
expect(beatTimes({ durationSec: 3, tracks: [], tempoMap: [{ sec: 0, bpm: 120 }], timeSigs: [{ sec: 0, num: 6, den: 8 }] })).toEqual([0, 0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.25, 2.5, 2.75]);
});
test("no auto anchor in the final partial bar", () => {
const D = distortion(song.song.bpm);
const smf = smfFrom(song.truth.tracks, song.song.tracks.map((t) => t.program), song.song.bpm, D.toAudio);
const g = meterGrid(smf);
const last = g.bars[g.bars.length - 1]!;
const cut: Smf = { ...smf, durationSec: last + 0.5 * (last - g.bars[g.bars.length - 2]!) }; // ends half-way into the last bar
expect(meterGrid(cut).lastBarPartial).toBe(true);
const { anchors } = autoAlign(song.mix, cut, rolesOf(song.truth.tracks), [], SF2);
expect(anchors.some((a) => a.midiSec >= last - 1e-9)).toBe(false);
}, BIG);
test("12 s of extra outro in the recording: open-end DTW, no ramp at the end", () => {
const D = distortion(song.song.bpm);
const smf = smfFrom(song.truth.tracks, song.song.tracks.map((t) => t.program), song.song.bpm, D.toAudio);
const sr = song.mix.sampleRate, extra = 12 * sr;
// the outro repeats the first 12 s of the song (material that DTW could be tempted to match)
const mix: Audio = { sampleRate: sr, channels: song.mix.channels.map((c) => { const o = new Float32Array(c.length + extra); o.set(c); o.set(c.subarray(0, extra), c.length); return o; }) };
const { anchors, chosen } = autoAlign(mix, smf, rolesOf(song.truth.tracks), [], SF2);
expect(chosen).toBe("dtw");
const autos = anchors.filter((a) => a.kind === "auto");
const tail = autos.slice(-4).map((a) => a.audioSec - D.toMidi(a.midiSec));
debug(`outro: last anchor offsets vs truth ${tail.map((e) => (e * 1000).toFixed(0)).join(", ")} ms`);
expect(tail.every((e) => Math.abs(e) < 0.06)).toBe(true);
const w = makeWarp(anchors);
const endErr = Math.abs(w.toAudio(smf.durationSec) - D.toMidi(smf.durationSec));
expect(endErr).toBeLessThan(0.1);
const err = recoveryErrors(smf, anchors, D.toMidi, durOf(song.mix));
expect(median(err)).toBeLessThan(0.04);
}, BIG);