#!/usr/bin/env node // Build exported instrument bundles (format numbl-instrument-v1) for one // instrument repository. Run from the repo root: // // node scripts/build-exports.mjs // // For each export-src//export.json, the bundle's files are that // directory's .m sources (the drivers, render_note.m required) plus every // matlab/*.m of the repo, and the result is written to exports/.json. // The canonical copy of this script lives in // simulated_instruments/instrument-format; instrument repos carry a copy so // they build standalone. import { mkdirSync, readdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs'; import { join } from 'node:path'; const ID_RE = /^[a-z0-9][a-z0-9-]*$/; const SRC = 'export-src'; const OUT = 'exports'; function fail(msg) { console.error(`build-exports: ${msg}`); process.exit(1); } function checkKnobs(id, knobs) { if (!Array.isArray(knobs)) fail(`${id}: 'knobs' must be an array`); for (const k of knobs) { if (typeof k.key !== 'string' || typeof k.label !== 'string') fail(`${id}: knobs need key and label`); if (typeof k.min !== 'number' || typeof k.max !== 'number' || typeof k.default !== 'number' || !(k.min < k.max)) { fail(`${id}: knob '${k.key}' needs min < max and a default`); } if (k.default < k.min || k.default > k.max) fail(`${id}: knob '${k.key}' default outside its range`); if (k.step !== undefined && !(k.step > 0)) fail(`${id}: knob '${k.key}' step must be > 0`); if (k.scale !== undefined && k.scale !== 'log') fail(`${id}: knob '${k.key}' has unknown scale`); if (k.scale === 'log' && k.min <= 0) fail(`${id}: knob '${k.key}' log scale needs min > 0`); } } function mfilesIn(dir) { if (!existsSync(dir)) return []; return readdirSync(dir) .filter((f) => f.endsWith('.m')) .sort() .map((name) => ({ name, content: readFileSync(join(dir, name), 'utf8') })); } if (!existsSync(SRC)) fail(`no ${SRC}/ directory here; run from the instrument repo root`); const shared = mfilesIn('matlab'); const dirs = readdirSync(SRC, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).sort(); if (dirs.length === 0) fail(`${SRC}/ has no instrument directories`); mkdirSync(OUT, { recursive: true }); for (const dir of dirs) { const metaPath = join(SRC, dir, 'export.json'); if (!existsSync(metaPath)) fail(`${metaPath} is missing`); const meta = JSON.parse(readFileSync(metaPath, 'utf8')); const id = meta.id; if (typeof id !== 'string' || !ID_RE.test(id)) fail(`${metaPath}: 'id' must match ${ID_RE}`); if (typeof meta.name !== 'string' || meta.name === '') fail(`${id}: missing 'name'`); checkKnobs(id, meta.knobs ?? []); const drivers = mfilesIn(join(SRC, dir)); if (!drivers.some((f) => f.name === 'render_note.m')) fail(`${id}: no render_note.m in ${SRC}/${dir}/`); const names = new Set(drivers.map((f) => f.name)); const clash = shared.find((f) => names.has(f.name)); if (clash) fail(`${id}: driver file '${clash.name}' collides with matlab/${clash.name}`); const bundle = { format: 'numbl-instrument-v1', id, name: meta.name, ...(meta.description ? { description: meta.description } : {}), ...(meta.source ? { source: meta.source } : {}), knobs: meta.knobs ?? [], files: [...drivers, ...shared], }; const outPath = join(OUT, `${id}.json`); writeFileSync(outPath, JSON.stringify(bundle, null, 2) + '\n'); console.log(`wrote ${outPath} (${bundle.files.length} files, ${bundle.knobs.length} knobs)`); }