/ proof-of-concept / midiboot
Sign in
proof-of-concept / midiboot
Carry instrument ids and knob settings in exported MIDI files
Each track gets a sequencer-specific meta event (FF 7F, MDBT1 + JSON) with its instrument id and settings, restored exactly on import when the id is known and ignored by other players; a representative GM program change rides beside it so foreign players pick a plausible patch. Import validates embedded ids against the registry and falls back to the GM mapping.
Jeremy Magland <jmagland@flatironinstitute.org> committed commit ca4875ddd51c parent 348b677 Browse files
6 changed files+139−8
README.mdmodified+2−2View file
@@ -35,11 +35,11 @@ Dragging in the ruler makes a *time selection*, shown as a band across the roll,
3535
3636 ## Playback and rendering
3737
38-Playback is offline-first: each track is rendered by its instrument into a buffer, cached under a key derived from everything that affects the audio (notes, tempo map, ppq, instrument, knob settings), and the buffers are played through per-track gains. Volume, mute, and solo act at mix time and never cause a re-render, an arrangement borrowed from [instrawave-midi](../instrawave-midi). This is what makes the expensive instruments practical: a render costs what it costs, once, and editing anything else costs nothing. The trade-off is that pressing play after an edit waits for the affected track to re-render; for the builtin synths this is not noticeable, and for a simulated instrument the track row shows a progress bar with the render stage and a cancel button, with a one-line summary in the transport. Export produces a 16-bit mono WAV at 44.1 kHz rendered at each instrument's full quality tier, or a standard MIDI file (format 1, with the tempo and time signature maps and with drum tracks on channel 10).
38+Playback is offline-first: each track is rendered by its instrument into a buffer, cached under a key derived from everything that affects the audio (notes, tempo map, ppq, instrument, knob settings), and the buffers are played through per-track gains. Volume, mute, and solo act at mix time and never cause a re-render, an arrangement borrowed from [instrawave-midi](../instrawave-midi). This is what makes the expensive instruments practical: a render costs what it costs, once, and editing anything else costs nothing. The trade-off is that pressing play after an edit waits for the affected track to re-render; for the builtin synths this is not noticeable, and for a simulated instrument the track row shows a progress bar with the render stage and a cancel button, with a one-line summary in the transport. Export produces a 16-bit mono WAV at 44.1 kHz rendered at each instrument's full quality tier, or a standard MIDI file (format 1, with the tempo and time signature maps and with drum tracks on channel 10). Each exported track also carries its instrument id and knob settings in a sequencer-specific meta event, plus a representative GM program change, so re-importing one of our own files restores the instruments and their parameters exactly, while other players see an ordinary MIDI file and pick the nearest GM patch.
3939
4040 ## Files and storage
4141
42-Songs autosave to IndexedDB a moment after each edit, and the Library dialog lists, opens, renames, duplicates, and deletes them. Import reads standard MIDI files, formats 0 and 1, with running status and merged tempo maps; each (track, channel) pair carrying notes becomes an editable track, channel 10 defaults to the drum kit, other tracks get the builtin synth nearest their GM program, and track names fall back to General MIDI program names. Note that import keeps only notes, tempo, and time signature: controllers, pitch bend, and aftertouch are parsed past and dropped, and SMPTE-timed files are refused rather than misread.
42+Songs autosave to IndexedDB a moment after each edit, and the Library dialog lists, opens, renames, duplicates, and deletes them. Import reads standard MIDI files, formats 0 and 1, with running status and merged tempo maps; each (track, channel) pair carrying notes becomes an editable track; a midiboot-exported file restores each track's instrument and knob settings from the embedded meta event, while for other files channel 10 defaults to the drum kit, other tracks get the builtin synth nearest their GM program, and track names fall back to General MIDI program names. Note that import keeps only notes, tempo, and time signature: controllers, pitch bend, and aftertouch are parsed past and dropped, and SMPTE-timed files are refused rather than misread.
4343
4444 ## Development
4545
scripts/test-node.tsmodified+34−1View file
@@ -134,12 +134,45 @@ test('MIDI write/parse round trip preserves notes, tempo, and drums', () => {
134134 assert.deepEqual(sortedNotes(back.tracks[1]), sortedNotes(song.tracks[1]));
135135 assert.equal(back.tracks[0].name, 'melody');
136136 assert.equal(back.tracks[1].instrumentId, 'drumkit'); // came back on channel 10
137- assert.equal(back.tracks[0].instrumentId, 'piano'); // GM family mapping of the default program
137+ assert.equal(back.tracks[0].instrumentId, 'pluck'); // restored from the embedded meta
138138 assert.deepEqual(back.tempo, song.tempo);
139139 assert.equal(back.timeSig[0].numerator, 3);
140140 assert.equal(back.timeSig[0].denominator, 4);
141141 });
142142
143+test('instrument id and knob settings survive the MIDI round trip', () => {
144+ const song: Song = {
145+ ppq: 480,
146+ tempo: [{ tick: 0, usPerQuarter: 500000 }],
147+ timeSig: [{ tick: 0, numerator: 4, denominator: 4 }],
148+ tracks: [
149+ mkTrack('wb', 'woodblock', [mkNote(0, 240, 76, 100, 'a')], {
150+ settings: { strike_position: 0.3, mallet_stiffness: 2e8 },
151+ }),
152+ ],
153+ };
154+ const bytes = writeMidi(song);
155+
156+ // midiboot's own import: the embedded meta wins over the GM mapping.
157+ const known = new Set(['woodblock', 'drumkit', 'piano']);
158+ const back = parseMidi(bytes, known);
159+ assert.equal(back.tracks[0].instrumentId, 'woodblock');
160+ assert.deepEqual(back.tracks[0].settings, { strike_position: 0.3, mallet_stiffness: 2e8 });
161+
162+ // An id the registry does not know falls back to the GM mapping of the
163+ // written program change (woodblock exports program 115), and the
164+ // settings are dropped with it.
165+ const strangers = parseMidi(bytes, new Set(['piano', 'pluck', 'drumkit']));
166+ assert.equal(strangers.tracks[0].instrumentId, 'pluck');
167+ assert.equal(strangers.tracks[0].settings, undefined);
168+
169+ // A foreign file with no meta behaves as before: GM program mapping.
170+ const plain: Song = { ...song, tracks: [mkTrack('m', 'piano', [mkNote(0, 240, 60, 90, 'b')])] };
171+ const foreign = parseMidi(writeMidi(plain), known);
172+ assert.equal(foreign.tracks[0].instrumentId, 'piano');
173+ assert.equal(foreign.tracks[0].settings, undefined);
174+});
175+
143176 test('parse splits a multi-channel track by channel', () => {
144177 const song: Song = {
145178 ppq: 480,
src/midi/gm.tsmodified+15−0View file
@@ -52,6 +52,21 @@ export function gmProgramInstrument(program: number | undefined): string {
5252 return 'pluck'; // ethnic, percussive, effects
5353 }
5454
55+// Representative GM program per instrument id, written as a program
56+// change on export so foreign players pick a plausible patch. The
57+// simulated percussion has no true GM program; the nearest percussive
58+// program is used. Unknown ids get none.
59+const GM_PROGRAM_FOR_INSTRUMENT: Record<string, number> = {
60+ piano: 0, epiano: 4, organ: 16, pluck: 24, synthbass: 38,
61+ strings: 48, brass: 61, lead: 80,
62+ bass: 32, 'bass-muted': 32, recorder: 74, woodblock: 115,
63+ triangle: 112, cymbal2: 119, maraca: 113,
64+};
65+
66+export function gmProgramForInstrument(instrumentId: string): number | null {
67+ return GM_PROGRAM_FOR_INSTRUMENT[instrumentId] ?? null;
68+}
69+
5570 export function noteName(pitch: number): string {
5671 const names = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
5772 return `${names[((pitch % 12) + 12) % 12]}${Math.floor(pitch / 12) - 1}`;
src/midi/parse.tsmodified+55−2View file
@@ -1,6 +1,7 @@
11 import type { Note, Song, TempoEvent, TimeSigEvent, Track } from '../types.ts';
22 import { newId } from '../types.ts';
33 import { GM_PROGRAMS, gmProgramInstrument } from './gm.ts';
4+import { INSTRUMENT_META_PREFIX } from './write.ts';
45
56 // Standard MIDI file parser for formats 0 and 1, with running status and a
67 // merged tempo map. Each (SMF track, channel) pair that carries notes
@@ -35,7 +36,43 @@ class Reader {
3536
3637 interface RawNote { tick: number; duration: number; pitch: number; velocity: number; channel: number }
3738
38-export function parseMidi(data: Uint8Array): Song {
39+interface TrackInstrumentMeta {
40+ instrumentId?: string;
41+ settings?: Record<string, number>;
42+}
43+
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.
47+function 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+ }
70+}
71+
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. */
75+export function parseMidi(data: Uint8Array, knownInstrumentIds?: Set<string>): Song {
3976 const r = new Reader(data);
4077 const magic = String.fromCharCode(...r.bytes(4));
4178 if (magic !== 'MThd') throw new Error('not a standard MIDI file (missing MThd)');
@@ -62,6 +99,7 @@ export function parseMidi(data: Uint8Array): Song {
6299 let tick = 0;
63100 let running = 0;
64101 let trackName = '';
102+ let instrumentMeta: TrackInstrumentMeta | null = null;
65103 const notes: RawNote[] = [];
66104 const open = new Map<number, { tick: number; velocity: number }[]>();
67105 const programByChannel = new Map<number, number>();
@@ -93,6 +131,8 @@ export function parseMidi(data: Uint8Array): Song {
93131 timeSig.push({ tick, numerator: body[0], denominator: 1 << body[1] });
94132 } else if (type === 0x03 && trackName === '') {
95133 trackName = new TextDecoder().decode(body).trim();
134+ } else if (type === 0x7f && instrumentMeta === null) {
135+ instrumentMeta = parseInstrumentMeta(body);
96136 } else if (type === 0x2f) {
97137 break;
98138 }
@@ -149,10 +189,23 @@ export function parseMidi(data: Uint8Array): Song {
149189 let name = trackName;
150190 if (channels.length > 1 && name !== '') name = `${name} ch${ch + 1}`;
151191 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+ }
152204 tracks.push({
153205 id: newId('t'),
154206 name,
155- instrumentId: ch === 9 ? 'drumkit' : gmProgramInstrument(prog),
207+ instrumentId,
208+ ...(settings ? { settings } : {}),
156209 volume: 1,
157210 mute: false,
158211 solo: false,
src/midi/write.tsmodified+31−2View file
@@ -1,8 +1,14 @@
1-import type { Song } from '../types.ts';
1+import type { Song, Track } from '../types.ts';
2+import { gmProgramForInstrument } from './gm.ts';
23
34 // Standard MIDI file writer, format 1: one conductor track carrying the
45 // tempo and time signature maps, then one MTrk per song track. Drum-kit
56 // 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.
612
713 class Writer {
814 private bytes: number[] = [];
@@ -41,6 +47,24 @@ function trackChunk(events: Ev[], endTick: number): Uint8Array {
4147 return out.toUint8Array();
4248 }
4349
50+/** The midiboot sequencer-specific payload prefix, version 1. */
51+export const INSTRUMENT_META_PREFIX = 'MDBT1';
52+
53+function 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()];
66+}
67+
4468 function nameMeta(name: string): number[] {
4569 const enc = new TextEncoder().encode(name.slice(0, 60));
4670 const out = [0xff, 0x03];
@@ -75,7 +99,12 @@ export function writeMidi(song: Song): Uint8Array {
7599 ch = nextCh % 16;
76100 nextCh++;
77101 }
78- const evs: Ev[] = [{ tick: 0, order: -1, data: nameMeta(t.name) }];
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] });
79108 for (const n of t.notes) {
80109 const vel = Math.max(1, Math.min(127, Math.round(n.velocity)));
81110 const pitch = Math.max(0, Math.min(127, Math.round(n.pitch)));
src/state/store.tsmodified+2−1View file
@@ -3,6 +3,7 @@ import type { Song, SongMeta, SongRecord, Track } from '../types.ts';
33 import { defaultSong, newId, songEndTick } from '../types.ts';
44 import * as db from '../storage/db.ts';
55 import { parseMidi } from '../midi/parse.ts';
6+import { listInstruments } from '../instruments/registry.ts';
67 import { TempoMap } from '../midi/time.ts';
78 import { clearRange, deleteTimeFromEvents, deleteTimeRange, extractRange, insertTime, insertTimeIntoEvents, quantizeNotes } from '../midi/ops.ts';
89 import { player } from '../audio/player.ts';
@@ -237,7 +238,7 @@ class Store {
237238 async importMidi(fileName: string, data: Uint8Array): Promise<void> {
238239 let song: Song;
239240 try {
240- song = parseMidi(data);
241+ song = parseMidi(data, new Set(listInstruments().map((i) => i.id)));
241242 } catch (err) {
242243 this.set({ status: `Import failed: ${err instanceof Error ? err.message : String(err)}` });
243244 return;
↑↓ move↵openescclose