char-slop/chordpad.vibed.software

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

Charlotte Sominitial commit3c7bc6e

main
3.6 KiB105 linesraw
1/** a note from a midi file, timed in beats */
2export type MidiNote = { start: number; dur: number; pitch: number; vel: number };
3export type MidiPart = { name: string; channel: number; notes: MidiNote[] };
4
5const DRUM_CHANNEL = 9;
6const MIN_DUR = 1 / 16;
7
8/**
9 * read the notes out of a standard midi file, one part per track and channel. drums are left
10 * out: they'd only come out as a pitched mess on a piano
11 */
12export function parseMidi(bytes: Uint8Array): MidiPart[] {
13  const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
14  const tag = (at: number) => String.fromCharCode(...bytes.subarray(at, at + 4));
15  if (bytes.length < 14 || tag(0) !== "MThd") throw new Error("not a MIDI file");
16
17  const division = view.getUint16(12);
18  if (division & 0x8000) throw new Error("SMPTE-timed MIDI files aren't supported");
19
20  const parts: MidiPart[] = [];
21  let trackIndex = 0;
22  for (let pos = 8 + view.getUint32(4); pos + 8 <= bytes.length;) {
23    const end = Math.min(bytes.length, pos + 8 + view.getUint32(pos + 4));
24    if (tag(pos) === "MTrk")
25      parts.push(...parseTrack(bytes.subarray(pos + 8, end), division, trackIndex++));
26    pos = end;
27  }
28  return parts;
29}
30
31function parseTrack(data: Uint8Array, ppq: number, index: number): MidiPart[] {
32  let pos = 0;
33  let tick = 0;
34  let status = 0;
35  let name = "";
36  const held = new Map<number, { tick: number; vel: number }[]>();
37  const byChannel = new Map<number, MidiNote[]>();
38
39  const varInt = () => {
40    let value = 0;
41    let b;
42    do {
43      b = data[pos++];
44      value = (value << 7) | (b & 0x7f);
45    } while (b & 0x80 && pos < data.length);
46    return value;
47  };
48  const release = (channel: number, pitch: number, at: number) => {
49    // repeated note-ons of one pitch are paired first-in, first-out
50    const on = held.get(channel * 128 + pitch)?.shift();
51    if (!on) return;
52    const notes = byChannel.get(channel) ?? [];
53    byChannel.set(channel, notes);
54    notes.push({
55      start: on.tick / ppq,
56      dur: Math.max(MIN_DUR, (at - on.tick) / ppq),
57      pitch,
58      vel: on.vel / 127,
59    });
60  };
61
62  while (pos < data.length) {
63    tick += varInt();
64    let event = data[pos];
65    if (event & 0x80) pos++;
66    else if (status)
67      event = status; // running status: the data byte is still unread
68    else throw new Error("malformed MIDI track");
69
70    if (event === 0xff) {
71      const type = data[pos++];
72      const length = varInt();
73      if (type === 0x03 && !name)
74        name = new TextDecoder().decode(data.subarray(pos, pos + length));
75      if (type === 0x2f) break;
76      pos += length;
77      status = 0;
78    } else if (event === 0xf0 || event === 0xf7) {
79      const length = varInt();
80      pos += length;
81      status = 0;
82    } else {
83      status = event;
84      const kind = event & 0xf0;
85      const channel = event & 0x0f;
86      const pitch = data[pos++];
87      const vel = kind === 0xc0 || kind === 0xd0 ? 0 : data[pos++];
88      if (kind === 0x90 && vel > 0) {
89        const key = channel * 128 + pitch;
90        held.set(key, [...(held.get(key) ?? []), { tick, vel }]);
91      } else if (kind === 0x80 || kind === 0x90) release(channel, pitch, tick);
92    }
93  }
94
95  for (const [key, ons] of held)
96    ons.forEach(() => release(Math.floor(key / 128), key % 128, tick));
97
98  const channels = [...byChannel.keys()].filter(c => c !== DRUM_CHANNEL).sort((a, b) => a - b);
99  return channels.map(channel => ({
100    name:
101      (name.trim() || `track ${index + 1}`) + (channels.length > 1 ? ` ch${channel + 1}` : ""),
102    channel,
103    notes: byChannel.get(channel)!.sort((a, b) => a.start - b.start || a.pitch - b.pitch),
104  }));
105}