feat(align): SMF parser, role guess, reference manifest

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 06:27:08 +01:00
co-authored by Claude Sonnet 5.5
parent 67168e6103
commit e910036e67
8 changed files with 414 additions and 1 deletions
+5
View File
@@ -35,3 +35,8 @@ The baseline is in `results/baseline-2026-10-30.md` (tier summaries plus a per-s
- `oracle-stems` mode feeds the true per-track stems, which reveals the track count and roles, so part-split F1 under oracle-stems (and the oracle modes built on it) is not meaningful.
- The SONiVOX guitar is partly routed to Demucs' piano stem, a synthetic-timbre gap that lowers full-mode guitar scores.
- Fingering (string agreement) is informational, not pass/fail.
## Reference songs (alignment)
Team-supplied real recordings and Guitar Pro-exported MIDI live in the git-ignored `reference/` directory (never committed).
Register a song with `bun run align add <audio> <midi> --id <id> [--title <t>] [--capo "<track name>=9"]`; this writes `reference/songs.json` and guesses a scoring role per MIDI track (unmapped tracks are `null`).
+2 -1
View File
@@ -6,7 +6,8 @@
"test": "bun test",
"typecheck": "bunx tsc --noEmit",
"fetch-models": "bun scripts/fetch-models.ts",
"bench": "bun src/bench/cli.ts"
"bench": "bun src/bench/cli.ts",
"align": "bun src/align/cli.ts"
},
"devDependencies": {
"@types/bun": "latest"
+33
View File
@@ -0,0 +1,33 @@
import { parseArgs } from "node:util";
import { addSong, trackKeys } from "../ref/manifest.ts";
import { parseSmf } from "./smf.ts";
const USAGE = `usage: bun run align add <audio> <midi> --id <id> [--title <t>] [--capo "<track name>=9"]...`;
async function add(argv: string[]) {
const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: { id: { type: "string" }, title: { type: "string" }, capo: { type: "string", multiple: true } } });
const [audio, midi] = positionals;
if (!audio || !midi || !values.id) throw new Error(USAGE);
const smf = parseSmf(new Uint8Array(await Bun.file(midi).arrayBuffer()));
const keys = new Set(trackKeys(smf.tracks));
const capo: Record<string, number> = {};
for (const c of values.capo ?? []) {
const i = c.lastIndexOf("=");
const k = c.slice(0, i), n = Number(c.slice(i + 1));
if (i < 0 || !Number.isInteger(n) || n < 0) throw new Error(`bad --capo ${JSON.stringify(c)}; expected "<track name>=<fret>"`);
if (!keys.has(k)) throw new Error(`--capo: no track named ${JSON.stringify(k)}; tracks: ${[...keys].map((x) => JSON.stringify(x)).join(", ")}`);
capo[k] = n;
}
const e = await addSong({ id: values.id, title: values.title ?? values.id, audio, midi, capo });
console.log(`added ${e.id} (${e.title})`);
for (const [k, r] of Object.entries(e.roles)) console.log(` ${JSON.stringify(k)} -> ${r ?? "unmapped"}`);
}
const [cmd, ...rest] = process.argv.slice(2);
try {
if (cmd === "add") await add(rest);
else throw new Error(USAGE);
} catch (err) {
console.error((err as Error).message);
process.exit(1);
}
+17
View File
@@ -0,0 +1,17 @@
import type { Role } from "../core/types.ts";
import type { RefTrack } from "./smf.ts";
// Best-effort role for a reference MIDI track; null = unmapped (excluded from scoring until the user maps it).
export function guessRole(t: RefTrack): Role | null {
if (t.isDrums) return "drums";
const n = t.name.trim().toLowerCase();
if (/vocal|voice|vox|sing/.test(n)) return "vocals";
if (/bass/.test(n)) return "bass";
if (/guitar|delay/.test(n)) return "guitar";
if (/piano|key|synth|organ/.test(n)) return "keys";
const p = t.program;
if (p >= 32 && p <= 39) return "bass";
if (p >= 24 && p <= 31) return "guitar";
if ((p >= 0 && p <= 7) || (p >= 16 && p <= 23) || (p >= 88 && p <= 95)) return "keys";
return null;
}
+142
View File
@@ -0,0 +1,142 @@
// Standard MIDI File parser. Pure: bytes in, seconds-based notes out (no I/O).
export interface RefNote { onset: number; offset: number; pitch: number; velocity: number; channel: number }
export interface RefTrack { index: number; name: string; program: number; isDrums: boolean; notes: RefNote[] }
export interface TempoPoint { sec: number; bpm: number }
export interface Smf { durationSec: number; tracks: RefTrack[]; tempoMap: TempoPoint[]; timeSigs: { sec: number; num: number; den: number }[] }
const DEFAULT_US_PER_BEAT = 500000;
const DRUM_CHANNEL = 9;
interface RawNote { onTick: number; offTick: number; pitch: number; velocity: number; channel: number }
interface RawTrack { index: number; name: string; program: number; notes: RawNote[]; endTick: number }
class Reader {
p = 0;
constructor(readonly b: Uint8Array) {}
u8(): number {
if (this.p >= this.b.length) throw new Error("SMF: unexpected end of data");
return this.b[this.p++]!;
}
u16(): number { return (this.u8() << 8) | this.u8(); }
u32(): number { return ((this.u8() << 24) | (this.u8() << 16) | (this.u8() << 8) | this.u8()) >>> 0; }
vlq(): number {
let v = 0;
for (let i = 0; i < 4; i++) {
const c = this.u8();
v = (v << 7) | (c & 0x7f);
if (!(c & 0x80)) return v;
}
throw new Error("SMF: malformed variable-length quantity");
}
skip(n: number): void {
if (this.p + n > this.b.length) throw new Error("SMF: unexpected end of data");
this.p += n;
}
tag(): string { return String.fromCharCode(this.u8(), this.u8(), this.u8(), this.u8()); }
}
interface TempoEvent { tick: number; usPerBeat: number }
interface SigEvent { tick: number; num: number; den: number }
function parseTrack(r: Reader, index: number, end: number, tempos: TempoEvent[], sigs: SigEvent[]): RawTrack {
const tr: RawTrack = { index, name: "", program: -1, notes: [], endTick: 0 };
const open = new Map<number, RawNote[]>(); // key = channel*128+pitch, FIFO queue of sounding notes
let tick = 0, running = 0;
const close = (ch: number, pitch: number, at: number) => {
const q = open.get(ch * 128 + pitch);
const n = q?.shift();
if (n) n.offTick = at;
};
while (r.p < end) {
tick += r.vlq();
let status = r.b[r.p]!;
if (status & 0x80) r.p++;
else if (running) status = running;
else throw new Error("SMF: data byte without running status");
if (status === 0xff) {
const type = r.u8(), len = r.vlq(), start = r.p;
r.skip(len);
const d = r.b.subarray(start, start + len);
if (type === 0x03 && !tr.name) tr.name = new TextDecoder("latin1").decode(d);
else if (type === 0x51 && len === 3) tempos.push({ tick, usPerBeat: (d[0]! << 16) | (d[1]! << 8) | d[2]! });
else if (type === 0x58 && len >= 2) sigs.push({ tick, num: d[0]!, den: 2 ** d[1]! });
else if (type === 0x2f) break;
} else if (status === 0xf0 || status === 0xf7) {
r.skip(r.vlq()); // sysex: skipped; cancels running status per spec
running = 0;
} else if (status >= 0xf1) {
running = 0; // other system messages carry no data we use
} else {
running = status;
const hi = status & 0xf0, ch = status & 15;
const a = r.u8();
const b = hi === 0xc0 || hi === 0xd0 ? 0 : r.u8();
if (hi === 0xc0 && tr.program < 0) tr.program = a;
else if (hi === 0x90 && b > 0) {
const n: RawNote = { onTick: tick, offTick: -1, pitch: a, velocity: b, channel: ch };
tr.notes.push(n);
const k = ch * 128 + a;
const q = open.get(k);
if (q) q.push(n); else open.set(k, [n]);
} else if (hi === 0x80 || hi === 0x90) close(ch, a, tick);
}
}
r.p = end;
tr.endTick = tick;
for (const n of tr.notes) if (n.offTick < 0) n.offTick = tick; // unterminated: ends with the track
return tr;
}
export function parseSmf(bytes: Uint8Array): Smf {
const r = new Reader(bytes);
if (r.tag() !== "MThd") throw new Error("SMF: missing MThd header");
const hlen = r.u32();
r.u16(); // format: 0/1/2 all handled identically (tracks merged on one timeline)
const ntrks = r.u16(), div = r.u16();
r.skip(Math.max(0, hlen - 6));
if (div & 0x8000) throw new Error("SMF: SMPTE time division is not supported");
if (div === 0) throw new Error("SMF: zero ticks per quarter note");
const tempos: TempoEvent[] = [], sigs: SigEvent[] = [], raw: RawTrack[] = [];
for (let i = 0; i < ntrks && r.p < bytes.length; i++) {
const id = r.tag(), len = r.u32();
const end = Math.min(r.p + len, bytes.length);
if (id !== "MTrk") { r.p = end; continue; }
raw.push(parseTrack(r, i, end, tempos, sigs));
}
// tempo map: sorted by tick, later event wins on equal tick; implicit 120 bpm until the first change
tempos.sort((a, b) => a.tick - b.tick);
const segs: { tick: number; sec: number; us: number }[] = [{ tick: 0, sec: 0, us: DEFAULT_US_PER_BEAT }];
for (const t of tempos) {
const last = segs[segs.length - 1]!;
if (t.tick === last.tick) { last.us = t.usPerBeat; continue; }
segs.push({ tick: t.tick, sec: last.sec + ((t.tick - last.tick) / div) * (last.us / 1e6), us: t.usPerBeat });
}
const toSec = (tick: number): number => {
let lo = 0, hi = segs.length - 1;
while (lo < hi) { const m = (lo + hi + 1) >> 1; if (segs[m]!.tick <= tick) lo = m; else hi = m - 1; }
const s = segs[lo]!;
return s.sec + ((tick - s.tick) / div) * (s.us / 1e6);
};
let durationSec = 0;
const tracks: RefTrack[] = [];
for (const t of raw) {
durationSec = Math.max(durationSec, toSec(t.endTick));
if (!t.notes.length) continue; // conductor / meta-only tracks
const notes: RefNote[] = t.notes.map((n) => ({ onset: toSec(n.onTick), offset: toSec(n.offTick), pitch: n.pitch, velocity: n.velocity, channel: n.channel }));
notes.sort((a, b) => a.onset - b.onset || a.pitch - b.pitch);
for (const n of notes) durationSec = Math.max(durationSec, n.offset);
tracks.push({ index: t.index, name: t.name, program: t.program, isDrums: notes.some((n) => n.channel === DRUM_CHANNEL), notes });
}
sigs.sort((a, b) => a.tick - b.tick);
return {
durationSec,
tracks,
tempoMap: segs.map((s) => ({ sec: s.sec, bpm: 60e6 / s.us })),
timeSigs: sigs.map((s) => ({ sec: toSec(s.tick), num: s.num, den: s.den })),
};
}
+50
View File
@@ -0,0 +1,50 @@
import { join, resolve } from "node:path";
import { mkdir } from "node:fs/promises";
import type { Role } from "../core/types.ts";
import { parseSmf } from "../align/smf.ts";
import { guessRole } from "../align/roles.ts";
export interface SongEntry {
id: string; title: string; audio: string; midi: string;
roles: Record<string, Role | null>; capo: Record<string, number>; tuning?: Record<string, number[]>;
}
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
export function songDir(id: string, root = "reference"): string {
if (!ID_RE.test(id)) throw new Error(`invalid song id: ${JSON.stringify(id)}`);
return join(root, id);
}
export async function readManifest(root = "reference"): Promise<SongEntry[]> {
const f = Bun.file(join(root, "songs.json"));
if (!(await f.exists())) return [];
return (await f.json()) as SongEntry[];
}
// Role-map key for a track: its name, or "track N" when unnamed; disambiguated on collision.
export function trackKeys(tracks: { index: number; name: string }[]): string[] {
const used = new Set<string>();
return tracks.map((t) => {
let k = t.name || `track ${t.index}`;
if (used.has(k)) k = `${k} #${t.index}`;
used.add(k);
return k;
});
}
export async function addSong(e: Omit<SongEntry, "roles"> & { roles?: SongEntry["roles"] }, root = "reference"): Promise<SongEntry> {
const dir = songDir(e.id, root);
const all = await readManifest(root);
if (all.some((s) => s.id === e.id)) throw new Error(`duplicate song id: ${e.id}`);
let roles = e.roles;
if (!roles) {
const tracks = parseSmf(new Uint8Array(await Bun.file(e.midi).arrayBuffer())).tracks;
const keys = trackKeys(tracks);
roles = Object.fromEntries(tracks.map((t, i) => [keys[i]!, guessRole(t)]));
}
const entry: SongEntry = { ...e, audio: resolve(e.audio), midi: resolve(e.midi), roles };
await mkdir(dir, { recursive: true });
await Bun.write(join(root, "songs.json"), JSON.stringify([...all, entry], null, 2) + "\n");
return entry;
}
+116
View File
@@ -0,0 +1,116 @@
import { test, expect, describe } from "bun:test";
import { existsSync } from "node:fs";
import { parseSmf } from "../../src/align/smf.ts";
import { guessRole } from "../../src/align/roles.ts";
import type { RefTrack } from "../../src/align/smf.ts";
const vlq = (v: number): number[] => {
const out = [v & 0x7f];
while ((v >>= 7) > 0) out.unshift((v & 0x7f) | 0x80);
return out;
};
const u32 = (v: number) => [(v >>> 24) & 255, (v >>> 16) & 255, (v >>> 8) & 255, v & 255];
const u16 = (v: number) => [(v >> 8) & 255, v & 255];
const ascii = (s: string) => [...s].map((c) => c.charCodeAt(0));
const chunk = (id: string, body: number[]) => [...ascii(id), ...u32(body.length), ...body];
const meta = (dt: number, type: number, data: number[]) => [...vlq(dt), 0xff, type, ...vlq(data.length), ...data];
const name = (n: string) => meta(0, 3, ascii(n));
const tempo = (dt: number, bpm: number) => { const us = Math.round(60e6 / bpm); return meta(dt, 0x51, [(us >> 16) & 255, (us >> 8) & 255, us & 255]); };
const end = meta(0, 0x2f, []);
const smf = (tracks: number[][], ppq = 480) => new Uint8Array([...chunk("MThd", [...u16(1), ...u16(tracks.length), ...u16(ppq)]), ...tracks.flatMap((t) => chunk("MTrk", t))]);
function build() {
const conductor = [...name("tempo"), ...meta(0, 0x58, [3, 2, 24, 8]), ...tempo(0, 120), ...tempo(1920, 60), ...end];
// guitar: program 25; note 60 at tick 0 for 480 ticks, then running-status note 62 using velocity-0 note-off
const guitar = [
...name("Guitar 1"), ...vlq(0), 0xc0, 25,
...vlq(0), 0x90, 60, 100,
...vlq(480), 0x80, 60, 0,
...vlq(0), 0x90, 62, 90, // new status
...vlq(480), 62, 0, // running status note-on vel 0 = off, at tick 960
...vlq(0), 0xf0, 3, 1, 2, 0xf7, // sysex skipped
...vlq(960), 0x90, 64, 80, // tick 1920 (tempo change point) -> 2.0 s
...vlq(480), 64, 0, // tick 2400 -> 2.0 + 1.0 s
...end,
];
const drums = [...name("Drums"), ...vlq(0), 0x99, 36, 100, ...vlq(240), 0x99, 36, 0, ...end];
return smf([conductor, guitar, drums]);
}
describe("parseSmf", () => {
const s = parseSmf(build());
test("tempo map and time signature", () => {
expect(s.tempoMap).toEqual([{ sec: 0, bpm: 120 }, { sec: 2, bpm: 60 }]);
expect(s.timeSigs).toEqual([{ sec: 0, num: 3, den: 4 }]);
});
test("note times in seconds across tempo change", () => {
const g = s.tracks.find((t) => t.name === "Guitar 1")!;
expect(g.program).toBe(25);
expect(g.isDrums).toBe(false);
expect(g.notes.map((n) => [n.onset, n.offset, n.pitch, n.velocity])).toEqual([
[0, 0.5, 60, 100], [0.5, 1, 62, 90], [2, 3, 64, 80],
]);
expect(s.durationSec).toBeCloseTo(3, 9);
});
test("drums on channel 10", () => {
const d = s.tracks.find((t) => t.name === "Drums")!;
expect(d.isDrums).toBe(true);
expect(d.notes).toHaveLength(1);
expect(d.notes[0]!.channel).toBe(9);
});
test("conductor track without notes is dropped; index is SMF track order", () => {
expect(s.tracks.map((t) => t.index)).toEqual([1, 2]);
});
test("overlapping same-pitch notes on one channel pair FIFO", () => {
const t = [...name("x"), 0, 0x90, 60, 50, ...vlq(100), 0x90, 60, 60, ...vlq(100), 0x80, 60, 0, ...vlq(100), 0x80, 60, 0, ...end];
const n = parseSmf(smf([t], 480)).tracks[0]!.notes;
expect(n.map((x) => [x.velocity, x.offset > x.onset])).toEqual([[50, true], [60, true]]);
expect(n[0]!.offset).toBeLessThan(n[1]!.offset);
expect(n[0]!.onset).toBeLessThan(n[1]!.onset);
});
test("same pitch on different channels are independent", () => {
const t = [0, 0x90, 60, 50, 0, 0x91, 60, 50, ...vlq(480), 0x81, 60, 0, ...vlq(480), 0x80, 60, 0, ...end];
const n = parseSmf(smf([t])).tracks[0]!.notes;
expect(n.map((x) => [x.channel, x.offset]).sort()).toEqual([[0, 1], [1, 0.5]]);
});
test("rejects non-SMF", () => {
expect(() => parseSmf(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]))).toThrow();
});
});
describe("guessRole", () => {
const t = (o: Partial<RefTrack>): RefTrack => ({ index: 0, name: "", program: -1, isDrums: false, notes: [], ...o });
test("by drums / name / program", () => {
expect(guessRole(t({ isDrums: true }))).toBe("drums");
expect(guessRole(t({ name: "Lead Vocals" }))).toBe("vocals");
expect(guessRole(t({ name: "Bass" }))).toBe("bass");
expect(guessRole(t({ name: "Billie Joe Armstrong | Rhythm Guitar" }))).toBe("guitar");
expect(guessRole(t({ name: "Acoustic Guitar " }))).toBe("guitar");
expect(guessRole(t({ name: "Delay Lead" }))).toBe("guitar");
expect(guessRole(t({ name: "Piano" }))).toBe("keys");
expect(guessRole(t({ name: "Synth Pad" }))).toBe("keys");
expect(guessRole(t({ name: "", program: 25 }))).toBe("guitar");
expect(guessRole(t({ name: "", program: 33 }))).toBe("bass");
});
test("unknown -> null", () => {
expect(guessRole(t({ name: "Brass", program: 61 }))).toBeNull();
expect(guessRole(t({ name: "Crowd", program: 126 }))).toBeNull();
expect(guessRole(t({ name: "" }))).toBeNull();
});
test("integration with parsed SMF", () => {
const s = parseSmf(build());
expect(s.tracks.map(guessRole)).toEqual(["guitar", "drums"]);
});
});
const REAL = "Passenger-Holes-10-24-2016.mid";
describe("real GP export", () => {
test.skipIf(!existsSync(REAL))("Holes parses; Brass/Crowd unmapped", async () => {
const s = parseSmf(new Uint8Array(await Bun.file(REAL).arrayBuffer()));
expect(s.tracks).toHaveLength(7);
for (const t of s.tracks) {
if (/^(brass|crowd)/i.test(t.name.trim())) expect(guessRole(t)).toBeNull();
}
expect(s.durationSec).toBeGreaterThan(60);
});
});
+49
View File
@@ -0,0 +1,49 @@
import { test, expect, beforeEach, afterEach } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { addSong, readManifest, songDir } from "../../src/ref/manifest.ts";
let root = "";
beforeEach(async () => { root = await mkdtemp(join(tmpdir(), "ref-")); });
afterEach(async () => { await rm(root, { recursive: true, force: true }); });
const base = { id: "s1", title: "Song", audio: "a.flac", midi: "a.mid", capo: {} };
test("readManifest on empty root returns []", async () => {
expect(await readManifest(root)).toEqual([]);
});
test("addSong writes songs.json with given roles", async () => {
const e = await addSong({ ...base, roles: { Gtr: "guitar", Crowd: null } }, root);
expect(e.roles).toEqual({ Gtr: "guitar", Crowd: null });
expect(await Bun.file(join(root, "songs.json")).exists()).toBe(true);
expect(await readManifest(root)).toEqual([e]);
});
test("addSong guesses roles from the midi, keyed by track name", async () => {
const ascii = (x: string) => [...x].map((c) => c.charCodeAt(0));
const trk = (n: string, prog: number) => {
const body = [0, 0xff, 3, n.length, ...ascii(n), 0, 0xc0, prog, 0, 0x90, 60, 90, 0x60, 0x80, 60, 0, 0, 0xff, 0x2f, 0];
return [...ascii("MTrk"), 0, 0, 0, body.length, ...body];
};
const mid = new Uint8Array([...ascii("MThd"), 0, 0, 0, 6, 0, 1, 0, 2, 0x01, 0xe0, ...trk("Acoustic Guitar ", 25), ...trk("Brass", 61)]);
const midi = join(root, "in.mid");
await Bun.write(midi, mid);
const e = await addSong({ ...base, midi }, root);
expect(e.roles).toEqual({ "Acoustic Guitar ": "guitar", Brass: null });
});
test("addSong rejects duplicate ids", async () => {
await addSong({ ...base, roles: {} }, root);
await expect(addSong({ ...base, roles: {} }, root)).rejects.toThrow(/duplicate|exists/i);
expect(await readManifest(root)).toHaveLength(1);
});
test("rejects unsafe ids", async () => {
await expect(addSong({ ...base, id: "../x", roles: {} }, root)).rejects.toThrow();
});
test("songDir", () => {
expect(songDir("s1", root)).toBe(join(root, "s1"));
});