/ proof-of-concept / midiboot
Sign in
proof-of-concept / midiboot
Switch to exported instrument bundles with numbl drivers
The simulated-instrument manifest path is replaced by the numbl-instrument-v1 format (simulated_instruments/instrument-format): each bundle is self-contained JSON whose own MATLAB-syntax drivers map a MIDI note onto the model (render_note, with an optional prepare stage run once per batch), so the app no longer knows any model parameter. Committed exports/*.json are discovered at build time; bundle files can also be imported at runtime from the track panel, persisted in IndexedDB, shadowing a catalog bundle of the same id. The worker renders cache misses in small batched programs, keeping per-chunk progress.
Jeremy Magland <jmagland@flatironinstitute.org> committed commit 0557e3bca712 parent f67f407 Browse files
18 changed files+603−479
README.mdmodified+3−3View file
@@ -1,6 +1,6 @@
11 # midiboot
22
3-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](../../simulated_instruments) collection, executed client-side by [numbl](https://numbl.org).
3+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](../../simulated_instruments) collection, packaged as self-contained instrument bundles and executed client-side by [numbl](https://numbl.org).
44
55 ## The instrument interface
66
@@ -18,9 +18,9 @@ interface Instrument {
1818
1919 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](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.
2020
21-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](src/instruments/simulated/manifest.ts).
21+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](../../simulated_instruments/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](src/instruments/numbl/bundle.ts).
2222
23-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.
23+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.
2424
2525 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](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.
2626
scripts/test-node.tsmodified+62−43View file
@@ -9,7 +9,8 @@ import { clearRange, deleteTimeFromEvents, deleteTimeRange, extractRange, insert
99 import { drumNote, epianoNote, leadNote, pluckNote } from '../src/instruments/dsp.ts';
1010 import { builtinInstruments } from '../src/instruments/builtins.ts';
1111 import { audibleTracks, encodeWavMono16, mixTracks, trackRenderKey } from '../src/audio/render.ts';
12-import { bucketVelocity, mapPitch, mapVelocity, parseManifest } from '../src/instruments/simulated/manifest.ts';
12+import { parseBundle } from '../src/instruments/numbl/bundle.ts';
13+import { bucketDuration, bucketVelocity, buildProgram, noteCacheKey } from '../src/instruments/numbl/render.ts';
1314 import { readdirSync, readFileSync, existsSync } from 'node:fs';
1415 import { join } from 'node:path';
1516 import type { Note, Song, Track } from '../src/types.ts';
@@ -308,68 +309,86 @@ test('WAV encoding writes a well-formed header', () => {
308309 assert.equal(dv.getInt16(44 + 6, true), 32767); // clipped to full scale
309310 });
310311
311-// --- simulated-instrument manifests ---
312+// --- exported instrument bundles ---
312313
313314 const SIM_ROOT = join(import.meta.dirname, '../../../simulated_instruments');
314315
315-test('every instrument.json in ../simulated_instruments validates', () => {
316+test('every committed bundle in ../simulated_instruments validates', () => {
316317 if (!existsSync(SIM_ROOT)) return; // vault layout absent; nothing to check
317- const dirs = readdirSync(SIM_ROOT).filter((d) => existsSync(join(SIM_ROOT, d, 'instrument.json')));
318- assert.ok(dirs.length >= 1, 'no manifests found');
318+ const dirs = readdirSync(SIM_ROOT).filter((d) => existsSync(join(SIM_ROOT, d, 'exports')));
319+ assert.ok(dirs.length >= 1, 'no exports found');
320+ let count = 0;
319321 for (const d of dirs) {
320- const m = parseManifest(readFileSync(join(SIM_ROOT, d, 'instrument.json'), 'utf8'));
321- assert.equal(m.name, d, `manifest name '${m.name}' should match its directory '${d}'`);
322- assert.ok(existsSync(join(SIM_ROOT, d, 'matlab', `${m.entry}.m`)), `${d}: entry ${m.entry}.m missing`);
323- assert.ok(existsSync(join(SIM_ROOT, d, 'matlab', `${m.defaults}.m`)), `${d}: defaults ${m.defaults}.m missing`);
322+ for (const f of readdirSync(join(SIM_ROOT, d, 'exports')).filter((f) => f.endsWith('.json'))) {
323+ const b = parseBundle(readFileSync(join(SIM_ROOT, d, 'exports', f), 'utf8'));
324+ assert.equal(`${b.id}.json`, f, `bundle id '${b.id}' should match its filename '${f}'`);
325+ count++;
326+ }
324327 }
328+ assert.ok(count >= 7, `expected at least 7 bundles, found ${count}`);
325329 });
326330
327-test('manifest pitch and velocity mapping', () => {
328- const m = parseManifest(JSON.stringify({
329- format: 'simulated-instrument-v1', name: 't', label: 'T', entry: 'e', defaults: 'd',
330- fixed: {}, preview: {},
331- pitch: { param: 'root_midi', mode: 'midi', min: 28, max: 55 },
332- velocity: { param: 'force', min: 2, max: 12 },
333- knobs: [],
334- }));
335- assert.deepEqual(mapPitch(m, 40), { key: 'root_midi', value: 40 });
336- assert.deepEqual(mapPitch(m, 10), { key: 'root_midi', value: 28 }); // clamped
337- assert.deepEqual(mapVelocity(m, 0), { key: 'force', value: 2 });
338- assert.deepEqual(mapVelocity(m, 1), { key: 'force', value: 12 });
339- const t = parseManifest(JSON.stringify({
340- format: 'simulated-instrument-v1', name: 't2', label: 'T', entry: 'e', defaults: 'd',
341- fixed: {}, preview: {},
342- pitch: { param: 'note1', mode: 'table', table: [{ midi: 72, value: 1 }, { midi: 74, value: 2 }] },
343- velocity: { param: 'v', min: 0, max: 1 },
344- knobs: [],
345- }));
346- assert.deepEqual(mapPitch(t, 73), { key: 'note1', value: 1 }); // nearest, ties go low
347- assert.deepEqual(mapPitch(t, 90), { key: 'note1', value: 2 });
348- const un = parseManifest(JSON.stringify({
349- format: 'simulated-instrument-v1', name: 't3', label: 'T', entry: 'e', defaults: 'd',
350- fixed: {}, preview: {}, velocity: { param: 'v', min: 0, max: 1 }, knobs: [],
331+const TEST_BUNDLE = {
332+ format: 'numbl-instrument-v1',
333+ id: 'test',
334+ name: 'Test',
335+ knobs: [{ key: 'k', label: 'K', min: 1, max: 5, default: 2 }],
336+ files: [{ name: 'render_note.m', content: 'function y = render_note(note)\ny = [note.pitch];\nend' }],
337+};
338+
339+test('buildProgram assigns knobs clamped and omits prepare without prepare.m', () => {
340+ const b = parseBundle(JSON.stringify(TEST_BUNDLE));
341+ const code = buildProgram(b, [{ pitch: 60, velocity: 0.5, durationSec: 1 }], {
342+ sampleRate: 22050, quality: 'preview', settings: { k: 99 },
343+ });
344+ assert.ok(code.includes('params.k = 5;'), 'knob clamped to its max');
345+ assert.ok(code.includes('note.pitch = 60;'));
346+ assert.ok(code.includes('params.preview = 1;'));
347+ assert.ok(!code.includes('prepare('), 'no prepare stage without prepare.m');
348+ const withPrep = parseBundle(JSON.stringify({
349+ ...TEST_BUNDLE,
350+ files: [...TEST_BUNDLE.files, { name: 'prepare.m', content: 'function state = prepare(params)\nstate = struct();\nend' }],
351351 }));
352- assert.equal(mapPitch(un, 60), null);
352+ const code2 = buildProgram(withPrep, [{ pitch: 60, velocity: 0.5, durationSec: 1 }], {
353+ sampleRate: 44100, quality: 'full', settings: {},
354+ });
355+ assert.ok(code2.includes('state = prepare(params);'));
356+ assert.ok(code2.includes('note.state = state;'));
357+ assert.ok(code2.includes('params.k = 2;'), 'missing knob takes its default');
358+ assert.ok(code2.includes('params.preview = 0;'));
353359 });
354360
355-test('velocity bucketing quantizes into few levels', () => {
361+test('velocity and duration bucketing quantize into few levels', () => {
356362 assert.equal(bucketVelocity(1), 1);
357363 assert.equal(bucketVelocity(0.999), 1);
358364 assert.equal(bucketVelocity(0.51), 0.625);
359365 assert.equal(bucketVelocity(0.01), 0.125);
360366 assert.ok(bucketVelocity(0) > 0);
367+ assert.equal(bucketDuration(0.9), 1);
368+ assert.equal(bucketDuration(0.05), 0.25);
361369 });
362370
363-test('manifest validation rejects malformed knobs', () => {
364- assert.throws(() => parseManifest(JSON.stringify({
365- format: 'simulated-instrument-v1', name: 'x', label: 'X', entry: 'e', defaults: 'd',
366- fixed: {}, preview: {}, velocity: { param: 'v', min: 0, max: 1 },
371+test('noteCacheKey separates settings, pitch, and quality', () => {
372+ const b = parseBundle(JSON.stringify(TEST_BUNDLE));
373+ const k1 = noteCacheKey(b, 'preview', 22050, {}, 60, 1, 0.5);
374+ const k2 = noteCacheKey(b, 'preview', 22050, { k: 3 }, 60, 1, 0.5);
375+ const k3 = noteCacheKey(b, 'preview', 22050, {}, 62, 1, 0.5);
376+ const k4 = noteCacheKey(b, 'full', 22050, {}, 60, 1, 0.5);
377+ assert.ok(k1 !== k2 && k1 !== k3 && k1 !== k4 && k2 !== k3);
378+});
379+
380+test('bundle validation rejects malformed input', () => {
381+ assert.throws(() => parseBundle(JSON.stringify({ ...TEST_BUNDLE, format: 'wrong' })));
382+ assert.throws(() => parseBundle(JSON.stringify({ ...TEST_BUNDLE, id: 'Bad Id' })));
383+ assert.throws(() => parseBundle(JSON.stringify({ ...TEST_BUNDLE, files: [] })));
384+ assert.throws(() => parseBundle(JSON.stringify({
385+ ...TEST_BUNDLE,
386+ files: [{ name: 'other.m', content: 'x = 1;' }],
387+ })), /render_note/);
388+ assert.throws(() => parseBundle(JSON.stringify({
389+ ...TEST_BUNDLE,
367390 knobs: [{ key: 'k', label: 'K', min: 5, max: 1, default: 2 }],
368391 })));
369- assert.throws(() => parseManifest(JSON.stringify({
370- format: 'wrong', name: 'x', label: 'X', entry: 'e', defaults: 'd',
371- fixed: {}, preview: {}, velocity: { param: 'v', min: 0, max: 1 }, knobs: [],
372- })));
373392 });
374393
375394 console.log(`\n${passed} passed, ${failed} failed`);
scripts/test-sim.tsmodified+47−24View file
@@ -1,25 +1,21 @@
11 /**
2- * Integration test for the simulated-instrument path: real manifests, real
3- * MATLAB sources, real numbl execution in node (no browser, no worker).
4- * Renders a woodblock strike and a bass pluck at the preview tier and
5- * checks the audio is shaped right, including that velocity actually
6- * changes the result. Run with `npm run test:sim`; slower than npm test
7- * because it runs the physics.
2+ * Integration test for the exported-instrument path: real committed
3+ * bundles, real numbl execution in node (no browser, no worker). Renders
4+ * woodblock strikes and bass plucks at the preview tier through the same
5+ * renderNoteBatch the worker uses, and checks the audio is shaped right,
6+ * including that velocity actually changes the result and that the
7+ * recorder's prepare stage feeds its fingering chart through. Run with
8+ * `npm run test:sim`; slower than npm test because it runs the physics.
89 */
9-import { readdirSync, readFileSync, existsSync } from 'node:fs';
10+import { readFileSync, existsSync } from 'node:fs';
1011 import { join } from 'node:path';
11-import { parseManifest, type SimManifest } from '../src/instruments/simulated/manifest.ts';
12-import { renderSimNote, type MFile } from '../src/instruments/simulated/mrender.ts';
12+import { parseBundle, type InstrumentBundle } from '../src/instruments/numbl/bundle.ts';
13+import { renderNoteBatch } from '../src/instruments/numbl/render.ts';
1314
1415 const ROOT = join(import.meta.dirname, '../../../simulated_instruments');
1516
16-function loadDef(name: string): { manifest: SimManifest; mfiles: MFile[] } {
17- const manifest = parseManifest(readFileSync(join(ROOT, name, 'instrument.json'), 'utf8'));
18- const mdir = join(ROOT, name, 'matlab');
19- const mfiles = readdirSync(mdir)
20- .filter((f) => f.endsWith('.m'))
21- .map((f) => ({ name: f, source: readFileSync(join(mdir, f), 'utf8') }));
22- return { manifest, mfiles };
17+function loadBundle(repo: string, id: string): InstrumentBundle {
18+ return parseBundle(readFileSync(join(ROOT, repo, 'exports', `${id}.json`), 'utf8'));
2319 }
2420
2521 let failures = 0;
@@ -47,36 +43,63 @@ if (!existsSync(ROOT)) {
4743 const SR = 44100;
4844
4945 {
50- const { manifest, mfiles } = loadDef('woodblock');
46+ const b = loadBundle('woodblock', 'woodblock');
5147 const t0 = performance.now();
52- const soft = renderSimNote(manifest, mfiles, { pitch: 60, velocity: 0.25, durationSec: 0.25, sampleRate: SR, quality: 'preview', settings: {} });
53- const hard = renderSimNote(manifest, mfiles, { pitch: 60, velocity: 1.0, durationSec: 0.25, sampleRate: SR, quality: 'preview', settings: {} });
48+ const [soft, hard] = renderNoteBatch(b, [
49+ { pitch: 60, velocity: 0.25, durationSec: 0.25 },
50+ { pitch: 60, velocity: 1.0, durationSec: 0.25 },
51+ ], { sampleRate: SR, quality: 'preview', settings: {} });
5452 console.log(`woodblock: two strikes in ${((performance.now() - t0) / 1000).toFixed(1)} s`);
5553 check(soft.length > SR * 0.2, `woodblock trace has audio length (${soft.length})`);
5654 check(finite(soft) && finite(hard), 'woodblock samples finite');
5755 check(peak(soft) > 0, 'woodblock soft strike sounds');
5856 check(peak(hard) > peak(soft) * 1.5, `harder mallet is louder (${peak(hard).toExponential(2)} vs ${peak(soft).toExponential(2)})`);
59- const moved = renderSimNote(manifest, mfiles, { pitch: 60, velocity: 1.0, durationSec: 0.25, sampleRate: SR, quality: 'preview', settings: { strike_position: 0.25 } });
57+ const [moved] = renderNoteBatch(b, [{ pitch: 60, velocity: 1.0, durationSec: 0.25 }], {
58+ sampleRate: SR, quality: 'preview', settings: { strike_position: 0.25 },
59+ });
6060 let differs = moved.length !== hard.length;
6161 for (let i = 0; i < Math.min(moved.length, hard.length) && !differs; i++) if (moved[i] !== hard[i]) differs = true;
6262 check(differs, 'strike position knob changes the sound');
6363 }
6464
6565 {
66- const { manifest, mfiles } = loadDef('bass');
66+ const b = loadBundle('bass', 'bass');
6767 const t0 = performance.now();
68- const y = renderSimNote(manifest, mfiles, { pitch: 33, velocity: 0.7, durationSec: 0.5, sampleRate: SR, quality: 'preview', settings: { tail_s: 2.0 } });
68+ const [y] = renderNoteBatch(b, [{ pitch: 33, velocity: 0.7, durationSec: 0.5 }], {
69+ sampleRate: SR, quality: 'preview', settings: { tail_s: 2.0 },
70+ });
6971 console.log(`bass: one pluck in ${((performance.now() - t0) / 1000).toFixed(1)} s`);
7072 check(finite(y), 'bass samples finite');
7173 check(peak(y) > 0, 'bass pluck sounds');
7274 check(Math.abs(y.length - SR * 2.0) < SR * 0.2, `bass trace spans the tail (${y.length} samples)`);
7375 check(peak(y, 0, Math.round(SR * 0.5)) > peak(y, Math.round(SR * 1.5)), 'bass pluck decays');
7476 // Determinism: fixed seed, same request, same samples.
75- const y2 = renderSimNote(manifest, mfiles, { pitch: 33, velocity: 0.7, durationSec: 0.5, sampleRate: SR, quality: 'preview', settings: { tail_s: 2.0 } });
77+ const [y2] = renderNoteBatch(b, [{ pitch: 33, velocity: 0.7, durationSec: 0.5 }], {
78+ sampleRate: SR, quality: 'preview', settings: { tail_s: 2.0 },
79+ });
7680 let same = y.length === y2.length;
7781 for (let i = 0; i < y.length && same; i++) if (y[i] !== y2[i]) same = false;
7882 check(same, 'bass render is deterministic');
83+ // The muted variant of the same repo is a distinct bundle with its own driver.
84+ const muted = loadBundle('bass', 'bass-muted');
85+ const [ym] = renderNoteBatch(muted, [{ pitch: 33, velocity: 0.7, durationSec: 0.5 }], {
86+ sampleRate: SR, quality: 'preview', settings: {},
87+ });
88+ check(finite(ym) && peak(ym) > 0, 'muted bass sounds');
89+ check(ym.length < y.length, `muted bass rings shorter (${ym.length} vs ${y.length} samples)`);
7990 }
8091
81-console.log(failures === 0 ? 'simulated-instrument test passed' : `${failures} failure(s)`);
92+{
93+ // The recorder exercises the prepare stage (fingering chart in state)
94+ // at a low sample rate to keep the jet integration quick.
95+ const b = loadBundle('recorder', 'recorder');
96+ const t0 = performance.now();
97+ const [g5] = renderNoteBatch(b, [{ pitch: 79, velocity: 0.6, durationSec: 0.3 }], {
98+ sampleRate: 22050, quality: 'preview', settings: {},
99+ });
100+ console.log(`recorder: one note in ${((performance.now() - t0) / 1000).toFixed(1)} s`);
101+ check(finite(g5) && peak(g5) > 0, 'recorder note sounds (prepare state reached the driver)');
102+}
103+
104+console.log(failures === 0 ? 'exported-instrument test passed' : `${failures} failure(s)`);
82105 process.exit(failures > 0 ? 1 : 0);
src/components/TrackList.tsxmodified+54−0View file
@@ -1,8 +1,61 @@
1+import { useRef } from 'react';
12 import { store, useApp } from '../state/store.ts';
23 import { getInstrument, listInstruments } from '../instruments/registry.ts';
34 import type { KnobSpec } from '../instruments/types.ts';
5+import { importInstrumentText, listImportedIds, removeImportedInstrument } from '../instruments/numbl/adapter.ts';
46 import { trackColor } from './colors.ts';
57
8+// Imported instrument bundles: a file picker that accepts exported
9+// numbl-instrument-v1 JSON, and a chip per import so a stale one can be
10+// removed (a shadowed catalog instrument of the same id comes back).
11+function ImportedInstruments() {
12+ const fileRef = useRef<HTMLInputElement>(null);
13+ const imported = listImportedIds();
14+ const onFiles = async (files: FileList | null) => {
15+ if (!files) return;
16+ for (const f of files) {
17+ try {
18+ const bundle = await importInstrumentText(await f.text());
19+ store.setStatus(`imported instrument '${bundle.name}'`);
20+ } catch (err) {
21+ store.setStatus(err instanceof Error ? err.message : String(err));
22+ }
23+ }
24+ store.bumpInstruments();
25+ };
26+ return (
27+ <div className="importinstr">
28+ <button
29+ className="addtrack"
30+ title="Import an exported instrument bundle (numbl-instrument-v1 JSON)"
31+ onClick={() => fileRef.current?.click()}
32+ >+ Import instrument</button>
33+ <input
34+ ref={fileRef}
35+ type="file"
36+ accept=".json,application/json"
37+ multiple
38+ style={{ display: 'none' }}
39+ onChange={(e) => { void onFiles(e.target.files); e.target.value = ''; }}
40+ />
41+ {imported.length > 0 && (
42+ <div className="importedlist">
43+ {imported.map((id) => (
44+ <span key={id} className="importedchip">
45+ {id}
46+ <button
47+ className="mini danger"
48+ title="Remove imported instrument"
49+ onClick={() => { void removeImportedInstrument(id).then(() => store.bumpInstruments()); }}
50+ >✕</button>
51+ </span>
52+ ))}
53+ </div>
54+ )}
55+ </div>
56+ );
57+}
58+
659 function fmtKnob(v: number): string {
760 const a = Math.abs(v);
861 if (a >= 1e5 || (a > 0 && a < 0.01)) return v.toExponential(1);
@@ -119,6 +172,7 @@ export function TrackList() {
119172 );
120173 })}
121174 <button className="addtrack" onClick={() => store.addTrack()}>+ Add track</button>
175+ <ImportedInstruments />
122176 </div>
123177 );
124178 }
src/instruments/numbl/adapter.tsadded+134−0View file
@@ -0,0 +1,134 @@
1+/**
2+ * Exported numbl instruments behind the generic Instrument interface:
3+ * self-contained bundles (format numbl-instrument-v1, see
4+ * simulated_instruments/instrument-format) whose own MATLAB-syntax
5+ * drivers map each MIDI note onto the model and render it, executed by
6+ * numbl in a worker and mixed per track. Bundles arrive two ways: the
7+ * committed exports of the sibling simulated_instruments repos, inlined
8+ * at build time, and files imported at runtime, persisted in IndexedDB.
9+ * An imported bundle with a catalog bundle's id shadows it, which is how
10+ * a newer export is dropped in without rebuilding the app. There is no
11+ * live noteOn voice yet, so monitoring while recording falls back to the
12+ * player's beep.
13+ */
14+import type { Instrument, RenderRequest } from '../types.ts';
15+import { registerInstrument, unregisterInstrument } from '../registry.ts';
16+import { loadCatalog } from './catalog.ts';
17+import { parseBundle, type InstrumentBundle } from './bundle.ts';
18+import * as db from '../../storage/db.ts';
19+import type { WorkerRenderMsg, WorkerResponse } from './worker.ts';
20+
21+type StatusFn = (msg: string) => void;
22+let onStatus: StatusFn = () => {};
23+export function setNumblStatus(cb: StatusFn): void {
24+ onStatus = cb;
25+}
26+
27+let worker: Worker | null = null;
28+let nextId = 1;
29+const pending = new Map<number, { resolve: (v: Float32Array) => void; reject: (e: Error) => void }>();
30+
31+function ensureWorker(): Worker {
32+ if (worker) return worker;
33+ worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
34+ worker.onmessage = (ev: MessageEvent<WorkerResponse>) => {
35+ const msg = ev.data;
36+ if (msg.kind === 'status') {
37+ onStatus(msg.text);
38+ return;
39+ }
40+ const p = pending.get(msg.id);
41+ if (!p) return;
42+ pending.delete(msg.id);
43+ if (msg.kind === 'result') p.resolve(msg.samples);
44+ else p.reject(new Error(msg.message));
45+ };
46+ worker.onerror = (ev) => {
47+ const err = new Error(`numbl instrument worker failed: ${ev.message}`);
48+ for (const p of pending.values()) p.reject(err);
49+ pending.clear();
50+ };
51+ return worker;
52+}
53+
54+class NumblInstrument implements Instrument {
55+ readonly id: string;
56+ readonly name: string;
57+ readonly knobs;
58+
59+ constructor(readonly bundle: InstrumentBundle, imported: boolean) {
60+ this.id = `nb-${bundle.id}`;
61+ this.name = imported ? `${bundle.name} (imported)` : bundle.name;
62+ this.knobs = bundle.knobs;
63+ }
64+
65+ render(req: RenderRequest): Promise<Float32Array> {
66+ if (req.notes.length === 0) return Promise.resolve(new Float32Array(req.lengthSamples));
67+ const w = ensureWorker();
68+ const id = nextId++;
69+ const msg: WorkerRenderMsg = {
70+ kind: 'render',
71+ id,
72+ bundle: this.bundle,
73+ notes: req.notes.map((n) => ({ pitch: n.pitch, velocity: n.velocity, durationSec: n.durationSec, timeSec: n.timeSec })),
74+ sampleRate: req.sampleRate,
75+ lengthSamples: req.lengthSamples,
76+ quality: req.quality ?? 'preview',
77+ settings: req.settings ?? {},
78+ };
79+ return new Promise<Float32Array>((resolve, reject) => {
80+ pending.set(id, { resolve, reject });
81+ w.postMessage(msg);
82+ });
83+ }
84+}
85+
86+const catalogById = new Map<string, InstrumentBundle>();
87+const importedIds = new Set<string>();
88+
89+export function registerCatalogInstruments(): number {
90+ for (const bundle of loadCatalog()) {
91+ catalogById.set(bundle.id, bundle);
92+ registerInstrument(new NumblInstrument(bundle, false));
93+ }
94+ return catalogById.size;
95+}
96+
97+/** Re-register persisted imports at startup; call after the catalog. */
98+export async function loadImportedInstruments(): Promise<number> {
99+ const recs = await db.listImportedInstruments();
100+ let n = 0;
101+ for (const rec of recs) {
102+ try {
103+ const bundle = parseBundle(rec.text);
104+ registerInstrument(new NumblInstrument(bundle, true));
105+ importedIds.add(bundle.id);
106+ n++;
107+ } catch (err) {
108+ console.warn(`skipping imported instrument '${rec.id}': ${err instanceof Error ? err.message : String(err)}`);
109+ }
110+ }
111+ return n;
112+}
113+
114+/** Validate, persist, and register one bundle file's text. */
115+export async function importInstrumentText(text: string): Promise<InstrumentBundle> {
116+ const bundle = parseBundle(text);
117+ await db.putImportedInstrument({ id: bundle.id, text, importedAt: Date.now() });
118+ registerInstrument(new NumblInstrument(bundle, true));
119+ importedIds.add(bundle.id);
120+ return bundle;
121+}
122+
123+export function listImportedIds(): string[] {
124+ return [...importedIds].sort();
125+}
126+
127+/** Remove an import; a shadowed catalog bundle of the same id comes back. */
128+export async function removeImportedInstrument(bundleId: string): Promise<void> {
129+ await db.deleteImportedInstrument(bundleId);
130+ importedIds.delete(bundleId);
131+ const cat = catalogById.get(bundleId);
132+ if (cat) registerInstrument(new NumblInstrument(cat, false));
133+ else unregisterInstrument(`nb-${bundleId}`);
134+}
src/instruments/numbl/bundle.tsadded+87−0View file
@@ -0,0 +1,87 @@
1+// Vendored from simulated_instruments/instrument-format/ts/bundle.ts, the
2+// format's canonical definition; keep the two in sync.
3+
4+export interface BundleKnob {
5+ key: string;
6+ label: string;
7+ min: number;
8+ max: number;
9+ default: number;
10+ step?: number;
11+ scale?: 'log';
12+}
13+
14+export interface BundleFile {
15+ name: string; // e.g. "render_note.m"; unique within the bundle
16+ content: string; // full source, MATLAB syntax, executed by numbl
17+}
18+
19+export interface InstrumentBundle {
20+ format: 'numbl-instrument-v1';
21+ /** Stable identifier, lowercase letters, digits, hyphens. Hosts key
22+ * caches and track references on it, so it should not change between
23+ * versions of the same instrument. */
24+ id: string;
25+ /** Display name. */
26+ name: string;
27+ description?: string;
28+ /** Provenance, e.g. "simulated_instruments/bass". Informational. */
29+ source?: string;
30+ /** Curated user-editable parameters. Hosts clamp values to [min, max]
31+ * and fill missing ones with the default before every call. */
32+ knobs: BundleKnob[];
33+ /** Every .m source the drivers need, drivers included. Must contain
34+ * render_note.m; prepare.m is optional. */
35+ files: BundleFile[];
36+}
37+
38+const ID_RE = /^[a-z0-9][a-z0-9-]*$/;
39+
40+function fail(id: string, msg: string): never {
41+ throw new Error(`instrument bundle ${id}: ${msg}`);
42+}
43+
44+export function parseBundle(text: string): InstrumentBundle {
45+ let j: unknown;
46+ try {
47+ j = JSON.parse(text);
48+ } catch (err) {
49+ throw new Error(`instrument bundle is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
50+ }
51+ const b = j as InstrumentBundle;
52+ const id = typeof b.id === 'string' ? b.id : '(no id)';
53+ if (b.format !== 'numbl-instrument-v1') fail(id, `unknown format '${String(b.format)}'`);
54+ if (typeof b.id !== 'string' || !ID_RE.test(b.id)) fail(id, `'id' must match ${ID_RE}`);
55+ if (typeof b.name !== 'string' || b.name === '') fail(id, `missing string field 'name'`);
56+ for (const k of ['description', 'source'] as const) {
57+ if (b[k] !== undefined && typeof b[k] !== 'string') fail(id, `'${k}' must be a string when present`);
58+ }
59+ if (!Array.isArray(b.knobs)) fail(id, `missing 'knobs' array`);
60+ for (const k of b.knobs) {
61+ if (typeof k.key !== 'string' || typeof k.label !== 'string') fail(id, `knobs need key and label`);
62+ if (typeof k.min !== 'number' || typeof k.max !== 'number' || typeof k.default !== 'number' || !(k.min < k.max)) {
63+ fail(id, `knob '${k.key}' needs min < max and a default`);
64+ }
65+ if (k.default < k.min || k.default > k.max) fail(id, `knob '${k.key}' default outside its range`);
66+ if (k.step !== undefined && (typeof k.step !== 'number' || !(k.step > 0))) fail(id, `knob '${k.key}' step must be > 0`);
67+ if (k.scale !== undefined && k.scale !== 'log') fail(id, `knob '${k.key}' has unknown scale`);
68+ if (k.scale === 'log' && k.min <= 0) fail(id, `knob '${k.key}' log scale needs min > 0`);
69+ }
70+ if (!Array.isArray(b.files) || b.files.length === 0) fail(id, `missing 'files' array`);
71+ const seen = new Set<string>();
72+ for (const f of b.files) {
73+ if (typeof f.name !== 'string' || !f.name.endsWith('.m') || f.name.includes('/')) {
74+ fail(id, `file '${String(f.name)}' must be a bare .m filename`);
75+ }
76+ if (typeof f.content !== 'string' || f.content === '') fail(id, `file '${f.name}' has no content`);
77+ if (seen.has(f.name)) fail(id, `duplicate file '${f.name}'`);
78+ seen.add(f.name);
79+ }
80+ if (!seen.has('render_note.m')) fail(id, `no render_note.m among the files`);
81+ return b;
82+}
83+
84+/** Whether the bundle declares the optional prepare stage. */
85+export function hasPrepare(b: InstrumentBundle): boolean {
86+ return b.files.some((f) => f.name === 'prepare.m');
87+}
src/instruments/numbl/catalog.tsadded+28−0View file
@@ -0,0 +1,28 @@
1+import { parseBundle, type InstrumentBundle } from './bundle.ts';
2+
3+/**
4+ * Discovery of committed bundles: every
5+ * ../simulated_instruments/<repo>/exports/*.json, inlined at build time.
6+ * A bundle that fails validation is reported and skipped rather than
7+ * taking the app down. Bundles imported at runtime live in imports.ts,
8+ * not here.
9+ */
10+
11+const bundleModules = import.meta.glob('../../../../../simulated_instruments/*/exports/*.json', {
12+ eager: true,
13+ query: '?raw',
14+ import: 'default',
15+}) as Record<string, string>;
16+
17+export function loadCatalog(): InstrumentBundle[] {
18+ const out: InstrumentBundle[] = [];
19+ for (const [path, text] of Object.entries(bundleModules)) {
20+ try {
21+ out.push(parseBundle(text));
22+ } catch (err) {
23+ console.warn(`skipping ${path}: ${err instanceof Error ? err.message : String(err)}`);
24+ }
25+ }
26+ out.sort((a, b) => a.name.localeCompare(b.name));
27+ return out;
28+}
src/instruments/numbl/render.tsadded+100−0View file
@@ -0,0 +1,100 @@
1+import { executeCode } from 'numbl';
2+import { hasPrepare, type InstrumentBundle } from './bundle.ts';
3+
4+/**
5+ * Rendering notes through a bundle's numbl drivers. A batch of notes
6+ * becomes one generated program: the knob struct is built, prepare (if
7+ * the bundle has one) runs once, and render_note runs per note, so
8+ * pitch-independent setup is amortized across the batch. Pure with
9+ * respect to the environment (no DOM, no worker), so the browser worker
10+ * and the node tests share it.
11+ */
12+
13+export interface BatchNote {
14+ pitch: number; // MIDI note number
15+ velocity: number; // 0..1
16+ durationSec: number;
17+}
18+
19+export interface BatchOpts {
20+ sampleRate: number;
21+ quality: 'preview' | 'full';
22+ settings: Record<string, number>; // knob values; unknown keys are ignored
23+}
24+
25+interface RuntimeTensorLike {
26+ kind: 'tensor';
27+ data: Float64Array;
28+}
29+
30+function lit(name: string, value: number): string {
31+ if (!Number.isFinite(value)) throw new Error(`parameter ${name} is not finite`);
32+ return String(value);
33+}
34+
35+/** The generated host program, exposed for tests. */
36+export function buildProgram(bundle: InstrumentBundle, notes: BatchNote[], opts: BatchOpts): string {
37+ const preview = opts.quality === 'preview' ? 1 : 0;
38+ const lines = [
39+ 'params = struct();',
40+ `params.sample_rate = ${lit('sample_rate', opts.sampleRate)};`,
41+ `params.preview = ${preview};`,
42+ ];
43+ for (const k of bundle.knobs) {
44+ const raw = opts.settings[k.key];
45+ const v = raw === undefined ? k.default : Math.max(k.min, Math.min(k.max, raw));
46+ lines.push(`params.${k.key} = ${lit(k.key, v)};`);
47+ }
48+ lines.push('note = struct();', 'note.params = params;');
49+ if (hasPrepare(bundle)) lines.push('state = prepare(params);', 'note.state = state;');
50+ lines.push(`note.sample_rate = ${lit('sample_rate', opts.sampleRate)};`, `note.preview = ${preview};`);
51+ notes.forEach((n, i) => {
52+ lines.push(
53+ `note.pitch = ${lit('pitch', n.pitch)};`,
54+ `note.velocity = ${lit('velocity', n.velocity)};`,
55+ `note.duration_s = ${lit('duration_s', Math.max(1e-3, n.durationSec))};`,
56+ `y_${i} = render_note(note);`,
57+ );
58+ });
59+ return lines.join('\n');
60+}
61+
62+export function renderNoteBatch(bundle: InstrumentBundle, notes: BatchNote[], opts: BatchOpts): Float32Array[] {
63+ if (notes.length === 0) return [];
64+ const code = buildProgram(bundle, notes, opts);
65+ const mfiles = bundle.files.map((f) => ({ name: f.name, source: f.content }));
66+ const r = executeCode(code, { displayResults: false }, mfiles, 'midiboot_render.m');
67+ return notes.map((_, i) => {
68+ const y = r.variableValues[`y_${i}`] as unknown as RuntimeTensorLike;
69+ if (!y || y.kind !== 'tensor') throw new Error(`${bundle.id}: render_note did not return a vector`);
70+ const out = new Float32Array(y.data.length);
71+ for (let j = 0; j < y.data.length; j++) out[j] = y.data[j];
72+ return out;
73+ });
74+}
75+
76+/** Velocity quantized to a few levels so identical notes share a cached
77+ * render; 8 levels is inaudible next to the models' own dynamics. */
78+export function bucketVelocity(velocity01: number, levels = 8): number {
79+ return Math.ceil(Math.max(0.001, Math.min(1, velocity01)) * levels) / levels;
80+}
81+
82+/** Duration quantized to 0.25 s steps. The host cannot know whether a
83+ * driver reads duration, so it is always part of the cache key. */
84+export function bucketDuration(durationSec: number): number {
85+ return Math.max(0.25, Math.round(durationSec / 0.25) * 0.25);
86+}
87+
88+/** Everything that identifies one cached note render. */
89+export function noteCacheKey(
90+ bundle: InstrumentBundle,
91+ quality: 'preview' | 'full',
92+ sampleRate: number,
93+ settings: Record<string, number>,
94+ pitch: number,
95+ velocityBucket: number,
96+ durationBucket: number,
97+): string {
98+ const s = bundle.knobs.map((k) => `${k.key}=${settings[k.key] ?? k.default}`).join(',');
99+ return `${bundle.id}|${quality}|${sampleRate}|${s}|${pitch}|${velocityBucket}|${durationBucket}`;
100+}
src/instruments/simulated/worker.ts →src/instruments/numbl/worker.tsrenamed+27−31View file
@@ -1,14 +1,14 @@
11 /**
22 * The render worker: numbl runs here so a seconds-long eigensolve or jet
3- * integration never blocks the UI. One request renders one track: each
4- * note is rendered individually (velocity and duration quantized so
5- * repeats hit the cache), placed at its onset, and summed; the mixed
6- * buffer is transferred back. The per-note cache is an LRU bounded by
7- * total samples, keyed by everything that affects one note's audio.
3+ * integration never blocks the UI. One request renders one track through
4+ * one bundle (sent with the request; the worker holds no catalog). Notes
5+ * are grouped by cache key so each distinct sound renders once, cache
6+ * misses go through renderNoteBatch in small chunks (prepare amortized,
7+ * progress between chunks), and the mixed buffer is transferred back.
8+ * The per-note cache is an LRU bounded by total samples.
89 */
9-import { loadCatalog, type SimInstrumentDef } from './catalog.ts';
10-import { bucketDuration, bucketVelocity, noteCacheKey } from './manifest.ts';
11-import { renderSimNote } from './mrender.ts';
10+import type { InstrumentBundle } from './bundle.ts';
11+import { bucketDuration, bucketVelocity, noteCacheKey, renderNoteBatch } from './render.ts';
1212
1313 export interface WorkerNote {
1414 pitch: number;
@@ -20,7 +20,7 @@ export interface WorkerNote {
2020 export interface WorkerRenderMsg {
2121 kind: 'render';
2222 id: number;
23- name: string; // manifest name
23+ bundle: InstrumentBundle;
2424 notes: WorkerNote[];
2525 sampleRate: number;
2626 lengthSamples: number;
@@ -33,10 +33,8 @@ export type WorkerResponse =
3333 | { kind: 'error'; id: number; message: string }
3434 | { kind: 'status'; text: string };
3535
36-const catalog = new Map<string, SimInstrumentDef>();
37-for (const def of loadCatalog()) catalog.set(def.manifest.name, def);
38-
3936 const CACHE_MAX_SAMPLES = 48e6; // ~192 MB of Float32 note renders
37+const CHUNK = 4; // cache misses rendered per numbl program
4038 const cache = new Map<string, Float32Array>();
4139 let cacheSamples = 0;
4240
@@ -66,41 +64,39 @@ self.onmessage = (ev: MessageEvent<WorkerRenderMsg>) => {
6664 const msg = ev.data;
6765 if (msg.kind !== 'render') return;
6866 try {
69- const def = catalog.get(msg.name);
70- if (!def) throw new Error(`unknown simulated instrument '${msg.name}'`);
71- const m = def.manifest;
67+ const b = msg.bundle;
7268 const out = new Float32Array(msg.lengthSamples);
7369 // Group notes by cache key so each distinct sound renders once.
7470 const groups = new Map<string, { velB: number; durB: number; pitch: number; times: number[] }>();
7571 for (const n of msg.notes) {
7672 const velB = bucketVelocity(n.velocity);
7773 const durB = bucketDuration(n.durationSec);
78- const key = noteCacheKey(m, msg.quality, msg.sampleRate, msg.settings, n.pitch, velB, durB);
74+ const key = noteCacheKey(b, msg.quality, msg.sampleRate, msg.settings, n.pitch, velB, durB);
7975 const g = groups.get(key);
8076 if (g) g.times.push(n.timeSec);
8177 else groups.set(key, { velB, durB, pitch: n.pitch, times: [n.timeSec] });
8278 }
83- let done = 0;
79+ const misses = [...groups.entries()].filter(([key]) => !cache.has(key));
80+ for (let c = 0; c < misses.length; c += CHUNK) {
81+ const chunk = misses.slice(c, c + CHUNK);
82+ post({
83+ kind: 'status',
84+ text: `${b.name}: rendering notes ${c + 1}-${Math.min(c + CHUNK, misses.length)}/${misses.length} (${msg.quality})`,
85+ });
86+ const bufs = renderNoteBatch(
87+ b,
88+ chunk.map(([, g]) => ({ pitch: g.pitch, velocity: g.velB, durationSec: g.durB })),
89+ { sampleRate: msg.sampleRate, quality: msg.quality, settings: msg.settings },
90+ );
91+ chunk.forEach(([key], i) => cachePut(key, bufs[i]));
92+ }
8493 for (const [key, g] of groups) {
85- let buf = cacheGet(key);
86- if (!buf) {
87- post({ kind: 'status', text: `${m.label}: rendering note ${done + 1}/${groups.size} (${msg.quality})` });
88- buf = renderSimNote(m, def.mfiles, {
89- pitch: g.pitch,
90- velocity: g.velB,
91- durationSec: g.durB,
92- sampleRate: msg.sampleRate,
93- quality: msg.quality,
94- settings: msg.settings,
95- });
96- cachePut(key, buf);
97- }
94+ const buf = cacheGet(key)!;
9895 for (const t of g.times) {
9996 const off = Math.round(t * msg.sampleRate);
10097 const end = Math.min(out.length, off + buf.length);
10198 for (let i = Math.max(0, off); i < end; i++) out[i] += buf[i - off];
10299 }
103- done++;
104100 }
105101 post({ kind: 'status', text: '' });
106102 post({ kind: 'result', id: msg.id, samples: out }, [out.buffer]);
src/instruments/registry.tsmodified+4−0View file
@@ -14,6 +14,10 @@ export function registerInstrument(ins: Instrument): void {
1414 instruments.set(ins.id, ins);
1515 }
1616
17+export function unregisterInstrument(id: string): void {
18+ instruments.delete(id);
19+}
20+
1721 export function getInstrument(id: string): Instrument {
1822 return instruments.get(id) ?? builtinInstruments[0];
1923 }
src/instruments/simulated/adapter.tsdeleted+0−88View file
@@ -1,88 +0,0 @@
1-/**
2- * Simulated instruments (../simulated_instruments) behind the generic
3- * Instrument interface: physically based MATLAB models executed by numbl
4- * in a worker, one render per distinct note, mixed per track. What each
5- * instrument exposes (entry point, pitch and velocity mapping, curated
6- * knobs, preview overrides) comes from its own instrument.json manifest;
7- * nothing here is instrument-specific. There is no live noteOn voice yet,
8- * so monitoring while recording falls back to the player's beep.
9- */
10-import type { Instrument, RenderRequest } from '../types.ts';
11-import { registerInstrument } from '../registry.ts';
12-import { loadCatalog } from './catalog.ts';
13-import type { SimManifest } from './manifest.ts';
14-import type { WorkerRenderMsg, WorkerResponse } from './worker.ts';
15-
16-type StatusFn = (msg: string) => void;
17-let onStatus: StatusFn = () => {};
18-export function setSimulatedStatus(cb: StatusFn): void {
19- onStatus = cb;
20-}
21-
22-let worker: Worker | null = null;
23-let nextId = 1;
24-const pending = new Map<number, { resolve: (v: Float32Array) => void; reject: (e: Error) => void }>();
25-
26-function ensureWorker(): Worker {
27- if (worker) return worker;
28- worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
29- worker.onmessage = (ev: MessageEvent<WorkerResponse>) => {
30- const msg = ev.data;
31- if (msg.kind === 'status') {
32- onStatus(msg.text);
33- return;
34- }
35- const p = pending.get(msg.id);
36- if (!p) return;
37- pending.delete(msg.id);
38- if (msg.kind === 'result') p.resolve(msg.samples);
39- else p.reject(new Error(msg.message));
40- };
41- worker.onerror = (ev) => {
42- const err = new Error(`simulated-instrument worker failed: ${ev.message}`);
43- for (const p of pending.values()) p.reject(err);
44- pending.clear();
45- };
46- return worker;
47-}
48-
49-class SimInstrument implements Instrument {
50- readonly id: string;
51- readonly name: string;
52- readonly knobs;
53-
54- constructor(private readonly manifest: SimManifest) {
55- this.id = `sim-${manifest.name}`;
56- this.name = `${manifest.label} (sim)`;
57- this.knobs = manifest.knobs;
58- }
59-
60- render(req: RenderRequest): Promise<Float32Array> {
61- if (req.notes.length === 0) return Promise.resolve(new Float32Array(req.lengthSamples));
62- const w = ensureWorker();
63- const id = nextId++;
64- const msg: WorkerRenderMsg = {
65- kind: 'render',
66- id,
67- name: this.manifest.name,
68- notes: req.notes.map((n) => ({ pitch: n.pitch, velocity: n.velocity, durationSec: n.durationSec, timeSec: n.timeSec })),
69- sampleRate: req.sampleRate,
70- lengthSamples: req.lengthSamples,
71- quality: req.quality ?? 'preview',
72- settings: req.settings ?? {},
73- };
74- return new Promise<Float32Array>((resolve, reject) => {
75- pending.set(id, { resolve, reject });
76- w.postMessage(msg);
77- });
78- }
79-}
80-
81-export function registerSimulatedInstruments(): number {
82- let n = 0;
83- for (const def of loadCatalog()) {
84- registerInstrument(new SimInstrument(def.manifest));
85- n++;
86- }
87- return n;
88-}
src/instruments/simulated/catalog.tsdeleted+0−51View file
@@ -1,51 +0,0 @@
1-import { parseManifest, type SimManifest } from './manifest.ts';
2-import type { MFile } from './mrender.ts';
3-
4-/**
5- * Discovery: every ../simulated_instruments/<repo>/instrument.json, with
6- * that repo's matlab/*.m sources, bundled at build time. A repo without a
7- * manifest is simply not an instrument; a manifest that fails validation
8- * is reported and skipped rather than taking the app down.
9- */
10-
11-const manifestModules = import.meta.glob('../../../../../simulated_instruments/*/instrument.json', {
12- eager: true,
13- query: '?raw',
14- import: 'default',
15-}) as Record<string, string>;
16-
17-const mModules = import.meta.glob('../../../../../simulated_instruments/*/matlab/*.m', {
18- eager: true,
19- query: '?raw',
20- import: 'default',
21-}) as Record<string, string>;
22-
23-export interface SimInstrumentDef {
24- manifest: SimManifest;
25- mfiles: MFile[];
26-}
27-
28-export function loadCatalog(): SimInstrumentDef[] {
29- const out: SimInstrumentDef[] = [];
30- for (const [path, text] of Object.entries(manifestModules)) {
31- const dir = path.slice(0, path.lastIndexOf('/'));
32- let manifest: SimManifest;
33- try {
34- manifest = parseManifest(text);
35- } catch (err) {
36- console.warn(`skipping ${path}: ${err instanceof Error ? err.message : String(err)}`);
37- continue;
38- }
39- const mfiles: MFile[] = Object.entries(mModules)
40- .filter(([p]) => p.startsWith(dir + '/matlab/'))
41- .map(([p, source]) => ({ name: p.split('/').pop()!, source }))
42- .sort((a, b) => a.name.localeCompare(b.name));
43- if (mfiles.length === 0) {
44- console.warn(`skipping ${path}: no matlab/*.m sources found`);
45- continue;
46- }
47- out.push({ manifest, mfiles });
48- }
49- out.sort((a, b) => a.manifest.name.localeCompare(b.manifest.name));
50- return out;
51-}
src/instruments/simulated/manifest.tsdeleted+0−172View file
@@ -1,172 +0,0 @@
1-import type { KnobSpec } from '../types.ts';
2-
3-/**
4- * The simulated-instrument manifest, format "simulated-instrument-v1".
5- *
6- * Each repo under ../simulated_instruments carries an instrument.json
7- * describing how midiboot may play its MATLAB model:
8- *
9- * name / label identity and display name
10- * entry .m function called as y = entry(P) for one note
11- * defaults .m function returning the parameter struct P
12- * fixed parameter overrides always applied (single-event gesture,
13- * fixed seed, normalize off so velocity survives in pascals)
14- * pitch optional: how MIDI pitch reaches a parameter; mode "midi"
15- * assigns the clamped note number, mode "table" picks the
16- * nearest entry of a {midi, value} table (fingering charts)
17- * velocity how velocity 0..1 maps linearly onto a parameter range
18- * duration optional: how the note's length reaches a parameter
19- * preview parameter overrides for the fast tier (e.g. oversample 1)
20- * knobs curated user-editable parameters, with ranges and
21- * defaults; everything else stays at the model's defaults
22- *
23- * This file is the schema's one definition: parseManifest validates a
24- * manifest and the map* helpers below are the whole pitch/velocity/duration
25- * semantics, shared by the browser worker and the node tests.
26- */
27-
28-export interface PitchSpec {
29- param: string;
30- mode: 'midi' | 'table';
31- min?: number;
32- max?: number;
33- table?: { midi: number; value: number }[];
34-}
35-
36-export interface VelocitySpec {
37- param: string;
38- min: number;
39- max: number;
40-}
41-
42-export interface DurationSpec {
43- param: string;
44- min: number;
45- max: number;
46-}
47-
48-export interface SimManifest {
49- format: 'simulated-instrument-v1';
50- name: string;
51- label: string;
52- entry: string;
53- defaults: string;
54- fixed: Record<string, number>;
55- pitch?: PitchSpec;
56- velocity: VelocitySpec;
57- duration?: DurationSpec;
58- preview: Record<string, number>;
59- knobs: KnobSpec[];
60-}
61-
62-function fail(name: string, msg: string): never {
63- throw new Error(`instrument manifest ${name}: ${msg}`);
64-}
65-
66-export function parseManifest(text: string): SimManifest {
67- let j: unknown;
68- try {
69- j = JSON.parse(text);
70- } catch (err) {
71- throw new Error(`instrument manifest is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
72- }
73- const m = j as SimManifest;
74- const name = typeof m.name === 'string' ? m.name : '(unnamed)';
75- if (m.format !== 'simulated-instrument-v1') fail(name, `unknown format '${String(m.format)}'`);
76- for (const k of ['name', 'label', 'entry', 'defaults'] as const) {
77- if (typeof m[k] !== 'string' || m[k] === '') fail(name, `missing string field '${k}'`);
78- }
79- if (typeof m.fixed !== 'object' || m.fixed === null) fail(name, `missing 'fixed' overrides`);
80- if (typeof m.preview !== 'object' || m.preview === null) fail(name, `missing 'preview' overrides`);
81- for (const [k, v] of [...Object.entries(m.fixed), ...Object.entries(m.preview)]) {
82- if (typeof v !== 'number' || !Number.isFinite(v)) fail(name, `override '${k}' is not a finite number`);
83- }
84- const vel = m.velocity;
85- if (!vel || typeof vel.param !== 'string' || typeof vel.min !== 'number' || typeof vel.max !== 'number' || !(vel.min < vel.max)) {
86- fail(name, `'velocity' needs {param, min < max}`);
87- }
88- if (m.pitch) {
89- const p = m.pitch;
90- if (typeof p.param !== 'string') fail(name, `'pitch' needs a param`);
91- if (p.mode === 'midi') {
92- if (typeof p.min !== 'number' || typeof p.max !== 'number' || !(p.min < p.max)) fail(name, `pitch mode 'midi' needs min < max`);
93- } else if (p.mode === 'table') {
94- if (!Array.isArray(p.table) || p.table.length === 0) fail(name, `pitch mode 'table' needs a non-empty table`);
95- for (const row of p.table) {
96- if (typeof row.midi !== 'number' || typeof row.value !== 'number') fail(name, `pitch table rows need {midi, value}`);
97- }
98- } else {
99- fail(name, `unknown pitch mode '${String(p.mode)}'`);
100- }
101- }
102- if (m.duration) {
103- const d = m.duration;
104- if (typeof d.param !== 'string' || typeof d.min !== 'number' || typeof d.max !== 'number' || !(d.min < d.max)) {
105- fail(name, `'duration' needs {param, min < max}`);
106- }
107- }
108- if (!Array.isArray(m.knobs)) fail(name, `missing 'knobs' array`);
109- for (const k of m.knobs) {
110- if (typeof k.key !== 'string' || typeof k.label !== 'string') fail(name, `knobs need key and label`);
111- if (typeof k.min !== 'number' || typeof k.max !== 'number' || typeof k.default !== 'number' || !(k.min < k.max)) {
112- fail(name, `knob '${k.key}' needs min < max and a default`);
113- }
114- if (k.default < k.min || k.default > k.max) fail(name, `knob '${k.key}' default outside its range`);
115- if (k.scale !== undefined && k.scale !== 'log') fail(name, `knob '${k.key}' has unknown scale`);
116- if (k.scale === 'log' && k.min <= 0) fail(name, `knob '${k.key}' log scale needs min > 0`);
117- }
118- return m;
119-}
120-
121-/** MIDI pitch to a parameter assignment, or null for unpitched instruments. */
122-export function mapPitch(m: SimManifest, midiPitch: number): { key: string; value: number } | null {
123- const p = m.pitch;
124- if (!p) return null;
125- if (p.mode === 'midi') {
126- return { key: p.param, value: Math.max(p.min!, Math.min(p.max!, Math.round(midiPitch))) };
127- }
128- let best = p.table![0];
129- for (const row of p.table!) {
130- if (Math.abs(row.midi - midiPitch) < Math.abs(best.midi - midiPitch)) best = row;
131- }
132- return { key: p.param, value: best.value };
133-}
134-
135-/** Velocity 0..1 to a parameter assignment, linear across the range. */
136-export function mapVelocity(m: SimManifest, velocity01: number): { key: string; value: number } {
137- const v = Math.max(0, Math.min(1, velocity01));
138- return { key: m.velocity.param, value: m.velocity.min + (m.velocity.max - m.velocity.min) * v };
139-}
140-
141-/** Note duration to a parameter assignment, or null when the model ignores it. */
142-export function mapDuration(m: SimManifest, durationSec: number): { key: string; value: number } | null {
143- const d = m.duration;
144- if (!d) return null;
145- return { key: d.param, value: Math.max(d.min, Math.min(d.max, durationSec)) };
146-}
147-
148-/** Velocity quantized to a few levels so identical notes share a cached
149- * render; 8 levels is inaudible next to the models' own dynamics. */
150-export function bucketVelocity(velocity01: number, levels = 8): number {
151- return Math.ceil(Math.max(0.001, Math.min(1, velocity01)) * levels) / levels;
152-}
153-
154-/** Duration quantized to 0.25 s steps, only meaningful with a duration spec. */
155-export function bucketDuration(durationSec: number): number {
156- return Math.max(0.25, Math.round(durationSec / 0.25) * 0.25);
157-}
158-
159-/** Everything that identifies one cached note render. */
160-export function noteCacheKey(
161- m: SimManifest,
162- quality: 'preview' | 'full',
163- sampleRate: number,
164- settings: Record<string, number>,
165- pitch: number,
166- velocityBucket: number,
167- durationBucket: number,
168-): string {
169- const s = m.knobs.map((k) => `${k.key}=${settings[k.key] ?? k.default}`).join(',');
170- const pv = mapPitch(m, pitch);
171- return `${m.name}|${quality}|${sampleRate}|${s}|${pv ? pv.value : '-'}|${velocityBucket}|${m.duration ? durationBucket : '-'}`;
172-}
src/instruments/simulated/mrender.tsdeleted+0−63View file
@@ -1,63 +0,0 @@
1-import { executeCode, type RuntimeValue } from 'numbl';
2-import { mapDuration, mapPitch, mapVelocity, type SimManifest } from './manifest.ts';
3-
4-/**
5- * One note through one simulated instrument: build the parameter struct
6- * from the model's own defaults plus the manifest's overrides, call the
7- * entry function through numbl, and hand back the mono trace. Pure with
8- * respect to the environment (no DOM, no worker), so the browser worker
9- * and the node tests share it.
10- */
11-
12-export interface MFile {
13- name: string;
14- source: string;
15-}
16-
17-export interface SimNoteOpts {
18- pitch: number; // MIDI note number
19- velocity: number; // 0..1
20- durationSec: number;
21- sampleRate: number;
22- quality: 'preview' | 'full';
23- settings: Record<string, number>; // knob values; unknown keys are ignored
24-}
25-
26-interface RuntimeTensorLike {
27- kind: 'tensor';
28- data: Float64Array;
29-}
30-
31-export function renderSimNote(manifest: SimManifest, mfiles: MFile[], opts: SimNoteOpts): Float32Array {
32- const assigns: string[] = [];
33- const setP = (key: string, value: number) => {
34- if (!Number.isFinite(value)) throw new Error(`parameter ${key} is not finite`);
35- assigns.push(`P.${key} = ${value};`);
36- };
37- for (const [k, v] of Object.entries(manifest.fixed)) setP(k, v);
38- for (const knob of manifest.knobs) {
39- const v = opts.settings[knob.key];
40- setP(knob.key, v === undefined ? knob.default : Math.max(knob.min, Math.min(knob.max, v)));
41- }
42- if (opts.quality === 'preview') for (const [k, v] of Object.entries(manifest.preview)) setP(k, v);
43- setP('sample_rate', opts.sampleRate);
44- const pv = mapPitch(manifest, opts.pitch);
45- if (pv) setP(pv.key, pv.value);
46- const vv = mapVelocity(manifest, opts.velocity);
47- setP(vv.key, vv.value);
48- const dv = mapDuration(manifest, opts.durationSec);
49- if (dv) setP(dv.key, dv.value);
50-
51- const code = [
52- `P = ${manifest.defaults}();`,
53- ...assigns,
54- `y_out = ${manifest.entry}(P);`,
55- ].join('\n');
56-
57- const r = executeCode(code, { displayResults: false }, mfiles, 'midiboot_note.m');
58- const y = r.variableValues.y_out as RuntimeValue as RuntimeTensorLike;
59- if (!y || y.kind !== 'tensor') throw new Error(`${manifest.name}: entry '${manifest.entry}' did not return a tensor`);
60- const out = new Float32Array(y.data.length);
61- for (let i = 0; i < y.data.length; i++) out[i] = y.data[i];
62- return out;
63-}
src/main.tsxmodified+4−3View file
@@ -1,10 +1,11 @@
11 import { createRoot } from 'react-dom/client';
22 import { App } from './components/App.tsx';
33 import { store } from './state/store.ts';
4-import { registerSimulatedInstruments, setSimulatedStatus } from './instruments/simulated/adapter.ts';
4+import { loadImportedInstruments, registerCatalogInstruments, setNumblStatus } from './instruments/numbl/adapter.ts';
55 import './styles.css';
66
7-registerSimulatedInstruments();
8-setSimulatedStatus((msg) => store.setStatus(msg));
7+registerCatalogInstruments();
8+setNumblStatus((msg) => store.setStatus(msg));
9+void loadImportedInstruments().then((n) => { if (n > 0) store.bumpInstruments(); });
910 void store.init();
1011 createRoot(document.getElementById('root')!).render(<App />);
src/state/store.tsmodified+5−0View file
@@ -26,6 +26,8 @@ export interface AppState {
2626 snapBeats: number; // snap grid in quarter notes; 0 = off
2727 libraryOpen: boolean;
2828 status: string;
29+ /** Bumped when the instrument registry changes so pickers re-render. */
30+ instrumentsRev: number;
2931 }
3032
3133 const LAST_SONG_KEY = 'midiboot:lastSong';
@@ -44,6 +46,7 @@ class Store {
4446 snapBeats: 0.25,
4547 libraryOpen: false,
4648 status: '',
49+ instrumentsRev: 0,
4750 };
4851 recorder = new Recorder();
4952
@@ -69,6 +72,8 @@ class Store {
6972
7073 setStatus(msg: string): void { this.set({ status: msg }); }
7174
75+ bumpInstruments(): void { this.set({ instrumentsRev: this.state.instrumentsRev + 1 }); }
76+
7277 tempoMap(): TempoMap | null {
7378 const song = this.song();
7479 return song ? new TempoMap(song.ppq, song.tempo, song.timeSig) : null;
src/storage/db.tsmodified+35−1View file
@@ -5,16 +5,18 @@ import type { SongMeta, SongRecord } from '../types.ts';
55
66 const DB_NAME = 'midiboot';
77 const STORE = 'songs';
8+const INSTRUMENTS = 'instruments';
89
910 let dbPromise: Promise<IDBDatabase> | null = null;
1011
1112 function openDb(): Promise<IDBDatabase> {
1213 if (dbPromise) return dbPromise;
1314 dbPromise = new Promise((resolve, reject) => {
14- const req = indexedDB.open(DB_NAME, 1);
15+ const req = indexedDB.open(DB_NAME, 2);
1516 req.onupgradeneeded = () => {
1617 const db = req.result;
1718 if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE, { keyPath: 'id' });
19+ if (!db.objectStoreNames.contains(INSTRUMENTS)) db.createObjectStore(INSTRUMENTS, { keyPath: 'id' });
1820 };
1921 req.onsuccess = () => resolve(req.result);
2022 req.onerror = () => reject(req.error);
@@ -72,3 +74,35 @@ export async function listSongs(): Promise<SongMeta[]> {
7274 req.onerror = () => reject(req.error);
7375 });
7476 }
77+
78+// Imported instrument bundles (format numbl-instrument-v1), keyed by
79+// bundle id. Whole records again: a bundle is tens of kilobytes of JSON.
80+
81+export interface ImportedInstrumentRecord {
82+ id: string; // the bundle id
83+ text: string; // the bundle JSON, kept verbatim as imported
84+ importedAt: number;
85+}
86+
87+export async function putImportedInstrument(rec: ImportedInstrumentRecord): Promise<void> {
88+ const db = await openDb();
89+ const tx = db.transaction(INSTRUMENTS, 'readwrite');
90+ tx.objectStore(INSTRUMENTS).put(rec);
91+ await txDone(tx);
92+}
93+
94+export async function deleteImportedInstrument(id: string): Promise<void> {
95+ const db = await openDb();
96+ const tx = db.transaction(INSTRUMENTS, 'readwrite');
97+ tx.objectStore(INSTRUMENTS).delete(id);
98+ await txDone(tx);
99+}
100+
101+export async function listImportedInstruments(): Promise<ImportedInstrumentRecord[]> {
102+ const db = await openDb();
103+ return new Promise((resolve, reject) => {
104+ const req = db.transaction(INSTRUMENTS, 'readonly').objectStore(INSTRUMENTS).getAll();
105+ req.onsuccess = () => resolve((req.result as ImportedInstrumentRecord[]) ?? []);
106+ req.onerror = () => reject(req.error);
107+ });
108+}
src/styles.cssmodified+13−0View file
@@ -157,3 +157,16 @@ label { display: inline-flex; align-items: center; gap: 4px; white-space: nowrap
157157 text-align: left;
158158 }
159159 .dim { color: #777; font-size: 11px; }
160+
161+.importinstr { display: flex; flex-direction: column; gap: 4px; }
162+.importedlist { display: flex; flex-wrap: wrap; gap: 4px; padding: 0 6px 6px; }
163+.importedchip {
164+ display: inline-flex;
165+ align-items: center;
166+ gap: 4px;
167+ font-size: 11px;
168+ padding: 1px 4px 1px 8px;
169+ border: 1px solid #333a47;
170+ border-radius: 10px;
171+ color: #8b93a3;
172+}
↑↓ move↵openescclose