feat(align): SMF parser, role guess, reference manifest
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
67168e6103
commit
e910036e67
8 files changed
+414
-1
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 })),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user