midiboot#
An in-browser application for recording, importing, editing, and playing MIDI. A limitation of most browser MIDI tools is that the instrument is fixed: notes go into a General MIDI soundfont or a hard-coded synth, and there is no place to put your own rendering logic. Here we describe a small workstation built around one extension point, an instrument interface that turns note events into audio, so that the built-in synths sit alongside custom renderers without changing the editor, the transport, or the file handling around them. The first such renderers are the physically based models of the simulated_instruments collection, executed client-side by numbl.
The instrument interface#
An instrument is anything implementing the interface in src/instruments/types.ts:
interface Instrument {
id: string;
name: string;
render(req: RenderRequest): Float32Array | Promise<Float32Array>;
noteOn?(ctx: BaseAudioContext, dest: AudioNode, pitch: number,
velocity: number, whenSec: number): LiveVoice;
}
There are two paths. The offline path, render, receives every note of a track with times already converted to seconds and produces one mono buffer; it may be async and arbitrarily expensive, and playback, mixing, and WAV export all go through it. The live path, noteOn, is optional and exists for low-latency monitoring while recording and for previewing notes from the piano roll; an instrument without it still works everywhere, with a plain beep standing in for monitoring. New instruments are added with registerInstrument in src/instruments/registry.ts; tracks refer to instruments by id, so a song whose instrument is missing falls back to a builtin rather than failing.
Alongside the builtins are the simulated instruments: each repository in ../simulated_instruments (standup bass, recorder, triangle, cymbal, maraca, woodblock) is a physically based model written in MATLAB syntax and executed by numbl, and each declares how it may be played in an instrument.json manifest committed next to its sources. The manifest names the entry function (y = entry(P) for one note), the defaults function, fixed parameter overrides (a single-event gesture, a fixed seed, normalization off so velocity survives in pascals), how MIDI pitch reaches a parameter (directly for the bass's root_midi, through a nearest-entry fingering table for the recorder, or not at all for the unpitched percussion), how velocity 0..1 maps linearly onto an excitation range, optionally how note duration reaches a parameter (the recorder's note_dur_s; the others treat notes as onsets), fast-tier overrides (oversample: 1), and a curated list of knobs. Crafting a new instrument for midiboot is therefore writing a model and its manifest in a new sibling repository; midiboot discovers every manifest at build time and nothing in the application is instrument-specific. The schema's one definition, with validation, lives in src/instruments/simulated/manifest.ts.
The models render in a Web Worker so a seconds-long solve never blocks the editor. Playback renders each distinct note once (velocity quantized to eight levels, duration to quarter seconds), caches it in an LRU bounded by total samples, places copies at the onsets, and sums; a repeated drum pattern therefore costs one render. The knobs a manifest declares appear as sliders on the track (the active row), stored per track and invalidating that track's render when moved. The trade-off of per-note rendering is the loss of inter-note physics: the bass's choke of a note when the next lands, and a cymbal's energy build-up across a roll, are outside this arrangement, and a model whose expensive stage does not depend on the note (the cymbal's eigensolve) repeats it per distinct velocity rather than caching it across notes.
The four builtin instruments (Karplus-Strong pluck, FM electric piano, a filtered square lead, and a General MIDI drum kit) are constructed from pure per-note DSP functions in src/instruments/dsp.ts. These are deterministic, with noise drawn from a PRNG seeded by pitch, so the node test harness checks the actual audio. The instrumentFromNoteDsp helper derives both paths from one such function; a custom instrument is free to ignore it and implement the interface directly.
Recording#
Recording takes input from any connected MIDI device through the Web MIDI API, following hot-plugging via statechange, with a selector for one input or all of them at once. There is also a typing-keyboard mode (the a through ' row plays white keys, the row above the black keys, z and x shift the octave) for working without hardware. Pressing record gives a one-bar count-in of clicks at the current tempo and signature, then captures note on/off against the transport clock while the other tracks play; input is monitored live through the armed track's instrument. When the transport stops, the take is quantized only in the sense of being converted to ticks, is committed to the armed track as a single undo step, and is left selected so that Quantize (to the snap grid) is one click away.
Editing#
The piano roll is a single canvas: ruler, key column, note grid, and a velocity lane. Click on empty space to add a note (drag right to set its length), drag notes to move them, drag a note's right edge to resize, shift+drag for marquee selection, right-click to delete, and drag in the velocity lane to paint velocities. Snap is selectable from a bar down to 1/32, holding alt bypasses it, and ctrl+wheel zooms. The active track is edited in full color while the other tracks show as ghosts. Undo and redo (ctrl+z, ctrl+shift+z) cover every edit, with a whole drag gesture counting as one step. Tracks carry name, instrument, volume, mute, solo, and a record arm.
Playback and rendering#
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), 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. This is what makes expensive future 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, which for the builtin synths is not noticeable. Export produces a 16-bit mono WAV at 44.1 kHz, or a standard MIDI file (format 1, with the tempo and time signature maps and with drum tracks on channel 10).
Files and storage#
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, 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.
Development#
npm install
npm run dev # vite dev server
npm run build # typecheck and bundle
npm test # node-side tests (no browser, no audio device)
npm run test:sim # renders real notes through numbl in node; slower
The tests cover the MIDI writer/parser round trip, tempo map arithmetic, quantize and transpose, mixing rules, the determinism and boundedness of the DSP, and validation of every instrument manifest in the sibling collection. npm run test:sim additionally executes the actual models through numbl in node and checks the audio: finiteness, decay, determinism under the fixed seed, and that velocity and knobs genuinely change the sound.
Limitations and future work#
The mixdown is mono, matching the mono instrument interface; a stereo render path is a straightforward extension. There is no loop region, no controller or pitch bend data, and editing the tempo of a song that already has a tempo map is not supported (the bpm field edits only single-tempo songs rather than silently flattening the map). The live path plays each note as a pre-rendered buffer with a gain release, which is fine for monitoring but is not a substitute for a real streaming voice. The simulated instruments have no live monitoring voice, so recording against one monitors through a plain beep; a small pre-rendered voice bank per instrument is the natural fix. Note caches live in worker memory and are redone per page load; persisting them in IndexedDB, keyed the way the render cache already is, is a straightforward extension. Manifest knobs are the only editable parameters; the full parameter surfaces stay in each instrument's own web app, which remains the place to design a sound before curating its knobs here.