#!/usr/bin/env bun /** * MEMLNaut MIDI device-template codegen. * * Reads: schemas/midi_device.schema.json (Draft 2020-12 meta-schema) * schemas/midi_devices/*.json (one file per external MIDI device) * * Writes: nisps/midi/generated/midi_devices.hpp (no-heap constexpr, firmware + WASM) * manifold/src/midi-devices/generated/types.ts (shared TS types) * manifold/src/midi-devices/generated/devices.ts (device catalogue) * manifold/src/midi-devices/generated/index.ts (re-exports) * * One source of truth -> both surfaces. The performer picks a device, sees its params * by name, and selects which the ML drives over MIDI CC. Idempotent: regenerating the * same schemas yields byte-identical output. Exits non-zero on validation failure. * * Deliberately separate from codegen/generate.ts (the mode-schema pipeline) so it cannot * disturb the mode golden test. */ import { readFileSync, writeFileSync, readdirSync, mkdirSync, existsSync } from "node:fs"; import { join, dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import Ajv2020, { type AnySchemaObject } from "ajv/dist/2020.js"; // ----- Types ---------------------------------------------------------------- interface DeviceParam { id: string; cc: number; label: string; min: number; max: number; default: number; group: string; notes?: string; } interface DeviceSchema { $schema?: string; _note?: string; device_id: string; name: string; manufacturer: string; year?: number; midi: { default_channel: number; value_range: [number, number]; notes?: string; }; params: DeviceParam[]; } // ----- Paths ---------------------------------------------------------------- const __dirname = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(__dirname, ".."); const SCHEMAS_DIR = join(REPO_ROOT, "schemas"); const DEVICES_DIR = join(SCHEMAS_DIR, "midi_devices"); const META_SCHEMA_PATH = join(SCHEMAS_DIR, "midi_device.schema.json"); const CPP_OUT_DIR = join(REPO_ROOT, "nisps", "midi", "generated"); const TS_OUT_DIR = join(REPO_ROOT, "manifold", "src", "midi-devices", "generated"); // ----- Helpers -------------------------------------------------------------- function ensureDir(d: string): void { if (!existsSync(d)) mkdirSync(d, { recursive: true }); } function readJSON(path: string): T { return JSON.parse(readFileSync(path, "utf8")) as T; } function toPascalCase(snake: string): string { return snake.split("_").map(s => (s.length === 0 ? s : s[0].toUpperCase() + s.slice(1))).join(""); } function cppStringLit(s: string): string { return '"' + s.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"'; } function tsStringLit(s: string): string { return "'" + s.replace(/\\/g, "\\\\").replace(/'/g, "\\'") + "'"; } const BANNER = "// AUTOGENERATED — do not edit. Source: schemas/midi_devices/*.json. " + "Run `bun run codegen/generate-midi-devices.ts` to regenerate."; // ----- C++ emission --------------------------------------------------------- function emitCppHeader(devices: DeviceSchema[]): string { const L: string[] = []; L.push(BANNER); L.push("//"); L.push("// No-heap, constexpr MIDI device templates for the RP2350 firmware (and WASM)."); L.push("// Names are std::string_view into flash; CC/value fields are uint8_t."); L.push("#ifndef NISPS_MIDI_GENERATED_MIDI_DEVICES_HPP"); L.push("#define NISPS_MIDI_GENERATED_MIDI_DEVICES_HPP"); L.push(""); L.push("#include "); L.push("#include "); L.push("#include "); L.push("#include "); L.push(""); L.push("namespace nisps::midi::generated {"); L.push(""); L.push("struct MidiParam {"); L.push(" std::string_view id;"); L.push(" std::string_view label;"); L.push(" std::uint8_t cc;"); L.push(" std::uint8_t min;"); L.push(" std::uint8_t max;"); L.push(" std::uint8_t default_value;"); L.push(" std::string_view group;"); L.push("};"); L.push(""); L.push("struct MidiDevice {"); L.push(" std::string_view device_id;"); L.push(" std::string_view name;"); L.push(" std::string_view manufacturer;"); L.push(" std::uint8_t default_channel; // 1-16"); L.push(" std::uint8_t value_min;"); L.push(" std::uint8_t value_max;"); L.push(" std::span params;"); L.push("};"); L.push(""); for (const d of devices) { const pn = `k${toPascalCase(d.device_id)}Params`; L.push(`inline constexpr std::array ${pn} = {{`); for (const p of d.params) { L.push(` MidiParam{${cppStringLit(p.id)}, ${cppStringLit(p.label)}, ${p.cc}, ${p.min}, ${p.max}, ${p.default}, ${cppStringLit(p.group)}},`); } L.push("}};"); L.push(""); } for (const d of devices) { const cn = `k${toPascalCase(d.device_id)}`; const pn = `${cn}Params`; L.push(`inline constexpr MidiDevice ${cn} = {`); L.push(` ${cppStringLit(d.device_id)},`); L.push(` ${cppStringLit(d.name)},`); L.push(` ${cppStringLit(d.manufacturer)},`); L.push(` ${d.midi.default_channel},`); L.push(` ${d.midi.value_range[0]},`); L.push(` ${d.midi.value_range[1]},`); L.push(` std::span{${pn}},`); L.push("};"); L.push(""); } L.push(`inline constexpr std::size_t kMidiDeviceCount = ${devices.length}u;`); L.push(`inline constexpr std::array kMidiDevices = {{`); for (const d of devices) { L.push(` k${toPascalCase(d.device_id)},`); } L.push("}};"); L.push(""); L.push("} // namespace nisps::midi::generated"); L.push(""); L.push("#endif // NISPS_MIDI_GENERATED_MIDI_DEVICES_HPP"); L.push(""); return L.join("\n"); } // ----- TS emission ---------------------------------------------------------- function emitTsTypes(): string { return [ BANNER, "// Shared TypeScript types for generated MIDI device templates.", "", "export interface MidiDeviceParam {", " readonly id: string;", " readonly cc: number;", " readonly label: string;", " readonly min: number;", " readonly max: number;", " readonly default: number;", " readonly group: string;", "}", "", "export interface MidiDeviceTemplate {", " readonly device_id: string;", " readonly name: string;", " readonly manufacturer: string;", " readonly year?: number;", " readonly default_channel: number;", " readonly value_min: number;", " readonly value_max: number;", " readonly params: readonly MidiDeviceParam[];", "}", "", ].join("\n"); } function emitTsDevices(devices: DeviceSchema[]): string { const L: string[] = []; L.push(BANNER); L.push("import type { MidiDeviceTemplate } from './types';"); L.push(""); for (const d of devices) { const cn = `${toPascalCase(d.device_id)}Device`; L.push(`export const ${cn}: MidiDeviceTemplate = {`); L.push(` device_id: ${tsStringLit(d.device_id)},`); L.push(` name: ${tsStringLit(d.name)},`); L.push(` manufacturer: ${tsStringLit(d.manufacturer)},`); if (d.year) L.push(` year: ${d.year},`); L.push(` default_channel: ${d.midi.default_channel},`); L.push(` value_min: ${d.midi.value_range[0]},`); L.push(` value_max: ${d.midi.value_range[1]},`); L.push(" params: ["); for (const p of d.params) { L.push(` { id: ${tsStringLit(p.id)}, cc: ${p.cc}, label: ${tsStringLit(p.label)}, min: ${p.min}, max: ${p.max}, default: ${p.default}, group: ${tsStringLit(p.group)} },`); } L.push(" ],"); L.push("};"); L.push(""); } L.push("export const MIDI_DEVICES: readonly MidiDeviceTemplate[] = ["); for (const d of devices) { L.push(` ${toPascalCase(d.device_id)}Device,`); } L.push("];"); L.push(""); L.push("export const MIDI_DEVICES_BY_ID: Readonly> = {"); for (const d of devices) { L.push(` ${tsStringLit(d.device_id)}: ${toPascalCase(d.device_id)}Device,`); } L.push("};"); L.push(""); return L.join("\n"); } function emitTsIndex(): string { return [BANNER, "", "export * from './types';", "export * from './devices';", ""].join("\n"); } // ----- Driver --------------------------------------------------------------- function main(): number { if (!existsSync(META_SCHEMA_PATH)) { console.error(`error: meta-schema not found at ${META_SCHEMA_PATH}`); return 1; } const metaSchema = readJSON(META_SCHEMA_PATH); const ajv = new Ajv2020({ strict: false, allErrors: true, allowUnionTypes: true }); const validate = ajv.compile(metaSchema); if (!existsSync(DEVICES_DIR)) { console.error(`error: devices directory not found at ${DEVICES_DIR}`); return 1; } const files = readdirSync(DEVICES_DIR).filter(f => f.endsWith(".json")).sort(); if (files.length === 0) { console.error(`error: no device schemas in ${DEVICES_DIR}`); return 1; } const devices: DeviceSchema[] = []; let errors = 0; for (const f of files) { const path = join(DEVICES_DIR, f); let raw: unknown; try { raw = readJSON(path); } catch (e) { console.error(`error: ${f}: parse: ${(e as Error).message}`); errors++; continue; } if (!validate(raw)) { console.error(`error: ${f}: schema validation failed:`); for (const err of validate.errors ?? []) { console.error(` ${err.instancePath || ""} ${err.message}`); } errors++; continue; } const d = raw as DeviceSchema; // uniqueness checks const ids = new Set(); for (const p of d.params) { if (ids.has(p.id)) { console.error(`error: ${f}: duplicate param id '${p.id}'`); errors++; } ids.add(p.id); if (p.min > p.max) { console.error(`error: ${f}: param '${p.id}' min>max`); errors++; } } devices.push(d); } if (errors > 0) { console.error(`\n${errors} error(s); aborting codegen.`); return 1; } devices.sort((a, b) => a.device_id.localeCompare(b.device_id)); ensureDir(CPP_OUT_DIR); writeFileSync(join(CPP_OUT_DIR, "midi_devices.hpp"), emitCppHeader(devices)); ensureDir(TS_OUT_DIR); writeFileSync(join(TS_OUT_DIR, "types.ts"), emitTsTypes()); writeFileSync(join(TS_OUT_DIR, "devices.ts"), emitTsDevices(devices)); writeFileSync(join(TS_OUT_DIR, "index.ts"), emitTsIndex()); console.log(`OK ${devices.length} MIDI device template(s) processed.`); console.log(` C++ -> ${join(CPP_OUT_DIR, "midi_devices.hpp")}`); console.log(` TS -> ${TS_OUT_DIR}`); for (const d of devices) { console.log(` - ${d.device_id}: ${d.params.length} params, default ch ${d.midi.default_channel}`); } return 0; } const isMain = (() => { if (typeof process === "undefined") return false; const argv1 = process.argv[1]; if (!argv1) return false; return resolve(argv1) === fileURLToPath(import.meta.url); })(); if (isMain) process.exit(main()); export { main };