| scripts | |
| src | |
| .gitignore | |
| index.html | |
| package-lock.json | |
| package.json | |
| README.md | |
| tsconfig.json | |
| vite.config.ts |
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 an entire MIDI track 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, imported directly from their repos 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;
knobs?: KnobSpec[];
renderTrack(req: TrackRenderRequest): Promise<TrackRenderResult>;
noteOn?(ctx: BaseAudioContext, dest: AudioNode, pitch: number,
velocity: number, whenSec: number): LiveVoice;
}
There are two paths. The offline path, renderTrack, receives every note of a track with times already converted to seconds and produces one mono buffer for the whole track, which may run past the requested length so that a ring outliving the last note (a cymbal crash, a long bass tail) is kept rather than clipped; playback, mixing, and WAV export all go through it and mix buffers of unequal lengths. The whole track arrives in one call, so an instrument owns its rendering strategy end to end: it may render notes independently and cache them, share expensive pitch-independent stages across notes, or in principle simulate the track as one continuous process, which is what makes inter-note physics (a string choked by the next note, a cymbal heating up across a roll) expressible at all. The request carries a cooperative AbortSignal and an onProgress callback, so a seconds-long physical render is visible and cancellable from the transport and the track list. 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, imported directly from the sibling repos: each of bass (with a muted variant), woodblock, recorder, triangle, maraca, and cymbal2 carries an instrument/ module, a peer of its matlab/ sources and its own web app, that implements the interface above. The module owns a Web Worker running the MATLAB model through numbl, so a seconds-long solve never blocks the editor, and it owns its caching: distinct sounds (pitch, velocity quantized to eight levels, duration where the model actually reads it) render once and are summed at the onsets, and where the model's stages permit it, expensive pitch-independent intermediates are shared across notes (the bass reuses one coupled eigensolve per fingered pitch across velocities and repeats, and the triangle does the same with its FEM eigensolve). The note-to-parameter mapping, curated knobs included, lives in the same module and is the instrument author's business; midiboot knows nothing about any model. The backend is equally the module's business: the cymbal's time loop runs on WebGPU when the browser exposes it (the WGSL port living beside the model), with per-chunk progress and cancellation, and falls back to the numbl interpreter otherwise; midiboot cannot tell the difference. Even so, a full-quality cymbal stroke is a real computation (tens of seconds on an integrated GPU, minutes on the CPU), which is exactly what the per-track render cache and the progress UI exist for. The same interface, through the request's app-facing optional fields (params for the full parameter surface, mfiles for live-edited sources, wantDiagnostics for the plot payloads), is what each instrument's own web app renders through, so there is exactly one render path per model. Cancelling is cooperative, checked between render stages, and the caches survive it; a worker that never responds is torn down after a grace period. There is no live noteOn voice for these yet, so monitoring while recording falls back to the player's beep.
The builtin instruments are cheap synthetic ones, one per broad General MIDI family (an FM piano and e-piano, a drawbar organ, a Karplus-Strong pluck, a synth bass, a string pad, a saw brass section, a square lead, and a General MIDI drum kit), 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. Importing a MIDI file assigns each track the builtin nearest its GM program (channel 10 gets the drum kit), so an imported file is playable without a soundfont. The instrumentFromNoteDsp helper derives both paths from one per-note DSP 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, 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. 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).
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, 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.
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)
The simulated instruments are imported from the sibling repos as source, and their numbl dependency resolves from each module's own directory, so run npm install once in each ../../simulated_instruments/<repo>/instrument/ as well.
The tests cover the MIDI writer/parser round trip, tempo map arithmetic, quantize and transpose, mixing rules, the determinism and boundedness of the DSP, the GM program mapping, and the track-render contract (note timing, settings, progress, leveling, and abort) against a fake instrument, so they run fast and do not depend on the sibling repos. The tests that execute the actual models through numbl in node live with each model, in simulated_instruments/<repo>/instrument/ (npm test there); they check finiteness, decay, determinism under the fixed seed, cache hits, 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. Although the interface hands each instrument the whole track, the three current models still render notes independently and sum them, so inter-note physics (the bass choking a ringing string, legato breath on the recorder, a cymbal still moving when the next stroke lands) remains future work per instrument; the interface no longer stands in its way. Rendered notes persist across page loads: each instrument keeps its finished note renders in an in-memory LRU in front of an IndexedDB store (per-instrument sample budgets, least-recently-used trimming, and a 30-day age-out), so reopening a song replays it without re-rendering; the keys carry the .m code hash, so a model update simply stops matching and the stale entries age out. Curated knobs are the only editable parameters here; the full parameter surfaces stay in each instrument's own web app, which now renders through the same instrument module and remains the place to design a sound. Every current simulated_instruments repo is converted (cymbal is superseded by cymbal2); cymbal2, whose WebGPU time loop runs inside its instrument module, shows what a conversion looks like when the model warrants a backend of its own.