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 => ({ 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); }); });