0ad1ec1midiboot: in-browser MIDI workstation with pluggable instrumentsJeremy Magland 1import type { Note, Song, TempoEvent, TimeSigEvent, Track } from '../types.ts';
2import { newId } from '../types.ts';
3import { GM_PROGRAMS } from './gm.ts';
5// Standard MIDI file parser for formats 0 and 1, with running status and a
6// merged tempo map. Each (SMF track, channel) pair that carries notes
7// becomes one editable track; this splits multi-channel format 0 files
8// sensibly and is harmless for format 1.
10class Reader {
11 pos = 0;
12 constructor(readonly d: Uint8Array) {}
13 u8(): number {
14 if (this.pos >= this.d.length) throw new Error('unexpected end of MIDI data');
15 return this.d[this.pos++];
16 }
17 u16(): number { return (this.u8() << 8) | this.u8(); }
18 u32(): number { return (this.u16() << 16) | this.u16(); }
19 bytes(n: number): Uint8Array {
20 const out = this.d.subarray(this.pos, this.pos + n);
21 if (out.length < n) throw new Error('unexpected end of MIDI data');
22 this.pos += n;
23 return out;
24 }
25 varLen(): number {
26 let v = 0;
27 for (let i = 0; i < 4; i++) {
28 const b = this.u8();
29 v = (v << 7) | (b & 0x7f);
30 if ((b & 0x80) === 0) return v;
31 }
32 throw new Error('variable-length quantity too long');
33 }
34}
36interface RawNote { tick: number; duration: number; pitch: number; velocity: number; channel: number }
38export function parseMidi(data: Uint8Array): Song {
39 const r = new Reader(data);
40 const magic = String.fromCharCode(...r.bytes(4));
41 if (magic !== 'MThd') throw new Error('not a standard MIDI file (missing MThd)');
42 const headerLen = r.u32();
43 const format = r.u16();
44 const ntrks = r.u16();
45 const division = r.u16();
46 r.bytes(headerLen - 6);
47 if (format === 2) throw new Error('MIDI format 2 is not supported');
48 if (division & 0x8000) throw new Error('SMPTE time division is not supported');
49 const ppq = division;
51 const tempo: TempoEvent[] = [];
52 const timeSig: TimeSigEvent[] = [];
53 const tracks: Track[] = [];
55 for (let ti = 0; ti < ntrks; ti++) {
56 if (r.pos >= data.length) break;
57 const chunk = String.fromCharCode(...r.bytes(4));
58 const len = r.u32();
59 const end = r.pos + len;
60 if (chunk !== 'MTrk') { r.pos = end; continue; }
62 let tick = 0;
63 let running = 0;
64 let trackName = '';
65 const notes: RawNote[] = [];
66 const open = new Map<number, { tick: number; velocity: number }[]>();
67 const programByChannel = new Map<number, number>();
69 const closeNote = (channel: number, pitch: number, atTick: number) => {
70 const key = channel * 128 + pitch;
71 const stack = open.get(key);
72 if (!stack || stack.length === 0) return;
73 const o = stack.pop()!;
74 notes.push({ tick: o.tick, duration: Math.max(1, atTick - o.tick), pitch, velocity: o.velocity, channel });
75 };
77 while (r.pos < end) {
78 tick += r.varLen();
79 let status = r.u8();
80 if (status < 0x80) {
81 if (running === 0) throw new Error('running status with no prior status byte');
82 r.pos -= 1;
83 status = running;
84 }
85 if (status === 0xff) {
86 running = 0;
87 const type = r.u8();
88 const mlen = r.varLen();
89 const body = r.bytes(mlen);
90 if (type === 0x51 && mlen >= 3) {
91 tempo.push({ tick, usPerQuarter: (body[0] << 16) | (body[1] << 8) | body[2] });
92 } else if (type === 0x58 && mlen >= 2) {
93 timeSig.push({ tick, numerator: body[0], denominator: 1 << body[1] });
94 } else if (type === 0x03 && trackName === '') {
95 trackName = new TextDecoder().decode(body).trim();
96 } else if (type === 0x2f) {
97 break;
98 }
99 } else if (status === 0xf0 || status === 0xf7) {
100 running = 0;
101 const slen = r.varLen();
102 r.bytes(slen);
103 } else {
104 running = status;
105 const kind = status >> 4;
106 const channel = status & 0x0f;
107 if (kind === 0x9) {
108 const pitch = r.u8();
109 const vel = r.u8();
110 if (vel > 0) {
111 const key = channel * 128 + pitch;
112 if (!open.has(key)) open.set(key, []);
113 open.get(key)!.push({ tick, velocity: vel });
114 } else {
115 closeNote(channel, pitch, tick);
116 }
117 } else if (kind === 0x8) {
118 const pitch = r.u8();
119 r.u8();
120 closeNote(channel, pitch, tick);
121 } else if (kind === 0xc) {
122 const prog = r.u8();
123 if (!programByChannel.has(channel)) programByChannel.set(channel, prog);
124 } else if (kind === 0xd) {
125 r.u8();
126 } else {
127 r.u8();
128 r.u8();
129 }
130 }
131 }
132 r.pos = end;
134 // Close anything left hanging with a quarter-note duration.
135 for (const [key, stack] of open) {
136 const pitch = key % 128;
137 const channel = Math.floor(key / 128);
138 for (const o of stack) notes.push({ tick: o.tick, duration: Math.max(1, Math.round(ppq / 4)), pitch, velocity: o.velocity, channel });
139 }
140 if (notes.length === 0) continue;
142 const channels = [...new Set(notes.map((n) => n.channel))].sort((a, b) => a - b);
143 for (const ch of channels) {
144 const chNotes = notes
145 .filter((n) => n.channel === ch)
146 .sort((a, b) => a.tick - b.tick || a.pitch - b.pitch)
147 .map<Note>((n) => ({ id: newId('n'), tick: n.tick, duration: n.duration, pitch: n.pitch, velocity: n.velocity }));
148 const prog = programByChannel.get(ch);
149 let name = trackName;
150 if (channels.length > 1 && name !== '') name = `${name} ch${ch + 1}`;
151 if (name === '') name = ch === 9 ? 'Drums' : prog !== undefined ? GM_PROGRAMS[prog] ?? `Track ${tracks.length + 1}` : `Track ${tracks.length + 1}`;
152 tracks.push({
153 id: newId('t'),
154 name,
155 instrumentId: ch === 9 ? 'drumkit' : 'pluck',
156 volume: 1,
157 mute: false,
158 solo: false,
159 notes: chNotes,
160 });
161 }
162 }
164 tempo.sort((a, b) => a.tick - b.tick);
165 timeSig.sort((a, b) => a.tick - b.tick);
166 if (tempo.length === 0) tempo.push({ tick: 0, usPerQuarter: 500000 });
167 if (timeSig.length === 0) timeSig.push({ tick: 0, numerator: 4, denominator: 4 });
168 return { ppq, tempo, timeSig, tracks };
169}