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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Sonnet 5.5 committed 2026-10-30 06:27:08 +01:00
1 parent 67168e6103
commit e910036e67
8 files changed
+414 -1

No files matched your search

+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 })),
};
}