memlnaut-nisps/codegen/generate-midi-devices.ts

315 lines
11 KiB
TypeScript
Raw Normal View History

#!/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.
*
refactor(codegen): codegen owns mode identity, per-mode schemas and net dims Phase 3 (S1, S5, S6, S25, L11, L37, ST11, ST12). Behaviour-preserving by construction: the diff on the generated directories is PURELY ADDITIVE (217 insertions, 0 deletions), so no emitted constant changed value. This moves where truth lives; it does not change what truth says. - S5: nine mode headers each carried a mechanically identical 12-field positional ParamSchema aggregate. codegen now emits one `inline constexpr ParamSchema k<Mode>Schema` per mode, and the struct itself moved into generated/schema_types.hpp. Each param_schema() is a one-line return. - S6 + S25: every mode hand-typed its net shape a second time as MLP template args, duplicating the schema's own dims. codegen emits a `<Mode>MLP` alias built from the already-emitted constants (not re-literalled), and all nine modes use it. NMaxExamples still defaults from kDefaultMaxExamples (Phase 2). - S1: model.ts hand-imported all nine schemas by name and hand-paired each with its overlay — so the SET of modes was hand-maintained and could silently drift from codegen. codegen now emits ALL_MODE_SCHEMAS; SCHEMA_MODE_OVERLAYS is purely display truth (label/glyph/css/order), which stays hand-curated. - L37: deleted the hand-written modeEngineId switch, which duplicated schema.engine_id and silently defaulted unknown modes to 'thru'. Routes on MFMode.engineId with exactly one documented exception: sound_analysis_midi declares engine_id 'thru' because its ModeBase audio slot really is NoOpEngine, while it separately drives the real AnalysisEngine. - L11: ExternalSynthMIDIMode's shape was literal in two places; now named once in an ext_synth_defaults namespace with an ExtSynthMIDIMLP alias. Full folding into the JSON pipeline is NOT done and the reason is recorded in-file: it is a template family over an externally-supplied Device and variable NOut, with no single (device, NOut) schema to author. - ST12: extracted codegen/lib.ts for the helpers both generators duplicated, and corrected the comment that claimed they had to be separate. Proof the extraction was behaviour-free: regenerating the MIDI-device outputs produces a byte-identical tree. - ST11: deleted codegen/templates/ — dead "reference" files no generator reads, already drifted from the real emitters. Gates: run-all-tests.sh ALL GREEN; codegen idempotent (re-running both generators yields no further diff), which is what CI's dirty-diff gate checks.
2026-07-21 14:02:23 +02:00
* 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).
*/
refactor(codegen): codegen owns mode identity, per-mode schemas and net dims Phase 3 (S1, S5, S6, S25, L11, L37, ST11, ST12). Behaviour-preserving by construction: the diff on the generated directories is PURELY ADDITIVE (217 insertions, 0 deletions), so no emitted constant changed value. This moves where truth lives; it does not change what truth says. - S5: nine mode headers each carried a mechanically identical 12-field positional ParamSchema aggregate. codegen now emits one `inline constexpr ParamSchema k<Mode>Schema` per mode, and the struct itself moved into generated/schema_types.hpp. Each param_schema() is a one-line return. - S6 + S25: every mode hand-typed its net shape a second time as MLP template args, duplicating the schema's own dims. codegen emits a `<Mode>MLP` alias built from the already-emitted constants (not re-literalled), and all nine modes use it. NMaxExamples still defaults from kDefaultMaxExamples (Phase 2). - S1: model.ts hand-imported all nine schemas by name and hand-paired each with its overlay — so the SET of modes was hand-maintained and could silently drift from codegen. codegen now emits ALL_MODE_SCHEMAS; SCHEMA_MODE_OVERLAYS is purely display truth (label/glyph/css/order), which stays hand-curated. - L37: deleted the hand-written modeEngineId switch, which duplicated schema.engine_id and silently defaulted unknown modes to 'thru'. Routes on MFMode.engineId with exactly one documented exception: sound_analysis_midi declares engine_id 'thru' because its ModeBase audio slot really is NoOpEngine, while it separately drives the real AnalysisEngine. - L11: ExternalSynthMIDIMode's shape was literal in two places; now named once in an ext_synth_defaults namespace with an ExtSynthMIDIMLP alias. Full folding into the JSON pipeline is NOT done and the reason is recorded in-file: it is a template family over an externally-supplied Device and variable NOut, with no single (device, NOut) schema to author. - ST12: extracted codegen/lib.ts for the helpers both generators duplicated, and corrected the comment that claimed they had to be separate. Proof the extraction was behaviour-free: regenerating the MIDI-device outputs produces a byte-identical tree. - ST11: deleted codegen/templates/ — dead "reference" files no generator reads, already drifted from the real emitters. Gates: run-all-tests.sh ALL GREEN; codegen idempotent (re-running both generators yields no further diff), which is what CI's dirty-diff gate checks.
2026-07-21 14:02:23 +02:00
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";
refactor(codegen): codegen owns mode identity, per-mode schemas and net dims Phase 3 (S1, S5, S6, S25, L11, L37, ST11, ST12). Behaviour-preserving by construction: the diff on the generated directories is PURELY ADDITIVE (217 insertions, 0 deletions), so no emitted constant changed value. This moves where truth lives; it does not change what truth says. - S5: nine mode headers each carried a mechanically identical 12-field positional ParamSchema aggregate. codegen now emits one `inline constexpr ParamSchema k<Mode>Schema` per mode, and the struct itself moved into generated/schema_types.hpp. Each param_schema() is a one-line return. - S6 + S25: every mode hand-typed its net shape a second time as MLP template args, duplicating the schema's own dims. codegen emits a `<Mode>MLP` alias built from the already-emitted constants (not re-literalled), and all nine modes use it. NMaxExamples still defaults from kDefaultMaxExamples (Phase 2). - S1: model.ts hand-imported all nine schemas by name and hand-paired each with its overlay — so the SET of modes was hand-maintained and could silently drift from codegen. codegen now emits ALL_MODE_SCHEMAS; SCHEMA_MODE_OVERLAYS is purely display truth (label/glyph/css/order), which stays hand-curated. - L37: deleted the hand-written modeEngineId switch, which duplicated schema.engine_id and silently defaulted unknown modes to 'thru'. Routes on MFMode.engineId with exactly one documented exception: sound_analysis_midi declares engine_id 'thru' because its ModeBase audio slot really is NoOpEngine, while it separately drives the real AnalysisEngine. - L11: ExternalSynthMIDIMode's shape was literal in two places; now named once in an ext_synth_defaults namespace with an ExtSynthMIDIMLP alias. Full folding into the JSON pipeline is NOT done and the reason is recorded in-file: it is a template family over an externally-supplied Device and variable NOut, with no single (device, NOut) schema to author. - ST12: extracted codegen/lib.ts for the helpers both generators duplicated, and corrected the comment that claimed they had to be separate. Proof the extraction was behaviour-free: regenerating the MIDI-device outputs produces a byte-identical tree. - ST11: deleted codegen/templates/ — dead "reference" files no generator reads, already drifted from the real emitters. Gates: run-all-tests.sh ALL GREEN; codegen idempotent (re-running both generators yields no further diff), which is what CI's dirty-diff gate checks.
2026-07-21 14:02:23 +02:00
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 --------------------------------------------------------------
refactor(codegen): codegen owns mode identity, per-mode schemas and net dims Phase 3 (S1, S5, S6, S25, L11, L37, ST11, ST12). Behaviour-preserving by construction: the diff on the generated directories is PURELY ADDITIVE (217 insertions, 0 deletions), so no emitted constant changed value. This moves where truth lives; it does not change what truth says. - S5: nine mode headers each carried a mechanically identical 12-field positional ParamSchema aggregate. codegen now emits one `inline constexpr ParamSchema k<Mode>Schema` per mode, and the struct itself moved into generated/schema_types.hpp. Each param_schema() is a one-line return. - S6 + S25: every mode hand-typed its net shape a second time as MLP template args, duplicating the schema's own dims. codegen emits a `<Mode>MLP` alias built from the already-emitted constants (not re-literalled), and all nine modes use it. NMaxExamples still defaults from kDefaultMaxExamples (Phase 2). - S1: model.ts hand-imported all nine schemas by name and hand-paired each with its overlay — so the SET of modes was hand-maintained and could silently drift from codegen. codegen now emits ALL_MODE_SCHEMAS; SCHEMA_MODE_OVERLAYS is purely display truth (label/glyph/css/order), which stays hand-curated. - L37: deleted the hand-written modeEngineId switch, which duplicated schema.engine_id and silently defaulted unknown modes to 'thru'. Routes on MFMode.engineId with exactly one documented exception: sound_analysis_midi declares engine_id 'thru' because its ModeBase audio slot really is NoOpEngine, while it separately drives the real AnalysisEngine. - L11: ExternalSynthMIDIMode's shape was literal in two places; now named once in an ext_synth_defaults namespace with an ExtSynthMIDIMLP alias. Full folding into the JSON pipeline is NOT done and the reason is recorded in-file: it is a template family over an externally-supplied Device and variable NOut, with no single (device, NOut) schema to author. - ST12: extracted codegen/lib.ts for the helpers both generators duplicated, and corrected the comment that claimed they had to be separate. Proof the extraction was behaviour-free: regenerating the MIDI-device outputs produces a byte-identical tree. - ST11: deleted codegen/templates/ — dead "reference" files no generator reads, already drifted from the real emitters. Gates: run-all-tests.sh ALL GREEN; codegen idempotent (re-running both generators yields no further diff), which is what CI's dirty-diff gate checks.
2026-07-21 14:02:23 +02:00
// 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 <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 };