Files
STSheets/tests/align/auto.test.ts
T
Lucas WintherandClaude Opus 5.5 99a1076547 fix(align): densify coarse fallback path; per-segment offset pooling; no degenerate first stretch
The coarse-path fallback now expands each 10 Hz step to five 50 Hz steps so its
diagonals still yield bar anchors; fine band widths are injectable to test it.
Offset pooling is clipped to the current segment, and auto anchors need
audioSec > 0 unless midiSec is 0. Tests: forced fallback, wrong manual anchor,
p90 bound; debug output gated behind ALIGN_DEBUG.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 06:57:47 +01:00

172 lines
11 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, beforeAll } from "bun:test";
import { alignmentFit, autoAlign, bandedWithRetry, barTimes } from "../../src/align/auto.ts";
import { makeWarp, type Anchor, type Warp } from "../../src/align/warp.ts";
import type { RefTrack, Smf } from "../../src/align/smf.ts";
import type { Audio, Role, TruthTrack } from "../../src/core/types.ts";
import { buildSong, loadSongDir } from "../../src/bench/dataset.ts";
const SF2 = new Uint8Array(await Bun.file("node_modules/@coderline/alphatab/dist/soundfont/sonivox.sf2").arrayBuffer());
const BIG = 60_000;
let song: Awaited<ReturnType<typeof loadSongDir>>;
beforeAll(async () => { song = await loadSongDir(await buildSong("t1c", 1)); }, BIG);
/** In-memory Smf from truth tracks: `map` turns truth (= recording) time into MIDI time; `reps` tiles the song. */
function smfFrom(truth: TruthTrack[], programs: number[], bpm: number, map: (t: number) => number, reps = 1, period = 0): Smf {
const tracks: RefTrack[] = truth.map((t, i) => ({
index: i + 1, name: t.name, program: programs[i]!, isDrums: t.role === "drums",
notes: Array.from({ length: reps }, (_, r) => t.notes.map((n) => ({
onset: map(n.onset + r * period), offset: map(n.offset + r * period), pitch: n.pitch, velocity: n.velocity ?? 96,
channel: t.role === "drums" ? 9 : i,
}))).flat(),
}));
const durationSec = Math.max(...tracks.flatMap((t) => t.notes.map((n) => n.offset)));
return { durationSec, tracks, tempoMap: [{ sec: 0, bpm }], timeSigs: [{ sec: 0, num: 4, den: 4 }] };
}
const rolesOf = (truth: TruthTrack[]): Record<string, Role | null> => Object.fromEntries(truth.map((t) => [t.name, t.role]));
const durOf = (a: Audio) => a.channels[0]!.length / a.sampleRate;
const median = (xs: number[]) => { const s = [...xs].sort((a, b) => a - b); return s[s.length >> 1]!; };
const p90 = (xs: number[]) => { const s = [...xs].sort((a, b) => a - b); return s[Math.floor(s.length * 0.9)]!; };
const debug = (msg: string) => { if (process.env.ALIGN_DEBUG) console.log(msg); };
const strictlyMonotone = (A: Anchor[]) => A.every((a, k) => k === 0 || (a.midiSec > A[k - 1]!.midiSec && a.audioSec > A[k - 1]!.audioSec));
/** Known distortion recording time -> MIDI time: +0.4 s, then bars 4–10 at ×1.06 tempo, then ≈ −0.8 s (continuous). */
function distortion(bpm: number): Warp {
const bar = (4 * 60) / bpm, b1 = 4 * bar, b2 = 10 * bar;
const m2 = b1 + 0.4 + (b2 - b1) / 1.06;
return makeWarp([
{ midiSec: 0, audioSec: 0.4, kind: "manual" },
{ midiSec: b1, audioSec: b1 + 0.4, kind: "manual" },
{ midiSec: b2, audioSec: m2, kind: "manual" },
{ midiSec: b2 + 100, audioSec: m2 + 100, kind: "manual" },
]); // toAudio: recording -> MIDI, toMidi: MIDI -> recording (the truth the aligner must recover)
}
function recoveryErrors(smf: Smf, got: Anchor[], trueMidiToAudio: (m: number) => number, audioDur: number): number[] {
const w = makeWarp(got);
const onsets = [...new Set(smf.tracks.flatMap((t) => t.notes.map((n) => n.onset)))];
return onsets.filter((m) => { const a = trueMidiToAudio(m); return a >= 0 && a <= audioDur; })
.map((m) => Math.abs(w.toAudio(m) - trueMidiToAudio(m)));
}
test("recovers a known piecewise warp: median error < 40 ms, fit match rate > 0.7", () => {
const D = distortion(song.song.bpm);
const smf = smfFrom(song.truth.tracks, song.song.tracks.map((t) => t.program), song.song.bpm, D.toAudio);
const t0 = performance.now();
const { anchors, fit } = autoAlign(song.mix, smf, rolesOf(song.truth.tracks), [], SF2);
const ms = performance.now() - t0;
const err = recoveryErrors(smf, anchors, D.toMidi, durOf(song.mix));
debug(`synthetic warp: ${anchors.length} anchors, median err ${(median(err) * 1000).toFixed(1)} ms, p90 ${(p90(err) * 1000).toFixed(1)} ms, fit ${fit.matchRate?.toFixed(3)} / ${fit.medianResidualMs?.toFixed(1)} ms, ${ms.toFixed(0)} ms`);
expect(anchors.every((a) => a.kind === "auto")).toBe(true);
expect(median(err)).toBeLessThan(0.04);
expect(p90(err)).toBeLessThan(0.08);
expect(fit.matchRate!).toBeGreaterThan(0.7);
expect(fit.windows.length).toBe(2);
expect(anchors.every((a) => a.audioSec > 0 || a.midiSec === 0)).toBe(true);
}, BIG);
test("a correct manual anchor mid-song is honoured exactly", () => {
const D = distortion(song.song.bpm);
const smf = smfFrom(song.truth.tracks, song.song.tracks.map((t) => t.program), song.song.bpm, D.toAudio);
const manual: Anchor = { midiSec: D.toAudio(30), audioSec: 30, kind: "manual" };
const { anchors } = autoAlign(song.mix, smf, rolesOf(song.truth.tracks), [manual], SF2);
expect(anchors.filter((a) => a.kind === "manual")).toEqual([manual]);
expect(makeWarp(anchors).toAudio(manual.midiSec)).toBe(30);
// no auto anchor within 1 s (MIDI time) of the manual one
expect(anchors.some((a) => a.kind === "auto" && Math.abs(a.midiSec - manual.midiSec) < 1)).toBe(false);
expect(anchors.length).toBeGreaterThan(5);
const err = recoveryErrors(smf, anchors, D.toMidi, durOf(song.mix));
expect(median(err)).toBeLessThan(0.04);
}, BIG);
test("a silent intro in the recording does not drag the path", () => {
const pad = 4 * song.mix.sampleRate;
const mix: Audio = { sampleRate: song.mix.sampleRate, channels: song.mix.channels.map((c) => { const o = new Float32Array(c.length + pad); o.set(c, pad); return o; }) };
const smf = smfFrom(song.truth.tracks, song.song.tracks.map((t) => t.program), song.song.bpm, (t) => t);
const { anchors, fit } = autoAlign(mix, smf, rolesOf(song.truth.tracks), [], SF2);
const err = recoveryErrors(smf, anchors, (m) => m + 4, durOf(mix));
expect(median(err)).toBeLessThan(0.04);
expect(fit.matchRate!).toBeGreaterThan(0.7);
}, BIG);
test("5-minute input aligns in < 15 s", () => {
const reps = 5, period = durOf(song.mix);
const mix: Audio = { sampleRate: song.mix.sampleRate, channels: song.mix.channels.map((c) => { const o = new Float32Array(c.length * reps); for (let r = 0; r < reps; r++) o.set(c, r * c.length); return o; }) };
const smf = smfFrom(song.truth.tracks, song.song.tracks.map((t) => t.program), song.song.bpm, (t) => t + 0.3, reps, period);
const t0 = performance.now();
const { anchors, fit } = autoAlign(mix, smf, rolesOf(song.truth.tracks), [], SF2);
const sec = (performance.now() - t0) / 1000;
debug(`5-min (${durOf(mix).toFixed(0)} s) input: ${sec.toFixed(2)} s, ${anchors.length} anchors, fit ${fit.matchRate?.toFixed(3)}`);
expect(durOf(mix)).toBeGreaterThan(290);
expect(sec).toBeLessThan(15);
expect(fit.windows.length).toBe(10);
}, BIG);
test("barTimes follows tempo and time-signature changes", () => {
const smf: Smf = {
durationSec: 10, tracks: [],
tempoMap: [{ sec: 0, bpm: 120 }, { sec: 4, bpm: 60 }],
timeSigs: [{ sec: 0, num: 4, den: 4 }, { sec: 4, num: 3, den: 4 }],
};
// 4/4 @120: bars of 2 s at 0, 2; then 3/4 @60: bars of 3 s at 4, 7
expect(barTimes(smf)).toEqual([0, 2, 4, 7]);
expect(barTimes({ durationSec: 3, tracks: [], tempoMap: [{ sec: 0, bpm: 120 }], timeSigs: [] })).toEqual([0, 2]); // default 4/4
expect(barTimes({ durationSec: 4, tracks: [], tempoMap: [{ sec: 0, bpm: 120 }], timeSigs: [{ sec: 0, num: 6, den: 8 }] })).toEqual([0, 1.5, 3]);
});
test("fine pass widens the band when the end cell is unreachable, then gives up with null", () => {
const cost = (i: number, j: number) => Math.abs(i - j);
// centre jumps by 120 frames mid-way: ±25 and ±50 leave a gap between rows, ±100 bridges it
const jump = (i: number) => (i < 200 ? i : i + 120);
const p = bandedWithRetry(cost, 400, 600, jump);
expect(p).not.toBeNull();
expect(p![p!.length - 1]).toEqual([399, 599]);
// a 400-frame jump defeats even ±2 s (±100 frames)
expect(bandedWithRetry(cost, 400, 1000, (i) => (i < 200 ? i : i + 400))).toBeNull();
});
test("fit exposes a bad alignment instead of hiding it", () => {
const D = distortion(song.song.bpm);
const smf = smfFrom(song.truth.tracks, song.song.tracks.map((t) => t.program), song.song.bpm, D.toAudio);
const roles = rolesOf(song.truth.tracks);
const bad = alignmentFit(song.mix, smf, roles, []); // identity: off by 0.4–0.8 s throughout
expect(bad.matchRate!).toBeLessThan(0.5);
// the −0.8 s section is exposed; the +0.4 s section is not, as 0.4 s is ≈ 40 ms from two 16ths at 68 bpm (grid alias)
expect(bad.windows[1]!.flagged).toBe(true);
const good = alignmentFit(song.mix, smf, roles, autoAlign(song.mix, smf, roles, [], SF2).anchors);
expect(good.windows.some((w) => w.flagged)).toBe(false);
// unmapped tracks are excluded: with no scored roles there is nothing to measure
expect(alignmentFit(song.mix, smf, Object.fromEntries(Object.keys(roles).map((k) => [k, null])), []).matchRate).toBeNull();
}, BIG);
test("a deliberately wrong manual anchor (+0.3 s) is kept exactly and the result stays monotonic", () => {
const D = distortion(song.song.bpm);
const smf = smfFrom(song.truth.tracks, song.song.tracks.map((t) => t.program), song.song.bpm, D.toAudio);
const manual: Anchor = { midiSec: D.toAudio(30), audioSec: 30.3, kind: "manual" };
const { anchors } = autoAlign(song.mix, smf, rolesOf(song.truth.tracks), [manual], SF2);
expect(anchors.filter((a) => a.kind === "manual")).toEqual([manual]);
expect(makeWarp(anchors).toAudio(manual.midiSec)).toBe(30.3);
expect(strictlyMonotone(anchors)).toBe(true);
expect(anchors.every((a) => a.audioSec > 0 || a.midiSec === 0)).toBe(true);
// away from the bad anchor (> 4 s), the autos still follow the truth
const far = anchors.filter((a) => a.kind === "auto" && Math.abs(a.audioSec - 30) > 4);
expect(far.length).toBeGreaterThan(5);
expect(median(far.map((a) => Math.abs(a.audioSec - D.toMidi(a.midiSec))))).toBeLessThan(0.04);
}, BIG);
test("when every fine band fails, the coarse-path fallback still yields auto anchors", () => {
const D = distortion(song.song.bpm);
const smf = smfFrom(song.truth.tracks, song.song.tracks.map((t) => t.program), song.song.bpm, D.toAudio);
const manual: Anchor = { midiSec: D.toAudio(30), audioSec: 30, kind: "manual" };
const { anchors, fit } = autoAlign(song.mix, smf, rolesOf(song.truth.tracks), [manual], SF2, { fineBandsSec: [] });
const autos = anchors.filter((a) => a.kind === "auto");
// both segments (before and after the manual anchor) get anchors
expect(autos.filter((a) => a.audioSec < 30).length).toBeGreaterThan(3);
expect(autos.filter((a) => a.audioSec > 30).length).toBeGreaterThan(3);
expect(strictlyMonotone(anchors)).toBe(true);
const err = recoveryErrors(smf, anchors, D.toMidi, durOf(song.mix));
debug(`coarse fallback: ${autos.length} autos, median err ${(median(err) * 1000).toFixed(1)} ms, p90 ${(p90(err) * 1000).toFixed(1)} ms, fit ${fit.matchRate?.toFixed(3)}`);
expect(median(err)).toBeLessThan(0.06); // 10 Hz resolution: coarser than the fine pass, still hand-correctable
expect(p90(err)).toBeLessThan(0.12);
}, BIG);