memlnaut-nisps/codegen/generate-midi-devices.ts
monkey-w1n5t0n 9a9b66c5ee feat(midi-devices): canonical external-synth CC templates + dual codegen
Add a durable, committed source of truth for external MIDI synth control:
- synth-midi-cc.json: verified CC maps + provenance/sources (8 devices researched)
- schemas/midi_device.schema.json + schemas/midi_devices/*.json: 6 CC-controllable
  device templates (Moog Sub 37/Sub Phatty, Creamware Pro-12 ASB, Elektron Analog
  Keys, ASM Hydrasynth, Roland JD-800), params keyed {id, cc, label, min, max,
  default, group}.
- codegen/generate-midi-devices.ts (isolated from the mode golden test) emits both
  nisps/midi/generated/midi_devices.hpp (no-heap constexpr, firmware+WASM) and
  manifold/src/midi-devices/generated/ (typed catalogue for the browser).
- codegen/seed-midi-devices.ts: reproducible seed from the research artifact.

Lets a performer pick a device and address its parameters by name (not CC number)
on both the firmware and the Manifold browser engine.
2026-06-28 20:03:17 +02:00

324 lines
11 KiB
TypeScript

#!/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<T>(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 <array>");
L.push("#include <cstdint>");
L.push("#include <span>");
L.push("#include <string_view>");
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<const MidiParam> params;");
L.push("};");
L.push("");
for (const d of devices) {
const pn = `k${toPascalCase(d.device_id)}Params`;
L.push(`inline constexpr std::array<MidiParam, ${d.params.length}> ${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<const MidiParam>{${pn}},`);
L.push("};");
L.push("");
}
L.push(`inline constexpr std::size_t kMidiDeviceCount = ${devices.length}u;`);
L.push(`inline constexpr std::array<MidiDevice, kMidiDeviceCount> 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<Record<string, MidiDeviceTemplate>> = {");
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<AnySchemaObject>(META_SCHEMA_PATH);
const ajv = new Ajv2020({ strict: false, allErrors: true, allowUnionTypes: true });
const validate = ajv.compile<DeviceSchema>(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<unknown>(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 || "<root>"} ${err.message}`);
}
errors++;
continue;
}
const d = raw as DeviceSchema;
// uniqueness checks
const ids = new Set<string>();
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 };