#!/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 a separate DRIVER from codegen/generate.ts (the mode-schema * pipeline): different schema dir, different meta-schema, different output * dirs, and generate.ts's golden test must not depend on MIDI-device schema * state. That does not require duplicate helpers, though — the small * string/fs helpers both drivers need live once in ./lib.ts (ST12). */ import { writeFileSync, readdirSync, 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"; import { ensureDir, readJSON, toPascalCase, cppStringLit, tsStringLit } from "./lib.ts"; // ----- 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 -------------------------------------------------------------- // ensureDir/readJSON/toPascalCase/cppStringLit/tsStringLit now live in // ./lib.ts (ST12, shared with generate.ts). 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 };