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