Files
STSheets/tests/align/fit.test.ts
T
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

107 lines
5.8 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, fitSummary, FLAG_MIN_PRECISION, FLAG_MIN_LIFT, MIN_REC_ONSETS, SHIFT_MARGIN, type FitReport, type FitWindow } from "../../src/align/fit.ts";
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("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("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("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");
});