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 <noreply@anthropic.com>
This commit is contained in:
1 parent
41e38cb03f
commit
8cad85e103
6 files changed
+497
-11
No files matched your search
@@ -0,0 +1,135 @@
|
||||
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);
|
||||
@@ -0,0 +1,64 @@
|
||||
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}
|
||||
});
|
||||
Reference in new issue
Block a user