#!/usr/bin/env bun /** * MEMLNaut mode-schema codegen. * * Reads: schemas/schema.json (Draft 2020-12 meta-schema for modes) * schemas/modes/*.json (one file per mode) * * Writes: nisps/modes/generated/_schema.hpp * nisps/modes/generated/schema_types.hpp * playground/src/modes/generated/_schema.ts * playground/src/modes/generated/types.ts * playground/src/modes/generated/index.ts * * Idempotent: regenerating the same schemas yields byte-identical output. * Exits non-zero on validation failure. */ import { readFileSync, writeFileSync, readdirSync, mkdirSync, existsSync } from "node:fs"; import { join, dirname, resolve, basename } from "node:path"; import { fileURLToPath } from "node:url"; import Ajv2020, { type AnySchemaObject } from "ajv/dist/2020.js"; // ----- Types ---------------------------------------------------------------- type Curve = "linear" | "exp" | "log" | "square" | "sqrt" | "sigmoid" | "cubic"; interface ModeSchema { $schema?: string; _note?: string; mode_id: string; engine_id: string; ml: { input_channels: string[]; input_size: number; hidden_layers: number[]; output_size: number; default_spread: number; default_learning_rate: number; default_max_iterations: number; }; params: Array<{ name: string; label: string; min: number; max: number; default: number; curve: Curve; group: string; _note?: string; }>; voice_spaces: string[]; ui: { primary_input: "xy_pad" | "joystick" | "sliders" | "audio_in" | "midi_in" | "none"; show_voice_space_selector: boolean; show_synth_visualizer: boolean; }; } // ----- Path resolution ------------------------------------------------------ const __dirname = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(__dirname, ".."); const SCHEMAS_DIR = join(REPO_ROOT, "schemas"); const MODES_DIR = join(SCHEMAS_DIR, "modes"); const META_SCHEMA_PATH = join(SCHEMAS_DIR, "schema.json"); const CPP_OUT_DIR = join(REPO_ROOT, "nisps", "modes", "generated"); const TS_OUT_DIR = join(REPO_ROOT, "playground", "src", "modes", "generated"); // ----- Helpers -------------------------------------------------------------- function ensureDir(d: string): void { if (!existsSync(d)) { mkdirSync(d, { recursive: true }); } } function readJSON(path: string): T { return JSON.parse(readFileSync(path, "utf8")) as T; } /** * Convert snake_case to PascalCase. * "paf_synth" -> "PafSynth", "memlcelium" -> "Memlcelium" */ function toPascalCase(snake: string): string { return snake .split("_") .map(s => s.length === 0 ? s : s[0].toUpperCase() + s.slice(1)) .join(""); } /** * Convert mode_id to UPPER_SNAKE for #define guards. */ function toUpperSnake(snake: string): string { return snake.toUpperCase(); } /** * Escape a string literal for embedding into a C++ string. * We expect ASCII identifiers + label text only; quote and backslash are escaped. */ function cppStringLit(s: string): string { return '"' + s.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"'; } /** * Escape a string for a TS single-line single-quoted literal. */ function tsStringLit(s: string): string { return "'" + s.replace(/\\/g, "\\\\").replace(/'/g, "\\'") + "'"; } /** * Format a float literal for C++ ensuring `.f` suffix and explicit decimal point. * Required by the perf contract (architecture §3.3). */ function cppFloatLit(n: number): string { if (!Number.isFinite(n)) { throw new Error(`non-finite float: ${n}`); } // toFixed ensures a decimal point; trim trailing zeros but keep at least one digit let s = n.toString(); if (!s.includes(".") && !s.includes("e") && !s.includes("E")) { s += ".0"; } return s + "f"; } function cppCurveEnum(c: Curve): string { // Map snake-case curve names to PascalCase enum values switch (c) { case "linear": return "Curve::Linear"; case "exp": return "Curve::Exp"; case "log": return "Curve::Log"; case "square": return "Curve::Square"; case "sqrt": return "Curve::Sqrt"; case "sigmoid":return "Curve::Sigmoid"; case "cubic": return "Curve::Cubic"; } } const AUTOGEN_BANNER_CPP = (sourceFile: string): string => `// AUTOGENERATED — do not edit. Source: schemas/modes/${sourceFile}. ` + `Run \`bun run codegen/generate.ts\` to regenerate.\n`; const AUTOGEN_BANNER_TS = (sourceFile: string): string => `// AUTOGENERATED — do not edit. Source: schemas/modes/${sourceFile}. ` + `Run \`bun run codegen/generate.ts\` to regenerate.\n`; // ----- Shared types emission ----------------------------------------------- function emitSchemaTypesHpp(): string { return [ "// AUTOGENERATED — do not edit. Run `bun run codegen/generate.ts` to regenerate.", "// Shared C++ types for generated mode schemas.", "//", "// IMPORTANT: this file ships its own minimal `Curve` enum so generated mode", "// headers compile in isolation while stream 1 (`nisps/core/math.hpp`) is in", "// flight. Once stream 1 lands, replace the local enum below with", "// #include \"nisps/core/math.hpp\"", "// and delete the inline definition.", "#ifndef NISPS_GENERATED_SCHEMA_TYPES_HPP", "#define NISPS_GENERATED_SCHEMA_TYPES_HPP", "", "#include ", "#include ", "#include ", "", "namespace nisps::modes::generated {", "", "// TODO(stream-1): replace with `#include \"nisps/core/math.hpp\"` and remove this enum.", "enum class Curve : unsigned char {", " Linear = 0,", " Exp,", " Log,", " Square,", " Sqrt,", " Sigmoid,", " Cubic,", "};", "", "struct Param {", " std::string_view name;", " std::string_view label;", " float min;", " float max;", " float default_value;", " Curve curve;", " std::string_view group;", "};", "", "struct MLConfig {", " std::size_t input_size;", " std::size_t output_size;", " float default_spread;", " float default_learning_rate;", " std::size_t default_max_iterations;", "};", "", "enum class PrimaryInput : unsigned char {", " XYPad = 0,", " Joystick,", " Sliders,", " AudioIn,", " MidiIn,", " None,", "};", "", "struct UIConfig {", " PrimaryInput primary_input;", " bool show_voice_space_selector;", " bool show_synth_visualizer;", "};", "", "} // namespace nisps::modes::generated", "", "#endif // NISPS_GENERATED_SCHEMA_TYPES_HPP", "", ].join("\n"); } function emitSharedTsTypes(): string { return [ "// AUTOGENERATED — do not edit. Run `bun run codegen/generate.ts` to regenerate.", "// Shared TypeScript types for generated mode schemas.", "", "export type Curve =", " | 'linear'", " | 'exp'", " | 'log'", " | 'square'", " | 'sqrt'", " | 'sigmoid'", " | 'cubic';", "", "export type PrimaryInput =", " | 'xy_pad'", " | 'joystick'", " | 'sliders'", " | 'audio_in'", " | 'midi_in'", " | 'none';", "", "export interface Param {", " readonly name: string;", " readonly label: string;", " readonly min: number;", " readonly max: number;", " readonly default: number;", " readonly curve: Curve;", " readonly group: string;", "}", "", "export interface MLConfig {", " readonly input_channels: readonly string[];", " readonly input_size: number;", " readonly hidden_layers: readonly number[];", " readonly output_size: number;", " readonly default_spread: number;", " readonly default_learning_rate: number;", " readonly default_max_iterations: number;", "}", "", "export interface UIConfig {", " readonly primary_input: PrimaryInput;", " readonly show_voice_space_selector: boolean;", " readonly show_synth_visualizer: boolean;", "}", "", "export interface ModeSchema {", " readonly mode_id: string;", " readonly engine_id: string;", " readonly ml: MLConfig;", " readonly params: readonly Param[];", " readonly voice_spaces: readonly string[];", " readonly ui: UIConfig;", "}", "", ].join("\n"); } // ----- Per-mode C++ emission ------------------------------------------------ function emitModeHpp(schema: ModeSchema, sourceFile: string): string { const guard = `NISPS_GENERATED_${toUpperSnake(schema.mode_id)}_SCHEMA_HPP`; const constName = `k${toPascalCase(schema.mode_id)}`; const lines: string[] = []; lines.push(AUTOGEN_BANNER_CPP(sourceFile).trimEnd()); lines.push(`#ifndef ${guard}`); lines.push(`#define ${guard}`); lines.push(""); lines.push("#include \"schema_types.hpp\""); lines.push(""); lines.push("namespace nisps::modes::generated {"); lines.push(""); // mode_id and engine_id lines.push(`inline constexpr std::string_view ${constName}ModeId = ${cppStringLit(schema.mode_id)};`); lines.push(`inline constexpr std::string_view ${constName}EngineId = ${cppStringLit(schema.engine_id)};`); lines.push(""); // input channels lines.push(`inline constexpr std::array ${constName}InputChannels = {{`); for (const ch of schema.ml.input_channels) { lines.push(` ${cppStringLit(ch)},`); } lines.push("}};"); lines.push(""); // hidden layers lines.push(`inline constexpr std::array ${constName}HiddenLayers = {{`); for (const h of schema.ml.hidden_layers) { lines.push(` ${h}u,`); } lines.push("}};"); lines.push(""); // ML config lines.push(`inline constexpr MLConfig ${constName}MLConfig = {`); lines.push(` ${schema.ml.input_size}u,`); lines.push(` ${schema.ml.output_size}u,`); lines.push(` ${cppFloatLit(schema.ml.default_spread)},`); lines.push(` ${cppFloatLit(schema.ml.default_learning_rate)},`); lines.push(` ${schema.ml.default_max_iterations}u,`); lines.push("};"); lines.push(""); // params lines.push(`inline constexpr std::size_t ${constName}ParamCount = ${schema.params.length}u;`); lines.push(`inline constexpr std::array ${constName}Params = {{`); for (const p of schema.params) { lines.push(" Param{"); lines.push(` ${cppStringLit(p.name)},`); lines.push(` ${cppStringLit(p.label)},`); lines.push(` ${cppFloatLit(p.min)},`); lines.push(` ${cppFloatLit(p.max)},`); lines.push(` ${cppFloatLit(p.default)},`); lines.push(` ${cppCurveEnum(p.curve)},`); lines.push(` ${cppStringLit(p.group)},`); lines.push(" },"); } lines.push("}};"); lines.push(""); // voice spaces lines.push(`inline constexpr std::size_t ${constName}VoiceSpaceCount = ${schema.voice_spaces.length}u;`); if (schema.voice_spaces.length > 0) { lines.push(`inline constexpr std::array ${constName}VoiceSpaces = {{`); for (const v of schema.voice_spaces) { lines.push(` ${cppStringLit(v)},`); } lines.push("}};"); } else { // empty arrays of size 0 are technically allowed in C++; but std::array is fine lines.push(`inline constexpr std::array ${constName}VoiceSpaces = {};`); } lines.push(""); // UI let primary: string; switch (schema.ui.primary_input) { case "xy_pad": primary = "PrimaryInput::XYPad"; break; case "joystick": primary = "PrimaryInput::Joystick"; break; case "sliders": primary = "PrimaryInput::Sliders"; break; case "audio_in": primary = "PrimaryInput::AudioIn"; break; case "midi_in": primary = "PrimaryInput::MidiIn"; break; case "none": primary = "PrimaryInput::None"; break; } lines.push(`inline constexpr UIConfig ${constName}UI = {`); lines.push(` ${primary},`); lines.push(` ${schema.ui.show_voice_space_selector ? "true" : "false"},`); lines.push(` ${schema.ui.show_synth_visualizer ? "true" : "false"},`); lines.push("};"); lines.push(""); lines.push("} // namespace nisps::modes::generated"); lines.push(""); lines.push(`#endif // ${guard}`); lines.push(""); return lines.join("\n"); } // ----- Per-mode TS emission ------------------------------------------------- function emitModeTs(schema: ModeSchema, sourceFile: string): string { const constName = `${toPascalCase(schema.mode_id)}Schema`; const paramTypeName = `${toPascalCase(schema.mode_id)}Params`; const lines: string[] = []; lines.push(AUTOGEN_BANNER_TS(sourceFile).trimEnd()); lines.push("import type { ModeSchema } from './types';"); lines.push(""); // Per-param object type (a record of param name -> number) lines.push(`export interface ${paramTypeName} {`); for (const p of schema.params) { lines.push(` readonly ${p.name}: number;`); } lines.push("}"); lines.push(""); // Const schema lines.push(`export const ${constName}: ModeSchema = {`); lines.push(` mode_id: ${tsStringLit(schema.mode_id)},`); lines.push(` engine_id: ${tsStringLit(schema.engine_id)},`); lines.push(" ml: {"); lines.push(" input_channels: ["); for (const ch of schema.ml.input_channels) { lines.push(` ${tsStringLit(ch)},`); } lines.push(" ],"); lines.push(` input_size: ${schema.ml.input_size},`); lines.push(" hidden_layers: ["); for (const h of schema.ml.hidden_layers) { lines.push(` ${h},`); } lines.push(" ],"); lines.push(` output_size: ${schema.ml.output_size},`); lines.push(` default_spread: ${schema.ml.default_spread},`); lines.push(` default_learning_rate: ${schema.ml.default_learning_rate},`); lines.push(` default_max_iterations: ${schema.ml.default_max_iterations},`); lines.push(" },"); lines.push(" params: ["); for (const p of schema.params) { lines.push(" {"); lines.push(` name: ${tsStringLit(p.name)},`); lines.push(` label: ${tsStringLit(p.label)},`); lines.push(` min: ${p.min},`); lines.push(` max: ${p.max},`); lines.push(` default: ${p.default},`); lines.push(` curve: ${tsStringLit(p.curve)},`); lines.push(` group: ${tsStringLit(p.group)},`); lines.push(" },"); } lines.push(" ],"); if (schema.voice_spaces.length === 0) { lines.push(" voice_spaces: [],"); } else { lines.push(" voice_spaces: ["); for (const v of schema.voice_spaces) { lines.push(` ${tsStringLit(v)},`); } lines.push(" ],"); } lines.push(" ui: {"); lines.push(` primary_input: ${tsStringLit(schema.ui.primary_input)},`); lines.push(` show_voice_space_selector: ${schema.ui.show_voice_space_selector},`); lines.push(` show_synth_visualizer: ${schema.ui.show_synth_visualizer},`); lines.push(" },"); lines.push("};"); lines.push(""); return lines.join("\n"); } function emitTsIndex(modeIds: string[]): string { const lines: string[] = []; lines.push("// AUTOGENERATED — do not edit. Run `bun run codegen/generate.ts` to regenerate."); lines.push("// Re-exports every generated mode schema."); lines.push(""); lines.push("export * from './types';"); for (const id of modeIds) { lines.push(`export * from './${id}_schema';`); } lines.push(""); return lines.join("\n"); } // ----- Driver -------------------------------------------------------------- function main(): number { // 1. Load and compile meta-schema if (!existsSync(META_SCHEMA_PATH)) { console.error(`error: meta-schema not found at ${META_SCHEMA_PATH}`); return 1; } const metaSchema = readJSON(META_SCHEMA_PATH); // We pass strict:false because the meta-schema uses `_note` fields that aren't // in the JSON Schema vocab itself; the meta-schema explicitly allows them via // `additionalProperties` rules. const ajv = new Ajv2020({ strict: false, allErrors: true, allowUnionTypes: true, }); const validate = ajv.compile(metaSchema); // 2. Discover all mode schemas if (!existsSync(MODES_DIR)) { console.error(`error: modes directory not found at ${MODES_DIR}`); return 1; } const modeFiles = readdirSync(MODES_DIR) .filter(f => f.endsWith(".json")) .sort(); // deterministic order if (modeFiles.length === 0) { console.error(`error: no mode schemas in ${MODES_DIR}`); return 1; } // 3. Validate + parse all const schemas: Array<{ source: string; schema: ModeSchema }> = []; let errorCount = 0; for (const f of modeFiles) { const path = join(MODES_DIR, f); let raw: unknown; try { raw = readJSON(path); } catch (e) { console.error(`error: ${f}: parse: ${(e as Error).message}`); errorCount++; continue; } if (!validate(raw)) { console.error(`error: ${f}: schema validation failed:`); for (const err of validate.errors ?? []) { console.error(` ${err.instancePath || ""} ${err.message}`); } errorCount++; continue; } const schema = raw as ModeSchema; // Cross-field consistency checks if (schema.params.length !== schema.ml.output_size) { console.error( `error: ${f}: params.length (${schema.params.length}) != ml.output_size (${schema.ml.output_size})` ); errorCount++; continue; } if (schema.ml.input_channels.length !== schema.ml.input_size) { console.error( `error: ${f}: ml.input_channels.length (${schema.ml.input_channels.length}) != ml.input_size (${schema.ml.input_size})` ); errorCount++; continue; } schemas.push({ source: f, schema }); } if (errorCount > 0) { console.error(`\n${errorCount} schema(s) failed; aborting codegen.`); return 1; } // 4. Emit C++ outputs ensureDir(CPP_OUT_DIR); writeFileSync(join(CPP_OUT_DIR, "schema_types.hpp"), emitSchemaTypesHpp()); for (const { source, schema } of schemas) { const out = join(CPP_OUT_DIR, `${schema.mode_id}_schema.hpp`); writeFileSync(out, emitModeHpp(schema, source)); } // 5. Emit TS outputs ensureDir(TS_OUT_DIR); writeFileSync(join(TS_OUT_DIR, "types.ts"), emitSharedTsTypes()); for (const { source, schema } of schemas) { const out = join(TS_OUT_DIR, `${schema.mode_id}_schema.ts`); writeFileSync(out, emitModeTs(schema, source)); } writeFileSync( join(TS_OUT_DIR, "index.ts"), emitTsIndex(schemas.map(s => s.schema.mode_id).sort()) ); // 6. Report console.log(`OK ${schemas.length} mode schema(s) processed.`); console.log(` C++ -> ${CPP_OUT_DIR}`); console.log(` TS -> ${TS_OUT_DIR}`); for (const { schema } of schemas) { console.log( ` - ${schema.mode_id}: ${schema.params.length} params, ` + `${schema.voice_spaces.length} voice space(s), ` + `MLP ${schema.ml.input_size}->[${schema.ml.hidden_layers.join(",")}]->${schema.ml.output_size}` ); } return 0; } // Allow `import` without running when used as a library (e.g. for tests). 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 };