feat(align): SMF parser, role guess, reference manifest
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
67168e6103
commit
e910036e67
@@ -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
@@ -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"
|
||||
|
||||
@@ -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 })),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"));
|
||||
});
|
||||
Reference in New Issue
Block a user