import { assertEquals, assertThrows } from "@std/assert"; import { parseMidi } from "./smf.ts"; const u16 = (n: number) => [n >> 8, n & 0xff]; const u32 = (n: number) => [n >>> 24, (n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]; const ascii = (s: string) => [...s].map(c => c.charCodeAt(0)); function varInt(n: number) { const out = [n & 0x7f]; while ((n >>= 7)) out.unshift((n & 0x7f) | 0x80); return out; } const header = (format: number, tracks: number, ppq: number) => [ ...ascii("MThd"), ...u32(6), ...u16(format), ...u16(tracks), ...u16(ppq), ]; const track = (...events: number[][]) => { const body = [...events.flat(), 0, 0xff, 0x2f, 0]; return [...ascii("MTrk"), ...u32(body.length), ...body]; }; const trackName = (name: string) => [0, 0xff, 0x03, ...varInt(name.length), ...ascii(name)]; const midi = (...parts: number[][]) => new Uint8Array(parts.flat()); Deno.test("notes come out in beats, with running status and zero-velocity note-offs", () => { const parts = parseMidi( midi( header(0, 1, 480), track( [0, 0x90, 60, 127], [...varInt(480), 60, 0], // running status note-on, velocity 0: a note-off [0, 64, 64], [...varInt(240), 0x80, 64, 0], ), ), ); assertEquals(parts, [ { name: "track 1", channel: 0, notes: [ { start: 0, dur: 1, pitch: 60, vel: 1 }, { start: 1, dur: 0.5, pitch: 64, vel: 64 / 127 }, ], }, ]); }); Deno.test("each track with notes becomes a part, named, and drums are left out", () => { const parts = parseMidi( midi( header(1, 3, 96), track(trackName("tempo"), [0, 0xff, 0x51, 3, 0x07, 0xa1, 0x20]), track(trackName("lead"), [0, 0x91, 72, 100], [96, 0x81, 72, 0]), track(trackName("drums"), [0, 0x99, 36, 100], [96, 0x89, 36, 0]), ), ); assertEquals( parts.map(p => [p.name, p.channel, p.notes.length]), [["lead", 1, 1]], ); }); Deno.test("a track spanning several channels splits into one part per channel", () => { const parts = parseMidi( midi( header(0, 1, 96), track([0, 0x90, 60, 90], [0, 0x92, 48, 90], [96, 0x80, 60, 0], [0, 0x82, 48, 0]), ), ); assertEquals( parts.map(p => [p.name, p.notes[0].pitch]), [ ["track 1 ch1", 60], ["track 1 ch3", 48], ], ); }); Deno.test("overlapping repeats pair up in order, and held notes end with the track", () => { const [part] = parseMidi( midi( header(0, 1, 100), track( [0, 0x90, 60, 100], [50, 0x90, 60, 100], [50, 0x80, 60, 0], [0, 0xf0, 2, 0x7e, 0xf7], // sysex between events cancels running status [0, 0x90, 67, 100], [100, 0x80, 60, 0], ), ), ); assertEquals( part.notes.map(n => [n.pitch, n.start, n.dur]), [ [60, 0, 1], [60, 0.5, 1.5], [67, 1, 1], ], ); }); Deno.test("rejects files that aren't midi", () => { assertThrows( () => parseMidi(new TextEncoder().encode("RIFF....WAVEfmt ")), Error, "not a MIDI file", ); assertThrows(() => parseMidi(midi(header(0, 1, 0xe728), track())), Error, "SMPTE"); });