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:
1 parent
3b002059e9
commit
3db87c0317
19 files changed
+1016
-252
No files matched your search
+125
-16
@@ -1,5 +1,5 @@
|
||||
import { test, expect, beforeAll } from "bun:test";
|
||||
import { alignmentFit, autoAlign, bandedWithRetry, barTimes, fitFromOnsets, recordingOnsets } from "../../src/align/auto.ts";
|
||||
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";
|
||||
@@ -49,18 +49,22 @@ function recoveryErrors(smf: Smf, got: Anchor[], trueMidiToAudio: (m: number) =>
|
||||
.map((m) => Math.abs(w.toAudio(m) - trueMidiToAudio(m)));
|
||||
}
|
||||
|
||||
test("recovers a known piecewise warp: median error < 40 ms, fit match rate > 0.7", () => {
|
||||
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 } = autoAlign(song.mix, smf, rolesOf(song.truth.tracks), [], SF2);
|
||||
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.matchRate?.toFixed(3)} / ${fit.medianResidualMs?.toFixed(1)} ms, ${ms.toFixed(0)} ms`);
|
||||
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.matchRate!).toBeGreaterThan(0.7);
|
||||
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);
|
||||
@@ -86,7 +90,7 @@ test("a silent intro in the recording does not drag the path", () => {
|
||||
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.matchRate!).toBeGreaterThan(0.7);
|
||||
expect(fit.precision!).toBeGreaterThanOrEqual(0.8);
|
||||
}, BIG);
|
||||
|
||||
test("5-minute input aligns in < 15 s", () => {
|
||||
@@ -94,12 +98,18 @@ test("5-minute input aligns in < 15 s", () => {
|
||||
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 } = autoAlign(mix, smf, rolesOf(song.truth.tracks), [], SF2);
|
||||
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.matchRate?.toFixed(3)}`);
|
||||
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", () => {
|
||||
@@ -130,13 +140,13 @@ test("fit exposes a bad alignment instead of hiding it", () => {
|
||||
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.matchRate!).toBeLessThan(0.5);
|
||||
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])), []).matchRate).toBeNull();
|
||||
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", () => {
|
||||
@@ -158,23 +168,122 @@ test("when every fine band fails, the coarse-path fallback still yields auto anc
|
||||
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 } = autoAlign(song.mix, smf, rolesOf(song.truth.tracks), [manual], SF2, { fineBandsSec: [] });
|
||||
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.matchRate?.toFixed(3)}`);
|
||||
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("fitFromOnsets with cached recordingOnsets equals alignmentFit", () => {
|
||||
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 onsets = recordingOnsets(song.mix);
|
||||
expect(onsets.length).toBeGreaterThan(10);
|
||||
expect(fitFromOnsets(onsets, smf, roles, anchors, durOf(song.mix))).toEqual(alignmentFit(song.mix, smf, roles, anchors));
|
||||
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);
|
||||
@@ -43,3 +43,69 @@ test("3000x3000 unbanded dtw finishes in < 5 s", () => {
|
||||
expect(p[p.length - 1]).toEqual([n - 1, n - 1]);
|
||||
expect(ms).toBeLessThan(5000);
|
||||
});
|
||||
|
||||
// ---- slope-limited steps and open begin/end (ruling A8) ----
|
||||
const stepsOf = (p: [number, number][]) => p.slice(1).map(([i, j], k) => [i - p[k]![0], j - p[k]![1]] as const);
|
||||
/** No two consecutive non-diagonal steps in the same direction: local slope stays within [1/2, 2]. */
|
||||
function slopeLimited(p: [number, number][]): boolean {
|
||||
const s = stepsOf(p);
|
||||
return s.every(([di, dj], k) => {
|
||||
if (!((di === 1 && dj === 1) || (di === 1 && dj === 0) || (di === 0 && dj === 1))) return false;
|
||||
const prev = s[k - 1];
|
||||
return !(k > 0 && di + dj === 1 && prev![0] === di && prev![1] === dj);
|
||||
});
|
||||
}
|
||||
|
||||
test("slope-limited steps: path never makes an abrupt jump even when the cost invites one", () => {
|
||||
// the cost is 0 on a path that jumps 20 columns at i = 50; symmetric steps follow the jump, slope-limited ones cannot
|
||||
const jumpCost = (i: number, j: number) => Math.min(1, Math.abs(j - (i < 50 ? i : i + 20)) * 0.2);
|
||||
const sym = dtw(jumpCost, 100, 120);
|
||||
expect(stepsOf(sym).some(([di, dj], k, s) => di === 0 && dj === 1 && s[k + 1]?.[0] === 0)).toBe(true); // a horizontal run
|
||||
const p = dtw(jumpCost, 100, 120, undefined, { steps: "slope" });
|
||||
expect(p[0]).toEqual([0, 0]);
|
||||
expect(p[p.length - 1]).toEqual([99, 119]);
|
||||
expect(slopeLimited(p)).toBe(true);
|
||||
});
|
||||
|
||||
test("slope-limited steps follow a smooth diagonal-with-kink path exactly like the default (banded too)", () => {
|
||||
const p = dtw(cost, N, M, undefined, { steps: "slope" });
|
||||
expect(slopeLimited(p)).toBe(true);
|
||||
for (const [i, j] of p) if (i < 45 || i > 60) expect(Math.abs(j - truthJ(i))).toBeLessThanOrEqual(1);
|
||||
expect(dtw(cost, N, M, { center: truthJ, width: 15 }, { steps: "slope" })).toEqual(p);
|
||||
});
|
||||
|
||||
test("slope-limited steps throw 'unreachable' when the aspect ratio exceeds 2", () => {
|
||||
expect(() => dtw(cost, 10, 30, undefined, { steps: "slope" })).toThrow(/unreachable/);
|
||||
});
|
||||
|
||||
test("open end: the path may stop before the recording ends (subsequence end)", () => {
|
||||
// the query (rows) matches columns 0..99 diagonally; the last 40 columns are extra material
|
||||
const c = (i: number, j: number) => (j < 100 ? Math.min(1, Math.abs(i - j) * 0.2) : 0.5);
|
||||
const closed = dtw(c, 100, 140, undefined, { steps: "slope" });
|
||||
expect(closed[closed.length - 1]).toEqual([99, 139]);
|
||||
const open = dtw(c, 100, 140, undefined, { steps: "slope", openEnd: true });
|
||||
expect(open[open.length - 1]).toEqual([99, 99]);
|
||||
for (const [i, j] of open) expect(Math.abs(i - j)).toBeLessThanOrEqual(1);
|
||||
// the query being longer than the target (its extra rows mismatch everything, costing more than an unconsumed row):
|
||||
// the path may end on the last column
|
||||
const longer = dtw((i, j) => (i < 60 ? Math.min(1, Math.abs(i - j) * 0.2) : 1.5), 100, 60, undefined, { steps: "slope", openEnd: true });
|
||||
expect(longer[longer.length - 1]).toEqual([59, 59]);
|
||||
});
|
||||
|
||||
test("open begin: the path may start after the beginning of the target", () => {
|
||||
const c = (i: number, j: number) => (j >= 30 ? Math.min(1, Math.abs(i + 30 - j) * 0.2) : 0.5);
|
||||
const p = dtw(c, 100, 130, undefined, { steps: "slope", openBegin: true });
|
||||
expect(p[0]).toEqual([0, 30]);
|
||||
expect(p[p.length - 1]).toEqual([99, 129]);
|
||||
for (const [i, j] of p) expect(Math.abs(i + 30 - j)).toBeLessThanOrEqual(1);
|
||||
// banded with a band that does not include column 0 at row 0
|
||||
expect(dtw(c, 100, 130, { center: (i) => i + 30, width: 10 }, { steps: "slope", openBegin: true })).toEqual(p);
|
||||
});
|
||||
|
||||
test("open begin + end: a trivial path in a zero-cost corner does not win (unconsumed rows cost 1 each)", () => {
|
||||
// the last column is free everywhere (e.g. silence vs silence), the real match is the diagonal of the first 100 columns
|
||||
const c = (i: number, j: number) => (j === 119 ? 0 : j < 100 ? Math.min(1, Math.abs(i - j) * 0.2) : 0.6);
|
||||
const p = dtw(c, 100, 120, undefined, { steps: "slope", openBegin: true, openEnd: true });
|
||||
expect(p.length).toBeGreaterThan(90);
|
||||
expect(p[p.length - 1]).toEqual([99, 99]);
|
||||
});
|
||||
@@ -55,3 +55,16 @@ test("pickOnsets enforces 50 ms min gap and ignores flat input", () => {
|
||||
expect(pickOnsets(o, 100)).toEqual([0.22, 0.6]);
|
||||
expect(pickOnsets(new Float32Array(100), 100)).toEqual([]);
|
||||
});
|
||||
|
||||
test("pickOnsets refines peaks to sub-frame times by parabolic interpolation", () => {
|
||||
const o = new Float32Array(100);
|
||||
o[30] = 4; o[31] = 8; o[32] = 6; // peak leans right: δ = ½(4 − 6)/(4 − 16 + 6) = 1/6 frame
|
||||
const got = pickOnsets(o, 50);
|
||||
expect(got).toHaveLength(1);
|
||||
expect(got[0]!).toBeCloseTo((31 + 1 / 6) / 50, 9);
|
||||
// min-gap competition still compares the peak heights, not the interpolated times
|
||||
const p = new Float32Array(100);
|
||||
p[40] = 5; p[41] = 3; p[42] = 7; p[43] = 1;
|
||||
expect(pickOnsets(p, 100)).toHaveLength(1);
|
||||
expect(pickOnsets(p, 100)[0]!).toBeCloseTo((42 + 0.5 * (3 - 1) / (3 - 14 + 1)) / 100, 9);
|
||||
});
|
||||
+96
-62
@@ -1,72 +1,106 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { fitQuality, fitSummary } from "../../src/align/fit.ts";
|
||||
import { fitQuality, fitSummary, FLAG_MIN_PRECISION, FLAG_MIN_LIFT, MIN_REC_ONSETS, SHIFT_MARGIN, type FitReport, type FitWindow } from "../../src/align/fit.ts";
|
||||
|
||||
test("perfect fit: match rate 1, residual 0, one window per 30 s", () => {
|
||||
const on = [1, 2, 3, 31, 32, 61];
|
||||
const r = fitQuality(on, on, 75);
|
||||
expect(r.matchRate).toBe(1);
|
||||
expect(r.medianResidualMs).toBe(0);
|
||||
expect(r.windows.map((w) => w.startSec)).toEqual([0, 30, 60]);
|
||||
expect(r.windows.every((w) => !w.flagged && w.matchRate === 1)).toBe(true);
|
||||
const grid = (from: number, to: number, step: number) => { const o: number[] = []; for (let t = from; t < to - 1e-9; t += step) o.push(Math.round(t * 1000) / 1000); return o; };
|
||||
|
||||
test("constants match ruling A7", () => {
|
||||
expect(FLAG_MIN_PRECISION).toBe(0.6);
|
||||
expect(FLAG_MIN_LIFT).toBe(0.25);
|
||||
expect(MIN_REC_ONSETS).toBe(8);
|
||||
expect(SHIFT_MARGIN).toBe(0.05);
|
||||
});
|
||||
|
||||
test("match within ±70 ms only; residual is median |audio − midi| of matched onsets", () => {
|
||||
const midi = [1, 2, 3, 4];
|
||||
const audio = [1.01, 2.03, 3.069, 4.2]; // last is 200 ms away: unmatched
|
||||
const r = fitQuality(audio, midi, 10);
|
||||
expect(r.matchRate).toBe(0.75);
|
||||
test("precision = share of recording onsets with a MIDI onset within ±70 ms; audibleRate = the reverse (info)", () => {
|
||||
// recording hears every 2nd MIDI onset (dense score, sparse attacks) plus 2 extra attacks out of the score
|
||||
const midi = grid(0.5, 20.5, 0.25); // 80 onsets
|
||||
const rec = [...midi.filter((_, k) => k % 2 === 0), 3.13, 7.63]; // 40 + 2
|
||||
const r = fitQuality(rec, midi, 20.5);
|
||||
expect(r.precision).toBeCloseTo(40 / 42, 6);
|
||||
expect(r.audibleRate).toBeCloseTo(0.5, 6);
|
||||
expect(r.medianResidualMs).toBe(0);
|
||||
expect(r.windows).toHaveLength(1);
|
||||
expect(r.windows[0]!.recOnsets).toBe(42);
|
||||
// low audibleRate alone does not flag (that was the old, miscalibrated rule)
|
||||
expect(r.windows[0]!.flagged).toBe(false);
|
||||
});
|
||||
|
||||
test("lift: precision minus mean precision at off-grid shifts (+0.11, +0.23, +0.37 s)", () => {
|
||||
const midi = grid(1, 29, 1); // one onset per second: shifts of 0.11–0.37 s never land
|
||||
const r = fitQuality(midi, midi, 30);
|
||||
expect(r.precision).toBe(1);
|
||||
expect(r.lift).toBeCloseTo(1, 6);
|
||||
expect(r.windows[0]!.lift).toBeCloseTo(1, 6);
|
||||
// a dense MIDI grid (every 0.12 s) matches anything: precision 1 but no lift -> flagged
|
||||
const dense = grid(0, 30, 0.12);
|
||||
const rec = grid(0.5, 29.5, 0.5).map((t) => t + 0.031);
|
||||
const d = fitQuality(rec, dense, 30);
|
||||
expect(d.precision).toBe(1);
|
||||
expect(d.lift!).toBeLessThan(FLAG_MIN_LIFT);
|
||||
expect(d.windows[0]!.flagged).toBe(true);
|
||||
expect(d.windows[0]!.reasons.join(" ")).toContain("lift");
|
||||
});
|
||||
|
||||
test("window flagged when precision < 0.6; null and unflagged with < 8 recording onsets", () => {
|
||||
const midi = grid(1, 89, 1);
|
||||
const rec = [
|
||||
...grid(1, 11, 1), // window 0: 10 onsets, all on the score
|
||||
...grid(31.5, 41.5, 1), // window 1: 10 onsets, all half-way between score onsets
|
||||
61, 62, 63, // window 2: only 3 onsets
|
||||
];
|
||||
const r = fitQuality(rec, midi, 90);
|
||||
expect(r.windows.map((w) => w.startSec)).toEqual([0, 30, 60]);
|
||||
expect(r.windows[0]!.precision).toBe(1);
|
||||
expect(r.windows[0]!.flagged).toBe(false);
|
||||
expect(r.windows[1]!.precision).toBe(0);
|
||||
expect(r.windows[1]!.flagged).toBe(true);
|
||||
expect(r.windows[1]!.reasons[0]).toContain("precision");
|
||||
const w2 = r.windows[2]!;
|
||||
expect(w2.recOnsets).toBe(3);
|
||||
expect(w2.precision).toBeNull();
|
||||
expect(w2.lift).toBeNull();
|
||||
expect(w2.flagged).toBe(false);
|
||||
});
|
||||
|
||||
test("no MIDI onsets: overall nulls, nothing flagged", () => {
|
||||
const r = fitQuality([1, 2], [], 10);
|
||||
expect(r.precision).toBeNull();
|
||||
expect(r.audibleRate).toBeNull();
|
||||
expect(r.lift).toBeNull();
|
||||
expect(r.windows.every((w) => !w.flagged)).toBe(true);
|
||||
});
|
||||
|
||||
test("residual: median |recording − nearest MIDI| over matched recording onsets", () => {
|
||||
const midi = grid(1, 11, 1);
|
||||
const r = fitQuality(midi.map((t, k) => t + (k % 2 ? 0.02 : 0.04)), midi, 12);
|
||||
expect(r.medianResidualMs!).toBeCloseTo(30, 6);
|
||||
});
|
||||
|
||||
test("nearest recording onset is used and may be shared by several MIDI onsets", () => {
|
||||
const r = fitQuality([1.0], [0.98, 1.0, 1.02], 5);
|
||||
expect(r.matchRate).toBe(1);
|
||||
expect(r.medianResidualMs!).toBeCloseTo(20, 6);
|
||||
test("beat-shift check: flags a window where a shifted symbolic chroma beats the current one by the margin", () => {
|
||||
const rate = 10, n = 600; // 60 s, two windows
|
||||
const pc = (k: number) => { const c = new Float32Array(12); c[k % 12] = 1; return c; };
|
||||
// chord changes every second (one "beat"); recording = sequence s(t); current MIDI = s shifted by one beat in window 1
|
||||
const seq = (t: number) => pc(Math.floor(t / rate) * 5);
|
||||
const recording = Array.from({ length: n }, (_, t) => seq(t));
|
||||
const current = Array.from({ length: n }, (_, t) => (t < 300 ? seq(t) : seq(t - rate)));
|
||||
const plus1 = Array.from({ length: n }, (_, t) => seq(t - rate)), minus1 = Array.from({ length: n }, (_, t) => (t < 300 ? seq(t - rate) : seq(t)));
|
||||
const onsets = grid(0.5, 59.5, 0.5);
|
||||
const r = fitQuality(onsets, onsets, 60, { shift: { rate, recording, current, shifts: [{ label: "+1 beat", chroma: plus1 }, { label: "−1 beat", chroma: minus1 }] } });
|
||||
expect(r.windows[0]!.beatShift).toBeNull();
|
||||
expect(r.windows[0]!.flagged).toBe(false);
|
||||
expect(r.windows[1]!.beatShift?.label).toBe("−1 beat");
|
||||
expect(r.windows[1]!.beatShift!.gain).toBeGreaterThan(SHIFT_MARGIN);
|
||||
expect(r.windows[1]!.flagged).toBe(true);
|
||||
expect(r.windows[1]!.reasons).toContain("beat shift suspected: −1 beat");
|
||||
});
|
||||
|
||||
test("empty window gives nulls and is not flagged", () => {
|
||||
const r = fitQuality([1, 2, 65], [1, 2, 65], 90);
|
||||
expect(r.windows).toHaveLength(3);
|
||||
const w = r.windows[1]!;
|
||||
expect(w).toEqual({ startSec: 30, matchRate: null, medianResidualMs: null, flagged: false });
|
||||
});
|
||||
|
||||
test("no MIDI onsets at all gives overall nulls", () => {
|
||||
const r = fitQuality([1, 2], [], 10);
|
||||
expect(r.matchRate).toBeNull();
|
||||
expect(r.medianResidualMs).toBeNull();
|
||||
expect(r.windows).toEqual([{ startSec: 0, matchRate: null, medianResidualMs: null, flagged: false }]);
|
||||
});
|
||||
|
||||
test("window flagged when match rate < 0.5", () => {
|
||||
const midi = [1, 2, 3, 4, 31, 32];
|
||||
const audio = [1, 31, 32]; // window 0: 1/4 matched
|
||||
const r = fitQuality(audio, midi, 60);
|
||||
expect(r.windows[0]!.matchRate).toBe(0.25);
|
||||
expect(r.windows[0]!.flagged).toBe(true);
|
||||
expect(r.windows[1]!.flagged).toBe(false);
|
||||
expect(r.matchRate).toBe(0.5);
|
||||
});
|
||||
|
||||
test("window flagged when median residual > 60 ms even with full match", () => {
|
||||
const midi = [1, 2, 3];
|
||||
const r = fitQuality(midi.map((t) => t + 0.065), midi, 10);
|
||||
expect(r.matchRate).toBe(1);
|
||||
expect(r.windows[0]!.medianResidualMs!).toBeCloseTo(65, 6);
|
||||
expect(r.windows[0]!.flagged).toBe(true);
|
||||
});
|
||||
|
||||
test("unsorted and duplicate MIDI onsets: duplicates count once", () => {
|
||||
const r = fitQuality([2, 1], [2, 1, 1, 1], 5);
|
||||
expect(r.matchRate).toBe(1);
|
||||
const s = fitQuality([1], [1, 1, 1, 3], 5);
|
||||
expect(s.matchRate).toBe(0.5); // unique onsets {1, 3}
|
||||
});
|
||||
|
||||
test("fitSummary: one line with match rate, median residual and flagged windows", () => {
|
||||
const w = (startSec: number, flagged: boolean) => ({ startSec, matchRate: 0.9, medianResidualMs: 10, flagged });
|
||||
expect(fitSummary({ matchRate: 0.8234, medianResidualMs: 14.24, windows: [w(0, false), w(30, true), w(60, false), w(90, true)] }))
|
||||
.toBe("82.3% of MIDI onsets matched · median residual 14.2 ms · 2/4 windows flagged (0:30, 1:30)");
|
||||
expect(fitSummary({ matchRate: 1, medianResidualMs: 3, windows: [w(0, false)] })).toBe("100.0% of MIDI onsets matched · median residual 3.0 ms · 0/1 windows flagged");
|
||||
expect(fitSummary({ matchRate: null, medianResidualMs: null, windows: [] })).toBe("no scored MIDI onsets");
|
||||
test("fitSummary: precision, lift, audible rate, residual, flagged windows with reasons, stretch warnings", () => {
|
||||
const w = (startSec: number, reasons: string[]): FitWindow => ({ startSec, recOnsets: 20, precision: 0.9, lift: 0.7, audibleRate: 0.4, medianResidualMs: 10, beatShift: null, flagged: reasons.length > 0, reasons });
|
||||
const f: FitReport = {
|
||||
precision: 0.8234, lift: 0.6512, audibleRate: 0.3811, medianResidualMs: 14.24,
|
||||
windows: [w(0, []), w(30, ["beat shift suspected: +1 beat"]), w(60, []), w(90, ["precision 41%"])],
|
||||
stretches: { total: 40, warned: 3, over15: 1 },
|
||||
};
|
||||
expect(fitSummary(f)).toBe("precision 82.3% (lift +0.65) · MIDI onsets audible as attacks 38.1% · median residual 14.2 ms · 2/4 windows flagged (0:30 beat shift suspected: +1 beat; 1:30 precision 41%) · stretches: 3/40 warned, 1 > 15%");
|
||||
expect(fitSummary({ ...f, windows: [w(0, [])], stretches: undefined })).toBe("precision 82.3% (lift +0.65) · MIDI onsets audible as attacks 38.1% · median residual 14.2 ms · 0/1 windows flagged");
|
||||
expect(fitSummary({ precision: null, lift: null, audibleRate: null, medianResidualMs: null, windows: [] })).toBe("no scored MIDI onsets");
|
||||
});
|
||||
@@ -127,12 +127,18 @@ describe("API", () => {
|
||||
expect(Math.max(...first.recordingPeaks.max)).toBeGreaterThan(0.05);
|
||||
expect(first.source).toBe("auto");
|
||||
expect(first.savedAt).toBeNull();
|
||||
expect(first.anchors.length).toBeGreaterThan(2);
|
||||
// the fixture's MIDI is the recording's own score (identity alignment): the constant-offset baseline wins
|
||||
expect(first.auto.chosen).toBe("baseline");
|
||||
expect(first.anchors).toHaveLength(1);
|
||||
expect(Math.abs(first.auto.baseline.offsetSec)).toBeLessThan(0.05);
|
||||
expect(first.anchors.every((a: Anchor) => a.kind === "auto")).toBe(true);
|
||||
expect(first.stretches.length).toBeGreaterThanOrEqual(first.anchors.length); // +1, minus an empty edge stretch
|
||||
expect(first.stretches[1].midiBpm).toBeGreaterThan(30);
|
||||
expect(first.stretches[1].bpm).toBeCloseTo(first.stretches[1].midiBpm / first.stretches[1].ratio, 6);
|
||||
expect(first.fit.matchRate).toBeGreaterThan(0.5);
|
||||
expect(first.fit.precision).toBeGreaterThan(0.6);
|
||||
expect(first.fit.stretches.total).toBe(first.stretches.length);
|
||||
expect(["dtw", "baseline"]).toContain(first.auto.chosen);
|
||||
expect(typeof first.auto.baseline.offsetSec).toBe("number");
|
||||
expect(first.midiTempo[0].bpm).toBeGreaterThan(30);
|
||||
expect(await Bun.file(join(root, "fix", "recording.wav")).exists()).toBe(true);
|
||||
expect(await Bun.file(join(root, "fix", "peaks.json")).exists()).toBe(true);
|
||||
@@ -164,7 +170,7 @@ describe("API", () => {
|
||||
const r = await api("/api/song/fix/fit", json({ anchors: [{ midiSec: 1, audioSec: 1.4, kind: "manual" }] }));
|
||||
const j = await r.json();
|
||||
expect(j.fit.windows.length).toBeGreaterThan(0);
|
||||
expect(j.fit.matchRate).toBeLessThan(first.fit.matchRate);
|
||||
expect(j.fit.precision).toBeLessThan(first.fit.precision);
|
||||
expect(j.stretches.length).toBe(2);
|
||||
});
|
||||
test("bad anchors are rejected with 400", async () => {
|
||||
@@ -173,12 +179,13 @@ describe("API", () => {
|
||||
expect((await api("/api/song/fix/fit", { method: "POST", body: "{not json" })).status).toBe(400);
|
||||
});
|
||||
test("auto: keeps manual anchors and re-solves autos around them", async () => {
|
||||
const manual: Anchor = { ...first.anchors[3], kind: "manual" };
|
||||
const r = await api("/api/song/fix/auto", json({ anchors: [manual, ...first.anchors.slice(5)] }));
|
||||
const manual: Anchor = { midiSec: 10, audioSec: 10, kind: "manual" };
|
||||
const r = await api("/api/song/fix/auto", json({ anchors: [manual, { midiSec: 15, audioSec: 15.5, kind: "auto" }] }));
|
||||
const j = await r.json();
|
||||
expect(j.anchors.filter((a: Anchor) => a.kind === "manual")).toEqual([manual]);
|
||||
expect(j.anchors.filter((a: Anchor) => a.kind === "auto").length).toBeGreaterThan(2);
|
||||
expect(j.fit.matchRate).toBeGreaterThan(0.5);
|
||||
expect(j.fit.precision).toBeGreaterThan(0.6);
|
||||
expect(j.auto.chosen).toBe("dtw"); // manual anchors present: never the baseline
|
||||
expect(j.stretches.length).toBeGreaterThanOrEqual(j.anchors.length);
|
||||
}, BIG);
|
||||
test("reset: raw MIDI (no anchors)", async () => {
|
||||
@@ -217,6 +224,6 @@ describe("API", () => {
|
||||
const j = await g.json();
|
||||
expect(j.source).toBe("auto");
|
||||
expect(j.warning).toContain("alignment.json");
|
||||
expect(j.anchors.length).toBeGreaterThan(2);
|
||||
expect(j.anchors.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -230,3 +230,10 @@ describe("deleteAnchor", () => {
|
||||
expect(() => deleteAnchor([M(1, 1)], 1)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
test("extrapolation before the first anchor uses the first stretch's slope (no implicit 0 -> 0 anchor)", () => {
|
||||
const w = makeWarp([{ midiSec: 10, audioSec: 12, kind: "auto" }, { midiSec: 20, audioSec: 23, kind: "auto" }]); // slope 1.1
|
||||
expect(w.toAudio(0)).toBeCloseTo(12 - 10 * 1.1, 9);
|
||||
expect(w.toMidi(12 - 10 * 1.1)).toBeCloseTo(0, 9);
|
||||
expect(makeWarp([{ midiSec: 10, audioSec: 12, kind: "auto" }]).toAudio(0)).toBe(2); // single anchor: slope 1
|
||||
});
|
||||
Reference in new issue
Block a user