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]>
107 lines
5.8 KiB
TypeScript
107 lines
5.8 KiB
TypeScript
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");
|
||
});
|