Files
STSheets/tests/align/auto.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

136 lines
8.2 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]!; };
/** 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));
console.log(`synthetic warp: ${anchors.length} anchors, median err ${(median(err) * 1000).toFixed(1)} ms, p90 ${(err.sort((a, b) => a - b)[Math.floor(err.length * 0.9)]! * 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(fit.matchRate!).toBeGreaterThan(0.7);
expect(fit.windows.length).toBe(2);
}, 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;
console.log(`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);