Files
STSheets/tests/align/fit.test.ts
T
Lucas WintherandClaude Opus 5.5 8cad85e103 feat(align): automatic alignment and fit quality
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]>
2026-10-30 06:52:10 +01:00

65 lines
2.5 KiB
TypeScript
Raw 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 } 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}
});