/ proof-of-concept / midiboot
Sign in
proof-of-concept / midiboot
midiboot / src / midi / parse.ts
222 lines · 8.2 KBBlameHistoryRaw
1import type { Note, Song, TempoEvent, TimeSigEvent, Track } from '../types.ts';
2import { newId } from '../types.ts';
3import { GM_PROGRAMS, gmProgramInstrument } from './gm.ts';
4import { INSTRUMENT_META_PREFIX } from './write.ts';
6// Standard MIDI file parser for formats 0 and 1, with running status and a
7// merged tempo map. Each (SMF track, channel) pair that carries notes
8// becomes one editable track; this splits multi-channel format 0 files
9// sensibly and is harmless for format 1.
11class Reader {
12 pos = 0;
13 constructor(readonly d: Uint8Array) {}
14 u8(): number {
15 if (this.pos >= this.d.length) throw new Error('unexpected end of MIDI data');
16 return this.d[this.pos++];
17 }
18 u16(): number { return (this.u8() << 8) | this.u8(); }
19 u32(): number { return (this.u16() << 16) | this.u16(); }
20 bytes(n: number): Uint8Array {
21 const out = this.d.subarray(this.pos, this.pos + n);
22 if (out.length < n) throw new Error('unexpected end of MIDI data');
23 this.pos += n;
24 return out;
25 }
26 varLen(): number {
27 let v = 0;
28 for (let i = 0; i < 4; i++) {
29 const b = this.u8();
30 v = (v << 7) | (b & 0x7f);
31 if ((b & 0x80) === 0) return v;
32 }
33 throw new Error('variable-length quantity too long');
34 }
37interface RawNote { tick: number; duration: number; pitch: number; velocity: number; channel: number }
39interface TrackInstrumentMeta {
40 instrumentId?: string;
41 settings?: Record<string, number>;
44// A sequencer-specific meta event written by midiboot's own exporter:
45// "MDBT1" + JSON carrying the instrument id and knob settings. Anything
46// malformed is ignored and the GM fallback applies.
47function parseInstrumentMeta(body: Uint8Array): TrackInstrumentMeta | null {
48 let text: string;
49 try {
50 text = new TextDecoder().decode(body);
51 } catch {
52 return null;
53 }
54 if (!text.startsWith(INSTRUMENT_META_PREFIX)) return null;
55 try {
56 const raw = JSON.parse(text.slice(INSTRUMENT_META_PREFIX.length)) as { i?: unknown; s?: unknown };
57 const out: TrackInstrumentMeta = {};
58 if (typeof raw.i === 'string' && raw.i.length > 0) out.instrumentId = raw.i;
59 if (raw.s && typeof raw.s === 'object') {
60 const settings: Record<string, number> = {};
61 for (const [k, v] of Object.entries(raw.s as Record<string, unknown>)) {
62 if (typeof v === 'number' && Number.isFinite(v)) settings[k] = v;
63 }
64 if (Object.keys(settings).length > 0) out.settings = settings;
65 }
66 return out.instrumentId || out.settings ? out : null;
67 } catch {
68 return null;
69 }
72/** Parse a standard MIDI file. When knownInstrumentIds is given, an
73 * embedded instrument id not in the set is discarded (the GM mapping
74 * applies instead); without it, embedded ids are taken as they are. */
75export function parseMidi(data: Uint8Array, knownInstrumentIds?: Set<string>): Song {
76 const r = new Reader(data);
77 const magic = String.fromCharCode(...r.bytes(4));
78 if (magic !== 'MThd') throw new Error('not a standard MIDI file (missing MThd)');
79 const headerLen = r.u32();
80 const format = r.u16();
81 const ntrks = r.u16();
82 const division = r.u16();
83 r.bytes(headerLen - 6);
84 if (format === 2) throw new Error('MIDI format 2 is not supported');
85 if (division & 0x8000) throw new Error('SMPTE time division is not supported');
86 const ppq = division;
88 const tempo: TempoEvent[] = [];
89 const timeSig: TimeSigEvent[] = [];
90 const tracks: Track[] = [];
92 for (let ti = 0; ti < ntrks; ti++) {
93 if (r.pos >= data.length) break;
94 const chunk = String.fromCharCode(...r.bytes(4));
95 const len = r.u32();
96 const end = r.pos + len;
97 if (chunk !== 'MTrk') { r.pos = end; continue; }
99 let tick = 0;
100 let running = 0;
101 let trackName = '';
102 let instrumentMeta: TrackInstrumentMeta | null = null;
103 const notes: RawNote[] = [];
104 const open = new Map<number, { tick: number; velocity: number }[]>();
105 const programByChannel = new Map<number, number>();
107 const closeNote = (channel: number, pitch: number, atTick: number) => {
108 const key = channel * 128 + pitch;
109 const stack = open.get(key);
110 if (!stack || stack.length === 0) return;
111 const o = stack.pop()!;
112 notes.push({ tick: o.tick, duration: Math.max(1, atTick - o.tick), pitch, velocity: o.velocity, channel });
113 };
115 while (r.pos < end) {
116 tick += r.varLen();
117 let status = r.u8();
118 if (status < 0x80) {
119 if (running === 0) throw new Error('running status with no prior status byte');
120 r.pos -= 1;
121 status = running;
122 }
123 if (status === 0xff) {
124 running = 0;
125 const type = r.u8();
126 const mlen = r.varLen();
127 const body = r.bytes(mlen);
128 if (type === 0x51 && mlen >= 3) {
129 tempo.push({ tick, usPerQuarter: (body[0] << 16) | (body[1] << 8) | body[2] });
130 } else if (type === 0x58 && mlen >= 2) {
131 timeSig.push({ tick, numerator: body[0], denominator: 1 << body[1] });
132 } else if (type === 0x03 && trackName === '') {
133 trackName = new TextDecoder().decode(body).trim();
134 } else if (type === 0x7f && instrumentMeta === null) {
135 instrumentMeta = parseInstrumentMeta(body);
136 } else if (type === 0x2f) {
137 break;
138 }
139 } else if (status === 0xf0 || status === 0xf7) {
140 running = 0;
141 const slen = r.varLen();
142 r.bytes(slen);
143 } else {
144 running = status;
145 const kind = status >> 4;
146 const channel = status & 0x0f;
147 if (kind === 0x9) {
148 const pitch = r.u8();
149 const vel = r.u8();
150 if (vel > 0) {
151 const key = channel * 128 + pitch;
152 if (!open.has(key)) open.set(key, []);
153 open.get(key)!.push({ tick, velocity: vel });
154 } else {
155 closeNote(channel, pitch, tick);
156 }
157 } else if (kind === 0x8) {
158 const pitch = r.u8();
159 r.u8();
160 closeNote(channel, pitch, tick);
161 } else if (kind === 0xc) {
162 const prog = r.u8();
163 if (!programByChannel.has(channel)) programByChannel.set(channel, prog);
164 } else if (kind === 0xd) {
165 r.u8();
166 } else {
167 r.u8();
168 r.u8();
169 }
170 }
171 }
172 r.pos = end;
174 // Close anything left hanging with a quarter-note duration.
175 for (const [key, stack] of open) {
176 const pitch = key % 128;
177 const channel = Math.floor(key / 128);
178 for (const o of stack) notes.push({ tick: o.tick, duration: Math.max(1, Math.round(ppq / 4)), pitch, velocity: o.velocity, channel });
179 }
180 if (notes.length === 0) continue;
182 const channels = [...new Set(notes.map((n) => n.channel))].sort((a, b) => a - b);
183 for (const ch of channels) {
184 const chNotes = notes
185 .filter((n) => n.channel === ch)
186 .sort((a, b) => a.tick - b.tick || a.pitch - b.pitch)
187 .map<Note>((n) => ({ id: newId('n'), tick: n.tick, duration: n.duration, pitch: n.pitch, velocity: n.velocity }));
188 const prog = programByChannel.get(ch);
189 let name = trackName;
190 if (channels.length > 1 && name !== '') name = `${name} ch${ch + 1}`;
191 if (name === '') name = ch === 9 ? 'Drums' : prog !== undefined ? GM_PROGRAMS[prog] ?? `Track ${tracks.length + 1}` : `Track ${tracks.length + 1}`;
192 // The embedded midiboot meta beats the GM mapping; an id the caller
193 // does not recognize falls back rather than naming a missing
194 // instrument.
195 let instrumentId = ch === 9 ? 'drumkit' : gmProgramInstrument(prog);
196 let settings: Record<string, number> | undefined;
197 if (instrumentMeta) {
198 const id = instrumentMeta.instrumentId;
199 if (id && (!knownInstrumentIds || knownInstrumentIds.has(id))) {
200 instrumentId = id;
201 settings = instrumentMeta.settings;
202 }
203 }
204 tracks.push({
205 id: newId('t'),
206 name,
207 instrumentId,
208 ...(settings ? { settings } : {}),
209 volume: 1,
210 mute: false,
211 solo: false,
212 notes: chNotes,
213 });
214 }
215 }
217 tempo.sort((a, b) => a.tick - b.tick);
218 timeSig.sort((a, b) => a.tick - b.tick);
219 if (tempo.length === 0) tempo.push({ tick: 0, usPerQuarter: 500000 });
220 if (timeSig.length === 0) timeSig.push({ tick: 0, numerator: 4, denominator: 4 });
221 return { ppq, tempo, timeSig, tracks };
↑↓ move↵openescclose