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.
324 lines
11 KiB
TypeScript
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 };
|