char-slop/chordpad.vibed.software

git clone https://git.t4t.associates/char-slop/chordpad.vibed.software

Charlotte Sominitial commit3c7bc6e

main
3.1 KiB113 linesraw
1import { assertEquals, assertThrows } from "@std/assert";
2import { parseMidi } from "./smf.ts";
3
4const u16 = (n: number) => [n >> 8, n & 0xff];
5const u32 = (n: number) => [n >>> 24, (n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff];
6const ascii = (s: string) => [...s].map(c => c.charCodeAt(0));
7function varInt(n: number) {
8  const out = [n & 0x7f];
9  while ((n >>= 7)) out.unshift((n & 0x7f) | 0x80);
10  return out;
11}
12
13const header = (format: number, tracks: number, ppq: number) => [
14  ...ascii("MThd"),
15  ...u32(6),
16  ...u16(format),
17  ...u16(tracks),
18  ...u16(ppq),
19];
20const track = (...events: number[][]) => {
21  const body = [...events.flat(), 0, 0xff, 0x2f, 0];
22  return [...ascii("MTrk"), ...u32(body.length), ...body];
23};
24const trackName = (name: string) => [0, 0xff, 0x03, ...varInt(name.length), ...ascii(name)];
25const midi = (...parts: number[][]) => new Uint8Array(parts.flat());
26
27Deno.test("notes come out in beats, with running status and zero-velocity note-offs", () => {
28  const parts = parseMidi(
29    midi(
30      header(0, 1, 480),
31      track(
32        [0, 0x90, 60, 127],
33        [...varInt(480), 60, 0], // running status note-on, velocity 0: a note-off
34        [0, 64, 64],
35        [...varInt(240), 0x80, 64, 0],
36      ),
37    ),
38  );
39  assertEquals(parts, [
40    {
41      name: "track 1",
42      channel: 0,
43      notes: [
44        { start: 0, dur: 1, pitch: 60, vel: 1 },
45        { start: 1, dur: 0.5, pitch: 64, vel: 64 / 127 },
46      ],
47    },
48  ]);
49});
50
51Deno.test("each track with notes becomes a part, named, and drums are left out", () => {
52  const parts = parseMidi(
53    midi(
54      header(1, 3, 96),
55      track(trackName("tempo"), [0, 0xff, 0x51, 3, 0x07, 0xa1, 0x20]),
56      track(trackName("lead"), [0, 0x91, 72, 100], [96, 0x81, 72, 0]),
57      track(trackName("drums"), [0, 0x99, 36, 100], [96, 0x89, 36, 0]),
58    ),
59  );
60  assertEquals(
61    parts.map(p => [p.name, p.channel, p.notes.length]),
62    [["lead", 1, 1]],
63  );
64});
65
66Deno.test("a track spanning several channels splits into one part per channel", () => {
67  const parts = parseMidi(
68    midi(
69      header(0, 1, 96),
70      track([0, 0x90, 60, 90], [0, 0x92, 48, 90], [96, 0x80, 60, 0], [0, 0x82, 48, 0]),
71    ),
72  );
73  assertEquals(
74    parts.map(p => [p.name, p.notes[0].pitch]),
75    [
76      ["track 1 ch1", 60],
77      ["track 1 ch3", 48],
78    ],
79  );
80});
81
82Deno.test("overlapping repeats pair up in order, and held notes end with the track", () => {
83  const [part] = parseMidi(
84    midi(
85      header(0, 1, 100),
86      track(
87        [0, 0x90, 60, 100],
88        [50, 0x90, 60, 100],
89        [50, 0x80, 60, 0],
90        [0, 0xf0, 2, 0x7e, 0xf7], // sysex between events cancels running status
91        [0, 0x90, 67, 100],
92        [100, 0x80, 60, 0],
93      ),
94    ),
95  );
96  assertEquals(
97    part.notes.map(n => [n.pitch, n.start, n.dur]),
98    [
99      [60, 0, 1],
100      [60, 0.5, 1.5],
101      [67, 1, 1],
102    ],
103  );
104});
105
106Deno.test("rejects files that aren't midi", () => {
107  assertThrows(
108    () => parseMidi(new TextEncoder().encode("RIFF....WAVEfmt ")),
109    Error,
110    "not a MIDI file",
111  );
112  assertThrows(() => parseMidi(midi(header(0, 1, 0xe728), track())), Error, "SMPTE");
113});