/ proof-of-concept / midiboot
Sign in
proof-of-concept / midiboot
midiboot / src / midi / write.ts
130 lines · 4.7 KBBlameHistoryRaw
1import type { Song, Track } from '../types.ts';
2import { gmProgramForInstrument } from './gm.ts';
4// Standard MIDI file writer, format 1: one conductor track carrying the
5// tempo and time signature maps, then one MTrk per song track. Drum-kit
6// tracks are written on channel 10; the rest cycle through the others.
7// Each track carries its instrument id and knob settings in a
8// sequencer-specific meta event (FF 7F, payload "MDBT1" + JSON), which
9// midiboot's own import restores exactly and everything else ignores; a
10// GM program change is written beside it so foreign players pick a
11// plausible patch.
13class Writer {
14 private bytes: number[] = [];
15 u8(v: number) { this.bytes.push(v & 0xff); }
16 u16(v: number) { this.u8(v >> 8); this.u8(v); }
17 u32(v: number) { this.u16(v >>> 16); this.u16(v); }
18 varLen(v: number) {
19 if (v < 0) throw new Error('negative delta time');
20 const stack: number[] = [v & 0x7f];
21 let rest = Math.floor(v / 128);
22 while (rest > 0) { stack.push((rest & 0x7f) | 0x80); rest = Math.floor(rest / 128); }
23 for (let i = stack.length - 1; i >= 0; i--) this.u8(stack[i]);
24 }
25 raw(data: Uint8Array | number[]) { for (const b of data) this.u8(b); }
26 toUint8Array(): Uint8Array { return new Uint8Array(this.bytes); }
29interface Ev { tick: number; order: number; data: number[] }
31function trackChunk(events: Ev[], endTick: number): Uint8Array {
32 events.sort((a, b) => a.tick - b.tick || a.order - b.order);
33 const w = new Writer();
34 let last = 0;
35 for (const e of events) {
36 w.varLen(e.tick - last);
37 last = e.tick;
38 w.raw(e.data);
39 }
40 w.varLen(Math.max(0, endTick - last));
41 w.raw([0xff, 0x2f, 0x00]);
42 const body = w.toUint8Array();
43 const out = new Writer();
44 out.raw([0x4d, 0x54, 0x72, 0x6b]); // MTrk
45 out.u32(body.length);
46 out.raw(body);
47 return out.toUint8Array();
50/** The midiboot sequencer-specific payload prefix, version 1. */
51export const INSTRUMENT_META_PREFIX = 'MDBT1';
53function instrumentMeta(t: Track): number[] {
54 const payload: { i: string; s?: Record<string, number> } = { i: t.instrumentId };
55 const settings: Record<string, number> = {};
56 for (const [k, v] of Object.entries(t.settings ?? {})) {
57 if (typeof v === 'number' && Number.isFinite(v)) settings[k] = v;
58 }
59 if (Object.keys(settings).length > 0) payload.s = settings;
60 const enc = new TextEncoder().encode(INSTRUMENT_META_PREFIX + JSON.stringify(payload));
61 const w = new Writer();
62 w.raw([0xff, 0x7f]);
63 w.varLen(enc.length);
64 w.raw(enc);
65 return [...w.toUint8Array()];
68function nameMeta(name: string): number[] {
69 const enc = new TextEncoder().encode(name.slice(0, 60));
70 const out = [0xff, 0x03];
71 // name length always fits one varlen byte after the slice
72 out.push(enc.length);
73 out.push(...enc);
74 return out;
77export function writeMidi(song: Song): Uint8Array {
78 const chunks: Uint8Array[] = [];
79 let endTick = 0;
80 for (const t of song.tracks) for (const n of t.notes) endTick = Math.max(endTick, n.tick + n.duration);
82 const conductor: Ev[] = [];
83 for (const e of song.tempo) {
84 conductor.push({ tick: e.tick, order: 0, data: [0xff, 0x51, 0x03, (e.usPerQuarter >> 16) & 0xff, (e.usPerQuarter >> 8) & 0xff, e.usPerQuarter & 0xff] });
85 }
86 for (const s of song.timeSig) {
87 const dd = Math.round(Math.log2(s.denominator));
88 conductor.push({ tick: s.tick, order: 1, data: [0xff, 0x58, 0x04, s.numerator, dd, 24, 8] });
89 }
90 chunks.push(trackChunk(conductor, endTick));
92 let nextCh = 0;
93 for (const t of song.tracks) {
94 let ch: number;
95 if (t.instrumentId === 'drumkit') {
96 ch = 9;
97 } else {
98 if (nextCh === 9) nextCh++;
99 ch = nextCh % 16;
100 nextCh++;
101 }
102 const evs: Ev[] = [
103 { tick: 0, order: -3, data: nameMeta(t.name) },
104 { tick: 0, order: -2, data: instrumentMeta(t) },
105 ];
106 const prog = gmProgramForInstrument(t.instrumentId);
107 if (ch !== 9 && prog !== null) evs.push({ tick: 0, order: -1, data: [0xc0 | ch, prog] });
108 for (const n of t.notes) {
109 const vel = Math.max(1, Math.min(127, Math.round(n.velocity)));
110 const pitch = Math.max(0, Math.min(127, Math.round(n.pitch)));
111 evs.push({ tick: n.tick + n.duration, order: 0, data: [0x80 | ch, pitch, 0] });
112 evs.push({ tick: n.tick, order: 1, data: [0x90 | ch, pitch, vel] });
113 }
114 chunks.push(trackChunk(evs, endTick));
115 }
117 const w = new Writer();
118 w.raw([0x4d, 0x54, 0x68, 0x64]); // MThd
119 w.u32(6);
120 w.u16(1); // format 1
121 w.u16(chunks.length);
122 w.u16(song.ppq);
123 const head = w.toUint8Array();
124 const total = head.length + chunks.reduce((s, c) => s + c.length, 0);
125 const out = new Uint8Array(total);
126 out.set(head, 0);
127 let off = head.length;
128 for (const c of chunks) { out.set(c, off); off += c.length; }
129 return out;
↑↓ move↵openescclose