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]>
112 lines
5.6 KiB
TypeScript
112 lines
5.6 KiB
TypeScript
import { test, expect } from "bun:test";
|
|
import { dtw } from "../../src/align/dtw.ts";
|
|
|
|
// Ground-truth path: diagonal with a kink (j runs ahead by 10 after i=50).
|
|
function truthJ(i: number): number { return i < 50 ? i : i + 10; }
|
|
const N = 100, M = 110;
|
|
// Path cells cost 0, others grow with distance from the true path.
|
|
const cost = (i: number, j: number) => {
|
|
const t = truthJ(i);
|
|
return Math.min(1, Math.abs(j - t) * 0.2);
|
|
};
|
|
|
|
test("dtw recovers diagonal-with-kink path", () => {
|
|
const p = dtw(cost, N, M);
|
|
expect(p[0]).toEqual([0, 0]);
|
|
expect(p[p.length - 1]).toEqual([N - 1, M - 1]);
|
|
for (const [i, j] of p) if (i !== 49 && i !== 50) expect(Math.abs(j - truthJ(i))).toBeLessThanOrEqual(1);
|
|
for (let k = 1; k < p.length; k++) {
|
|
const di = p[k]![0] - p[k - 1]![0], dj = p[k]![1] - p[k - 1]![1];
|
|
expect(di >= 0 && di <= 1 && dj >= 0 && dj <= 1 && di + dj >= 1).toBe(true);
|
|
}
|
|
});
|
|
|
|
test("banded dtw equals unbanded when the band covers the path", () => {
|
|
const full = dtw(cost, N, M);
|
|
const banded = dtw(cost, N, M, { center: (i) => truthJ(i), width: 15 });
|
|
expect(banded).toEqual(full);
|
|
});
|
|
|
|
test("banded dtw only visits cells in the band", () => {
|
|
let calls = 0;
|
|
const p = dtw((i, j) => { calls++; return Math.abs(i - j) * 0.1; }, 200, 200, { center: (i) => i, width: 5 });
|
|
expect(calls).toBeLessThanOrEqual(200 * 11);
|
|
expect(p[p.length - 1]).toEqual([199, 199]);
|
|
});
|
|
|
|
test("3000x3000 unbanded dtw finishes in < 5 s", () => {
|
|
const n = 3000;
|
|
const t0 = performance.now();
|
|
const p = dtw((i, j) => Math.abs(i - j) * 0.001 + ((i * 7 + j * 13) % 5) * 0.0001, n, n);
|
|
const ms = performance.now() - t0;
|
|
console.log(`dtw 3000x3000: ${ms.toFixed(0)} ms`);
|
|
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]);
|
|
});
|