Exported-instrument format: spec, schema, types, build script
Defines numbl-instrument-v1: a self-contained JSON bundle carrying knob
declarations and the MATLAB-syntax driver code (render_note, optionally
prepare) plus model sources, so the MIDI-to-parameter mapping lives with
the instrument author rather than the host. Includes the JSON schema,
the vendorable TypeScript definition with validation, and the canonical
build-exports.mjs that instrument repos copy.
4 changed files+258−0
README.mdadded+44−0View file
@@ -0,0 +1,44 @@
1+# instrument-format
2+
3+The exported-instrument format shared by the simulated instrument repositories and the hosts that play them, currently [midiboot](../../proof-of-concept/midiboot). A limitation of the earlier arrangement was that the host owned the mapping from MIDI to model parameters: a JSON manifest in each instrument repository declared which parameter received pitch, which received velocity, and the host applied those rules in TypeScript, so any mapping the manifest schema had not anticipated required a host change. Here we define a self-contained bundle in which the mapping itself is code written by the instrument author, in the same MATLAB syntax as the model and executed by the same interpreter, [numbl](https://numbl.org). The host knows how to hand a note to the bundle and take audio back; it knows nothing about the model's parameters.
4+
5+## The bundle
6+
7+A bundle is one JSON file, format `numbl-instrument-v1`. It carries identity (`id`, `name`, optional `description` and `source`), a curated list of user-editable `knobs` (key, label, range, default, optional `step` and `scale: "log"`), and `files`, the complete set of `.m` sources needed to render a note: the driver functions defined below plus whatever model code they call. A bundle is self-contained by construction; nothing outside its `files` is on the numbl path when it runs. The machine-readable definitions are [schema/numbl-instrument-v1.schema.json](schema/numbl-instrument-v1.schema.json) and [ts/bundle.ts](ts/bundle.ts), which hosts vendor. One repository may export several bundles, typically variants of one model with different baked-in character.
8+
9+## The driver contract
10+
11+A bundle must contain `render_note.m` and may contain `prepare.m`:
12+
13+ state = prepare(params) % optional, pitch-independent setup
14+ y = render_note(note) % one MIDI note to one audio vector
15+
16+`prepare` receives a struct with every knob value (clamped to its range, defaults filled in) plus `params.sample_rate` and `params.preview`, and returns whatever precomputed state the renders want to share; a bundle without `prepare.m` gets no `state` field. `render_note` receives a struct with the fields below and returns a vector of samples at `note.sample_rate`, in arbitrary linear units. The vector's length is the driver's choice and may exceed `duration_s` (a struck instrument rings out); the host mixes it in at the note's onset and normalizes per track, so absolute level does not matter, only the relative level between notes.
17+
18+| Field | Meaning |
19+|---|---|
20+| `note.pitch` | MIDI note number; may be fractional in future hosts |
21+| `note.velocity` | 0 to 1 |
22+| `note.duration_s` | note-on to note-off, seconds; advisory for instruments that ring freely |
23+| `note.sample_rate` | output rate, Hz |
24+| `note.preview` | 1 for the fast tier, 0 for full quality |
25+| `note.params` | the same knob struct `prepare` saw |
26+| `note.state` | `prepare`'s return value, only when `prepare.m` exists |
27+
28+Hosts may add fields to these structs in later versions; drivers must ignore fields they do not use. Renders must be deterministic (fix any random seed from a constant or from the note's fields), because hosts cache one render per distinct note and reuse it wherever that note recurs. Hosts may also quantize velocity and duration before calling, trading fine dynamics for cache hits; midiboot uses eight velocity levels and quarter-second duration steps.
29+
30+`prepare` runs once per batch of notes rendered together, not once per note, so it is the place for work that does not depend on pitch, velocity, or duration: tables, filter designs, anything derived from the knobs alone. The trade-off in the current midiboot host is that prepared state lives only within one rendering program, since numbl values are not carried between interpreter runs; a driver should therefore keep `prepare` cheap relative to a note render rather than relying on it being amortized indefinitely.
31+
32+## Building bundles
33+
34+An instrument repository lays its exports out as one directory per bundle:
35+
36+ export-src/<name>/export.json identity and knobs
37+ export-src/<name>/render_note.m the driver (and prepare.m if used)
38+ matlab/*.m the model, shared by every bundle
39+
40+Running `node scripts/build-exports.mjs` from the repo root validates each `export.json`, inlines the drivers and the shared model sources, and writes `exports/<id>.json`, which is committed. The canonical copy of the script is [scripts/build-exports.mjs](scripts/build-exports.mjs); each instrument repo carries its own copy so it builds standalone. A host such as midiboot discovers committed bundles at build time and can also import a bundle file at runtime, so an export downloaded or built anywhere can be dropped into the app without rebuilding it.
41+
42+## Limitations
43+
44+Notes are rendered independently and mixed, so physics that couples successive notes (a string choked by the next pluck, a cymbal still swinging when struck again) is not expressible; a gesture-level contract would be the extension. There is no live low-latency voice in the contract, only offline rendering. Knob values are scalars; enumerated or boolean parameters would need a small schema addition. The format says nothing about licensing or signatures, which matters only once bundles travel beyond this vault.
schema/numbl-instrument-v1.schema.jsonadded+45−0View file
@@ -0,0 +1,45 @@
1+{
2+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3+ "$id": "numbl-instrument-v1.schema.json",
4+ "title": "numbl instrument bundle, format numbl-instrument-v1",
5+ "type": "object",
6+ "required": ["format", "id", "name", "knobs", "files"],
7+ "additionalProperties": false,
8+ "properties": {
9+ "format": { "const": "numbl-instrument-v1" },
10+ "id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$" },
11+ "name": { "type": "string", "minLength": 1 },
12+ "description": { "type": "string" },
13+ "source": { "type": "string" },
14+ "knobs": {
15+ "type": "array",
16+ "items": {
17+ "type": "object",
18+ "required": ["key", "label", "min", "max", "default"],
19+ "additionalProperties": false,
20+ "properties": {
21+ "key": { "type": "string", "minLength": 1 },
22+ "label": { "type": "string", "minLength": 1 },
23+ "min": { "type": "number" },
24+ "max": { "type": "number" },
25+ "default": { "type": "number" },
26+ "step": { "type": "number", "exclusiveMinimum": 0 },
27+ "scale": { "const": "log" }
28+ }
29+ }
30+ },
31+ "files": {
32+ "type": "array",
33+ "minItems": 1,
34+ "items": {
35+ "type": "object",
36+ "required": ["name", "content"],
37+ "additionalProperties": false,
38+ "properties": {
39+ "name": { "type": "string", "pattern": "^[^/]+\\.m$" },
40+ "content": { "type": "string", "minLength": 1 }
41+ }
42+ }
43+ }
44+ }
45+}
scripts/build-exports.mjsadded+79−0View file
@@ -0,0 +1,79 @@
1+#!/usr/bin/env node
2+// Build exported instrument bundles (format numbl-instrument-v1) for one
3+// instrument repository. Run from the repo root:
4+//
5+// node scripts/build-exports.mjs
6+//
7+// For each export-src/<name>/export.json, the bundle's files are that
8+// directory's .m sources (the drivers, render_note.m required) plus every
9+// matlab/*.m of the repo, and the result is written to exports/<id>.json.
10+// The canonical copy of this script lives in
11+// simulated_instruments/instrument-format; instrument repos carry a copy so
12+// they build standalone.
13+
14+import { mkdirSync, readdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
15+import { join } from 'node:path';
16+
17+const ID_RE = /^[a-z0-9][a-z0-9-]*$/;
18+const SRC = 'export-src';
19+const OUT = 'exports';
20+
21+function fail(msg) {
22+ console.error(`build-exports: ${msg}`);
23+ process.exit(1);
24+}
25+
26+function checkKnobs(id, knobs) {
27+ if (!Array.isArray(knobs)) fail(`${id}: 'knobs' must be an array`);
28+ for (const k of knobs) {
29+ if (typeof k.key !== 'string' || typeof k.label !== 'string') fail(`${id}: knobs need key and label`);
30+ if (typeof k.min !== 'number' || typeof k.max !== 'number' || typeof k.default !== 'number' || !(k.min < k.max)) {
31+ fail(`${id}: knob '${k.key}' needs min < max and a default`);
32+ }
33+ if (k.default < k.min || k.default > k.max) fail(`${id}: knob '${k.key}' default outside its range`);
34+ if (k.step !== undefined && !(k.step > 0)) fail(`${id}: knob '${k.key}' step must be > 0`);
35+ if (k.scale !== undefined && k.scale !== 'log') fail(`${id}: knob '${k.key}' has unknown scale`);
36+ if (k.scale === 'log' && k.min <= 0) fail(`${id}: knob '${k.key}' log scale needs min > 0`);
37+ }
38+}
39+
40+function mfilesIn(dir) {
41+ if (!existsSync(dir)) return [];
42+ return readdirSync(dir)
43+ .filter((f) => f.endsWith('.m'))
44+ .sort()
45+ .map((name) => ({ name, content: readFileSync(join(dir, name), 'utf8') }));
46+}
47+
48+if (!existsSync(SRC)) fail(`no ${SRC}/ directory here; run from the instrument repo root`);
49+const shared = mfilesIn('matlab');
50+const dirs = readdirSync(SRC, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).sort();
51+if (dirs.length === 0) fail(`${SRC}/ has no instrument directories`);
52+mkdirSync(OUT, { recursive: true });
53+
54+for (const dir of dirs) {
55+ const metaPath = join(SRC, dir, 'export.json');
56+ if (!existsSync(metaPath)) fail(`${metaPath} is missing`);
57+ const meta = JSON.parse(readFileSync(metaPath, 'utf8'));
58+ const id = meta.id;
59+ if (typeof id !== 'string' || !ID_RE.test(id)) fail(`${metaPath}: 'id' must match ${ID_RE}`);
60+ if (typeof meta.name !== 'string' || meta.name === '') fail(`${id}: missing 'name'`);
61+ checkKnobs(id, meta.knobs ?? []);
62+ const drivers = mfilesIn(join(SRC, dir));
63+ if (!drivers.some((f) => f.name === 'render_note.m')) fail(`${id}: no render_note.m in ${SRC}/${dir}/`);
64+ const names = new Set(drivers.map((f) => f.name));
65+ const clash = shared.find((f) => names.has(f.name));
66+ if (clash) fail(`${id}: driver file '${clash.name}' collides with matlab/${clash.name}`);
67+ const bundle = {
68+ format: 'numbl-instrument-v1',
69+ id,
70+ name: meta.name,
71+ ...(meta.description ? { description: meta.description } : {}),
72+ ...(meta.source ? { source: meta.source } : {}),
73+ knobs: meta.knobs ?? [],
74+ files: [...drivers, ...shared],
75+ };
76+ const outPath = join(OUT, `${id}.json`);
77+ writeFileSync(outPath, JSON.stringify(bundle, null, 2) + '\n');
78+ console.log(`wrote ${outPath} (${bundle.files.length} files, ${bundle.knobs.length} knobs)`);
79+}
ts/bundle.tsadded+90−0View file
@@ -0,0 +1,90 @@
1+// The numbl-instrument-v1 bundle: types and validation. This file is the
2+// format's one executable definition; consumers (midiboot) vendor a copy
3+// and producers generate bundles with scripts/build-exports.mjs, which
4+// applies the same checks. See README.md for the driver contract that the
5+// bundled .m files must satisfy.
6+
7+export interface BundleKnob {
8+ key: string;
9+ label: string;
10+ min: number;
11+ max: number;
12+ default: number;
13+ step?: number;
14+ scale?: 'log';
15+}
16+
17+export interface BundleFile {
18+ name: string; // e.g. "render_note.m"; unique within the bundle
19+ content: string; // full source, MATLAB syntax, executed by numbl
20+}
21+
22+export interface InstrumentBundle {
23+ format: 'numbl-instrument-v1';
24+ /** Stable identifier, lowercase letters, digits, hyphens. Hosts key
25+ * caches and track references on it, so it should not change between
26+ * versions of the same instrument. */
27+ id: string;
28+ /** Display name. */
29+ name: string;
30+ description?: string;
31+ /** Provenance, e.g. "simulated_instruments/bass". Informational. */
32+ source?: string;
33+ /** Curated user-editable parameters. Hosts clamp values to [min, max]
34+ * and fill missing ones with the default before every call. */
35+ knobs: BundleKnob[];
36+ /** Every .m source the drivers need, drivers included. Must contain
37+ * render_note.m; prepare.m is optional. */
38+ files: BundleFile[];
39+}
40+
41+const ID_RE = /^[a-z0-9][a-z0-9-]*$/;
42+
43+function fail(id: string, msg: string): never {
44+ throw new Error(`instrument bundle ${id}: ${msg}`);
45+}
46+
47+export function parseBundle(text: string): InstrumentBundle {
48+ let j: unknown;
49+ try {
50+ j = JSON.parse(text);
51+ } catch (err) {
52+ throw new Error(`instrument bundle is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
53+ }
54+ const b = j as InstrumentBundle;
55+ const id = typeof b.id === 'string' ? b.id : '(no id)';
56+ if (b.format !== 'numbl-instrument-v1') fail(id, `unknown format '${String(b.format)}'`);
57+ if (typeof b.id !== 'string' || !ID_RE.test(b.id)) fail(id, `'id' must match ${ID_RE}`);
58+ if (typeof b.name !== 'string' || b.name === '') fail(id, `missing string field 'name'`);
59+ for (const k of ['description', 'source'] as const) {
60+ if (b[k] !== undefined && typeof b[k] !== 'string') fail(id, `'${k}' must be a string when present`);
61+ }
62+ if (!Array.isArray(b.knobs)) fail(id, `missing 'knobs' array`);
63+ for (const k of b.knobs) {
64+ if (typeof k.key !== 'string' || typeof k.label !== 'string') fail(id, `knobs need key and label`);
65+ if (typeof k.min !== 'number' || typeof k.max !== 'number' || typeof k.default !== 'number' || !(k.min < k.max)) {
66+ fail(id, `knob '${k.key}' needs min < max and a default`);
67+ }
68+ if (k.default < k.min || k.default > k.max) fail(id, `knob '${k.key}' default outside its range`);
69+ if (k.step !== undefined && (typeof k.step !== 'number' || !(k.step > 0))) fail(id, `knob '${k.key}' step must be > 0`);
70+ if (k.scale !== undefined && k.scale !== 'log') fail(id, `knob '${k.key}' has unknown scale`);
71+ if (k.scale === 'log' && k.min <= 0) fail(id, `knob '${k.key}' log scale needs min > 0`);
72+ }
73+ if (!Array.isArray(b.files) || b.files.length === 0) fail(id, `missing 'files' array`);
74+ const seen = new Set<string>();
75+ for (const f of b.files) {
76+ if (typeof f.name !== 'string' || !f.name.endsWith('.m') || f.name.includes('/')) {
77+ fail(id, `file '${String(f.name)}' must be a bare .m filename`);
78+ }
79+ if (typeof f.content !== 'string' || f.content === '') fail(id, `file '${f.name}' has no content`);
80+ if (seen.has(f.name)) fail(id, `duplicate file '${f.name}'`);
81+ seen.add(f.name);
82+ }
83+ if (!seen.has('render_note.m')) fail(id, `no render_note.m among the files`);
84+ return b;
85+}
86+
87+/** Whether the bundle declares the optional prepare stage. */
88+export function hasPrepare(b: InstrumentBundle): boolean {
89+ return b.files.some((f) => f.name === 'prepare.m');
90+}