d315d2fExported-instrument format: spec, schema, types, build scriptJeremy Magland 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.
7export interface BundleKnob {
8 key: string;
9 label: string;
10 min: number;
11 max: number;
12 default: number;
13 step?: number;
14 scale?: 'log';
15}
17export 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}
22export 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}
41const ID_RE = /^[a-z0-9][a-z0-9-]*$/;
43function fail(id: string, msg: string): never {
44 throw new Error(`instrument bundle ${id}: ${msg}`);
45}
47export 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}
87/** Whether the bundle declares the optional prepare stage. */
88export function hasPrepare(b: InstrumentBundle): boolean {
89 return b.files.some((f) => f.name === 'prepare.m');
90}