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 <[email protected]>
65 lines
2.5 KiB
TypeScript
65 lines
2.5 KiB
TypeScript
import { test, expect } from "bun:test";
|
||
import { fitQuality } 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);
|
||
});
|
||
|
||
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);
|
||
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("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}
|
||
});
|