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, packaged as self-contained instrument bundles and 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 exported numbl instruments. Each repository in ../simulated_instruments (standup bass, recorder, triangle, cymbal, maraca, woodblock) is a physically based model written in MATLAB syntax, and each exports one or more instrument bundles: self-contained JSON files, format numbl-instrument-v1, carrying the model's sources together with driver functions, written by the instrument author in the same MATLAB syntax, that map one MIDI note onto the model (y = render_note(note), with an optional state = prepare(params) for pitch-independent setup). The note struct carries pitch, velocity 0..1, duration in seconds, sample rate, a preview flag, and the values of the curated knobs the bundle declares; how those become model parameters is entirely the driver's business, so midiboot knows nothing about any model. The format, the driver contract, and the export build script are specified in instrument-format, and one repository may export several bundles (the bass ships a muted variant with the damping baked in). Committed bundles under each repo's exports/ are discovered at build time; a bundle file can also be imported at runtime from the track panel, persisted in IndexedDB, and an import whose id matches a catalog bundle shadows it, which is how a newer export is tried without rebuilding the app. The vendored format definition, with validation, lives in src/instruments/numbl/bundle.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 bundle 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. The gestures follow the usual DAW conventions: clicking empty space moves the playhead there and clears the selection, dragging on empty space is a marquee selection (shift extends it), double-clicking adds a note at the snapped position (and deletes one when aimed at a note), dragging notes moves them (alt+drag moves a copy), dragging either edge of a note resizes it, right-click deletes, holding shift while dragging bypasses the snap grid, and dragging in the velocity lane paints velocities. Ctrl+wheel zooms horizontally and alt+wheel vertically. The selection responds to the expected keys: delete removes it, ctrl+A selects the active track, ctrl+C/X/V copy, cut, and paste at the playhead, ctrl+D duplicates in place after itself, ctrl+E splits the selected notes (or the active track's notes) at the playhead, and the arrow keys nudge by the snap grid horizontally and by semitones (shift for octaves) vertically. Home and End move the playhead to the start and end of the song, and tracks can be reordered and duplicated from the track list.
Dragging in the ruler makes a time selection, shown as a band across the roll, and this is where the arrangement edits live. The transport then offers: Select (the notes inside it), Clear (delete those notes, keeping the time; also the delete key when no notes are selected), Cut time (remove the span on every track and close the gap, with the tempo and time-signature maps rippling along), Insert (open that much empty time at the selection start), and Dup (insert a copy of the span immediately after itself and move the selection onto the copy). Notes crossing a boundary are trimmed, and a note spanning the whole range is split, so the edits behave the way region editing does in a conventional DAW. The Loop button (ctrl+L) plays the time selection on repeat; the loop restarts from cached renders, so the seam is small. Escape clears the time selection, and clicking the ruler moves the playhead as before. 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.