From 129e28b20719a71011a58e26b68fa0427e32b705 Mon Sep 17 00:00:00 2001 From: w1n5t0n Date: Wed, 29 Apr 2026 15:29:08 +0300 Subject: [PATCH] Add codegen tool: schemas -> C++ headers + TS modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bun-runnable TypeScript script (codegen/generate.ts) that: - Validates each schemas/modes/*.json against the meta-schema via ajv (Draft 2020-12). - Cross-checks params.length == ml.output_size and ml.input_channels.length == ml.input_size. - Emits constexpr C++ data into nisps/modes/generated/ (one schema_types.hpp + one _schema.hpp per mode). Uses std::string_view + std::array; no std::vector, no heap, .f suffixed float literals (perf contract §3.3). - Emits TS modules into playground/src/modes/generated/ (one types.ts + one _schema.ts + index.ts barrel). - Idempotent: re-running yields byte-identical output. - Exits non-zero on schema validation failure. Reference templates live in codegen/templates/ (not consumed at codegen time -- for human reviewers). Golden test (codegen/tests/golden_test.ts) snapshots paf_synth_schema.{hpp,ts} and verifies regeneration matches the golden + that a second run is idempotent. Until stream 1 lands nisps/core/math.hpp, schema_types.hpp ships its own minimal Curve enum with a TODO marker pointing at the eventual include. --- codegen/.gitignore | 2 + codegen/README.md | 67 +++ codegen/generate.ts | 596 ++++++++++++++++++++++ codegen/package.json | 19 + codegen/templates/cpp_schema.hpp.template | 53 ++ codegen/templates/ts_schema.ts.template | 44 ++ codegen/tests/golden/paf_synth_schema.hpp | 353 +++++++++++++ codegen/tests/golden/paf_synth_schema.ts | 374 ++++++++++++++ codegen/tests/golden_test.ts | 119 +++++ codegen/tsconfig.json | 16 + 10 files changed, 1643 insertions(+) create mode 100644 codegen/.gitignore create mode 100644 codegen/README.md create mode 100644 codegen/generate.ts create mode 100644 codegen/package.json create mode 100644 codegen/templates/cpp_schema.hpp.template create mode 100644 codegen/templates/ts_schema.ts.template create mode 100644 codegen/tests/golden/paf_synth_schema.hpp create mode 100644 codegen/tests/golden/paf_synth_schema.ts create mode 100644 codegen/tests/golden_test.ts create mode 100644 codegen/tsconfig.json diff --git a/codegen/.gitignore b/codegen/.gitignore new file mode 100644 index 0000000..ff2c585 --- /dev/null +++ b/codegen/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +*.tsbuildinfo diff --git a/codegen/README.md b/codegen/README.md new file mode 100644 index 0000000..6985428 --- /dev/null +++ b/codegen/README.md @@ -0,0 +1,67 @@ +# MEMLNaut Mode-Schema Codegen + +Bun + TypeScript tool that turns `schemas/modes/*.json` into: + +- C++ headers under `nisps/modes/generated/_schema.hpp` (`constexpr` data, no runtime cost). +- TypeScript modules under `playground/src/modes/generated/_schema.ts` (typed `ModeSchema` objects). + +The schemas are validated against `schemas/schema.json` (JSON Schema Draft 2020-12) on every run. Codegen exits non-zero if any schema fails validation. + +## Run + +```bash +cd codegen +bun install # one-shot, fetches ajv + types +bun run generate.ts # or: bun run generate +``` + +The script writes everything into the two output dirs in one shot. Re-running with no schema changes is a no-op (byte-identical output). + +## Tests + +`tests/golden_test.ts` regenerates from the live schemas into a temp dir and diffs against `tests/golden/`. The golden directory contains a snapshot of `paf_synth_schema.{hpp,ts}`. To refresh after intentional codegen changes: + +```bash +bun run generate.ts +cp ../nisps/modes/generated/paf_synth_schema.hpp tests/golden/ +cp ../playground/src/modes/generated/paf_synth_schema.ts tests/golden/ +``` + +Run the test: + +```bash +bun run test +``` + +## Adding a new mode + +1. Drop a new `.json` into `schemas/modes/` (must validate against `schemas/schema.json`). +2. Run `bun run generate.ts`. +3. Commit the JSON + the regenerated C++/TS pair. + +## Layout + +``` +codegen/ +├── package.json # ajv (+ formats) + bun-types +├── tsconfig.json +├── generate.ts # ~400 lines, single entrypoint +├── README.md # this file +├── templates/ # reference templates (NOT consumed — for review) +│ ├── cpp_schema.hpp.template +│ └── ts_schema.ts.template +└── tests/ + ├── golden_test.ts # diffs latest output against tests/golden/ + └── golden/ + ├── paf_synth_schema.hpp + └── paf_synth_schema.ts +``` + +## Conventions + +- **C++ namespace**: `nisps::modes::generated`. All generated symbols are `inline constexpr` so `#include`-ing the header in multiple TUs is safe. +- **C++ types**: `std::array`, `std::string_view`, `std::size_t`. No `std::vector`, no heap. +- **`Curve` enum**: declared in `nisps/modes/generated/schema_types.hpp` as a temporary local copy. Once stream 1 lands `nisps/core/math.hpp`, replace the local enum with an `#include` (search for `TODO(stream-1)` in the generated header). +- **Float literals**: emitted with explicit `.f` suffix and decimal point, per the perf contract (architecture §3.3). +- **Order**: schemas are processed in alphabetical order of mode_id so output is stable. +- **Naming**: `mode_id` is `snake_case` in JSON; the generated C++ const prefix is `k` + PascalCase (e.g. `kPafSynthParams`); TS const is PascalCase + `Schema` (e.g. `PafSynthSchema`). diff --git a/codegen/generate.ts b/codegen/generate.ts new file mode 100644 index 0000000..66cf019 --- /dev/null +++ b/codegen/generate.ts @@ -0,0 +1,596 @@ +#!/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 }; diff --git a/codegen/package.json b/codegen/package.json new file mode 100644 index 0000000..a76fa9b --- /dev/null +++ b/codegen/package.json @@ -0,0 +1,19 @@ +{ + "name": "memlnaut-codegen", + "version": "0.1.0", + "private": true, + "description": "Codegen for MEMLNaut mode schemas (JSON -> C++ + TypeScript).", + "type": "module", + "scripts": { + "generate": "bun run generate.ts", + "test": "bun run tests/golden_test.ts" + }, + "dependencies": { + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1" + }, + "devDependencies": { + "typescript": "^5.6.3", + "@types/node": "^22.9.0" + } +} diff --git a/codegen/templates/cpp_schema.hpp.template b/codegen/templates/cpp_schema.hpp.template new file mode 100644 index 0000000..63b7ccb --- /dev/null +++ b/codegen/templates/cpp_schema.hpp.template @@ -0,0 +1,53 @@ +// AUTOGENERATED — do not edit. Source: schemas/modes/{{SOURCE_FILE}}. Run `bun run codegen/generate.ts` to regenerate. +// +// This file is a reference template showing the shape of each generated C++ +// header. It is NOT consumed at codegen time — `codegen/generate.ts` builds the +// strings programmatically (see emitModeHpp). The template lives here so that: +// 1. reviewers can see the intended structure at a glance, +// 2. anyone tweaking the codegen has a "what should this look like?" anchor. +// +// Placeholders use {{NAME}} syntax purely for documentation. + +#ifndef NISPS_GENERATED_{{MODE_ID_UPPER}}_SCHEMA_HPP +#define NISPS_GENERATED_{{MODE_ID_UPPER}}_SCHEMA_HPP + +#include "schema_types.hpp" + +namespace nisps::modes::generated { + +inline constexpr std::string_view k{{ModeId}}ModeId = "{{mode_id}}"; +inline constexpr std::string_view k{{ModeId}}EngineId = "{{engine_id}}"; + +inline constexpr std::array k{{ModeId}}InputChannels = {{ + "{{input_channel_0}}", + /* ... */ +}}; + +inline constexpr std::array k{{ModeId}}HiddenLayers = {{ + /* ... */ +}}; + +inline constexpr MLConfig k{{ModeId}}MLConfig = { + {{input_size}}u, {{output_size}}u, + {{default_spread}}f, {{default_lr}}f, {{default_iter}}u, +}; + +inline constexpr std::size_t k{{ModeId}}ParamCount = {{N_PARAMS}}u; +inline constexpr std::array k{{ModeId}}Params = {{ + Param{ "{{name}}", "{{label}}", {{min}}f, {{max}}f, {{default}}f, Curve::{{Curve}}, "{{group}}" }, + /* ... */ +}}; + +inline constexpr std::size_t k{{ModeId}}VoiceSpaceCount = {{N_VS}}u; +inline constexpr std::array k{{ModeId}}VoiceSpaces = {{ + /* ... */ +}}; + +inline constexpr UIConfig k{{ModeId}}UI = { + PrimaryInput::{{PrimaryInput}}, + {{show_voice_space_selector}}, {{show_synth_visualizer}}, +}; + +} // namespace nisps::modes::generated + +#endif // NISPS_GENERATED_{{MODE_ID_UPPER}}_SCHEMA_HPP diff --git a/codegen/templates/ts_schema.ts.template b/codegen/templates/ts_schema.ts.template new file mode 100644 index 0000000..e52c010 --- /dev/null +++ b/codegen/templates/ts_schema.ts.template @@ -0,0 +1,44 @@ +// AUTOGENERATED — do not edit. Source: schemas/modes/{{SOURCE_FILE}}. Run `bun run codegen/generate.ts` to regenerate. +// +// Reference template for the TypeScript codegen output. Not consumed at runtime +// (the codegen builds strings programmatically in emitModeTs). Lives here for +// human reviewers — placeholders use {{NAME}} syntax. + +import type { ModeSchema } from './types'; + +export interface {{ModeId}}Params { + readonly {{param_name_0}}: number; + /* ... */ +} + +export const {{ModeId}}Schema: ModeSchema = { + mode_id: '{{mode_id}}', + engine_id: '{{engine_id}}', + ml: { + input_channels: ['{{input_channel_0}}', /* ... */], + input_size: {{input_size}}, + hidden_layers: [/* ... */], + output_size: {{output_size}}, + default_spread: {{default_spread}}, + default_learning_rate: {{default_lr}}, + default_max_iterations: {{default_iter}}, + }, + params: [ + { + name: '{{name}}', + label: '{{label}}', + min: {{min}}, + max: {{max}}, + default: {{default}}, + curve: '{{curve}}', + group: '{{group}}', + }, + /* ... */ + ], + voice_spaces: [/* ... */], + ui: { + primary_input: '{{primary_input}}', + show_voice_space_selector: {{show_voice_space_selector}}, + show_synth_visualizer: {{show_synth_visualizer}}, + }, +}; diff --git a/codegen/tests/golden/paf_synth_schema.hpp b/codegen/tests/golden/paf_synth_schema.hpp new file mode 100644 index 0000000..887355e --- /dev/null +++ b/codegen/tests/golden/paf_synth_schema.hpp @@ -0,0 +1,353 @@ +// AUTOGENERATED — do not edit. Source: schemas/modes/paf_synth.json. Run `bun run codegen/generate.ts` to regenerate. +#ifndef NISPS_GENERATED_PAF_SYNTH_SCHEMA_HPP +#define NISPS_GENERATED_PAF_SYNTH_SCHEMA_HPP + +#include "schema_types.hpp" + +namespace nisps::modes::generated { + +inline constexpr std::string_view kPafSynthModeId = "paf_synth"; +inline constexpr std::string_view kPafSynthEngineId = "paf_synth"; + +inline constexpr std::array kPafSynthInputChannels = {{ + "joy_x", + "joy_y", + "joy_z", + "joy_w", +}}; + +inline constexpr std::array kPafSynthHiddenLayers = {{ + 10u, + 10u, + 14u, +}}; + +inline constexpr MLConfig kPafSynthMLConfig = { + 4u, + 33u, + 0.6f, + 1.0f, + 1000u, +}; + +inline constexpr std::size_t kPafSynthParamCount = 33u; +inline constexpr std::array kPafSynthParams = {{ + Param{ + "p00", + "Param 00", + 0.0f, + 1.0f, + 0.5f, + Curve::Linear, + "general", + }, + Param{ + "p01", + "Param 01", + 0.0f, + 1.0f, + 0.5f, + Curve::Linear, + "general", + }, + Param{ + "paf0_cf", + "PAF0 Center Freq", + 0.0f, + 1.0f, + 0.5f, + Curve::Linear, + "operators", + }, + Param{ + "paf1_cf", + "PAF1 Center Freq", + 0.0f, + 1.0f, + 0.5f, + Curve::Linear, + "operators", + }, + Param{ + "p04", + "Param 04", + 0.0f, + 1.0f, + 0.5f, + Curve::Linear, + "general", + }, + Param{ + "paf0_bw", + "PAF0 Bandwidth", + 0.0f, + 1.0f, + 0.5f, + Curve::Linear, + "operators", + }, + Param{ + "paf1_bw", + "PAF1 Bandwidth", + 0.0f, + 1.0f, + 0.5f, + Curve::Linear, + "operators", + }, + Param{ + "p07", + "Param 07", + 0.0f, + 1.0f, + 0.5f, + Curve::Linear, + "general", + }, + Param{ + "paf0_vib", + "PAF0 Vibrato Depth", + 0.0f, + 1.0f, + 0.0f, + Curve::Square, + "modulation", + }, + Param{ + "paf1_vib", + "PAF1 Vibrato Depth", + 0.0f, + 1.0f, + 0.0f, + Curve::Square, + "modulation", + }, + Param{ + "p10", + "Param 10", + 0.0f, + 1.0f, + 0.5f, + Curve::Linear, + "general", + }, + Param{ + "paf0_vfr", + "PAF0 Vibrato Rate", + 0.0f, + 1.0f, + 0.5f, + Curve::Square, + "modulation", + }, + Param{ + "paf1_vfr", + "PAF1 Vibrato Rate", + 0.0f, + 1.0f, + 0.5f, + Curve::Square, + "modulation", + }, + Param{ + "p13", + "Param 13", + 0.0f, + 1.0f, + 0.5f, + Curve::Linear, + "general", + }, + Param{ + "paf0_shift", + "PAF0 Shift", + 0.0f, + 1.0f, + 0.5f, + Curve::Linear, + "operators", + }, + Param{ + "paf1_shift", + "PAF1 Shift", + 0.0f, + 1.0f, + 0.5f, + Curve::Linear, + "operators", + }, + Param{ + "p16", + "Param 16", + 0.0f, + 1.0f, + 0.5f, + Curve::Linear, + "general", + }, + Param{ + "dl1mix", + "Delay Mix", + 0.0f, + 1.0f, + 0.0f, + Curve::Square, + "delay", + }, + Param{ + "p18", + "Param 18", + 0.0f, + 1.0f, + 0.5f, + Curve::Linear, + "general", + }, + Param{ + "dlfb", + "Delay Feedback", + 0.0f, + 1.0f, + 0.5f, + Curve::Linear, + "delay", + }, + Param{ + "env_decay", + "Env Decay", + 0.0f, + 1.0f, + 0.3f, + Curve::Square, + "envelope", + }, + Param{ + "p21", + "Param 21", + 0.0f, + 1.0f, + 0.5f, + Curve::Linear, + "general", + }, + Param{ + "p22", + "Param 22", + 0.0f, + 1.0f, + 0.5f, + Curve::Linear, + "general", + }, + Param{ + "p23", + "Param 23", + 0.0f, + 1.0f, + 0.5f, + Curve::Linear, + "general", + }, + Param{ + "p24", + "Param 24", + 0.0f, + 1.0f, + 0.5f, + Curve::Linear, + "general", + }, + Param{ + "p25", + "Param 25", + 0.0f, + 1.0f, + 0.5f, + Curve::Linear, + "general", + }, + Param{ + "shape_gain", + "Sine Shape Gain", + 0.0f, + 1.0f, + 0.0f, + Curve::Square, + "shaper", + }, + Param{ + "shape_asym", + "Sine Shape Asym", + 0.0f, + 1.0f, + 0.0f, + Curve::Square, + "shaper", + }, + Param{ + "shape_mix", + "Sine Shape Mix", + 0.0f, + 1.0f, + 0.0f, + Curve::Linear, + "shaper", + }, + Param{ + "rm_gain", + "Ring Mod Gain", + 0.0f, + 1.0f, + 0.0f, + Curve::Square, + "shaper", + }, + Param{ + "env_attack", + "Env Attack", + 0.0f, + 1.0f, + 0.0f, + Curve::Linear, + "envelope", + }, + Param{ + "env_sustain", + "Env Sustain", + 0.0f, + 1.0f, + 0.5f, + Curve::Linear, + "envelope", + }, + Param{ + "env_release", + "Env Release", + 0.0f, + 1.0f, + 0.3f, + Curve::Linear, + "envelope", + }, +}}; + +inline constexpr std::size_t kPafSynthVoiceSpaceCount = 7u; +inline constexpr std::array kPafSynthVoiceSpaces = {{ + "Ellipticacacia", + "Rowantares", + "Neemeda", + "Aquillow", + "Magnetarch", + "Elderstar", + "Ipeleiades", +}}; + +inline constexpr UIConfig kPafSynthUI = { + PrimaryInput::XYPad, + true, + true, +}; + +} // namespace nisps::modes::generated + +#endif // NISPS_GENERATED_PAF_SYNTH_SCHEMA_HPP diff --git a/codegen/tests/golden/paf_synth_schema.ts b/codegen/tests/golden/paf_synth_schema.ts new file mode 100644 index 0000000..6b66bc0 --- /dev/null +++ b/codegen/tests/golden/paf_synth_schema.ts @@ -0,0 +1,374 @@ +// AUTOGENERATED — do not edit. Source: schemas/modes/paf_synth.json. Run `bun run codegen/generate.ts` to regenerate. +import type { ModeSchema } from './types'; + +export interface PafSynthParams { + readonly p00: number; + readonly p01: number; + readonly paf0_cf: number; + readonly paf1_cf: number; + readonly p04: number; + readonly paf0_bw: number; + readonly paf1_bw: number; + readonly p07: number; + readonly paf0_vib: number; + readonly paf1_vib: number; + readonly p10: number; + readonly paf0_vfr: number; + readonly paf1_vfr: number; + readonly p13: number; + readonly paf0_shift: number; + readonly paf1_shift: number; + readonly p16: number; + readonly dl1mix: number; + readonly p18: number; + readonly dlfb: number; + readonly env_decay: number; + readonly p21: number; + readonly p22: number; + readonly p23: number; + readonly p24: number; + readonly p25: number; + readonly shape_gain: number; + readonly shape_asym: number; + readonly shape_mix: number; + readonly rm_gain: number; + readonly env_attack: number; + readonly env_sustain: number; + readonly env_release: number; +} + +export const PafSynthSchema: ModeSchema = { + mode_id: 'paf_synth', + engine_id: 'paf_synth', + ml: { + input_channels: [ + 'joy_x', + 'joy_y', + 'joy_z', + 'joy_w', + ], + input_size: 4, + hidden_layers: [ + 10, + 10, + 14, + ], + output_size: 33, + default_spread: 0.6, + default_learning_rate: 1, + default_max_iterations: 1000, + }, + params: [ + { + name: 'p00', + label: 'Param 00', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'general', + }, + { + name: 'p01', + label: 'Param 01', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'general', + }, + { + name: 'paf0_cf', + label: 'PAF0 Center Freq', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'operators', + }, + { + name: 'paf1_cf', + label: 'PAF1 Center Freq', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'operators', + }, + { + name: 'p04', + label: 'Param 04', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'general', + }, + { + name: 'paf0_bw', + label: 'PAF0 Bandwidth', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'operators', + }, + { + name: 'paf1_bw', + label: 'PAF1 Bandwidth', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'operators', + }, + { + name: 'p07', + label: 'Param 07', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'general', + }, + { + name: 'paf0_vib', + label: 'PAF0 Vibrato Depth', + min: 0, + max: 1, + default: 0, + curve: 'square', + group: 'modulation', + }, + { + name: 'paf1_vib', + label: 'PAF1 Vibrato Depth', + min: 0, + max: 1, + default: 0, + curve: 'square', + group: 'modulation', + }, + { + name: 'p10', + label: 'Param 10', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'general', + }, + { + name: 'paf0_vfr', + label: 'PAF0 Vibrato Rate', + min: 0, + max: 1, + default: 0.5, + curve: 'square', + group: 'modulation', + }, + { + name: 'paf1_vfr', + label: 'PAF1 Vibrato Rate', + min: 0, + max: 1, + default: 0.5, + curve: 'square', + group: 'modulation', + }, + { + name: 'p13', + label: 'Param 13', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'general', + }, + { + name: 'paf0_shift', + label: 'PAF0 Shift', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'operators', + }, + { + name: 'paf1_shift', + label: 'PAF1 Shift', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'operators', + }, + { + name: 'p16', + label: 'Param 16', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'general', + }, + { + name: 'dl1mix', + label: 'Delay Mix', + min: 0, + max: 1, + default: 0, + curve: 'square', + group: 'delay', + }, + { + name: 'p18', + label: 'Param 18', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'general', + }, + { + name: 'dlfb', + label: 'Delay Feedback', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'delay', + }, + { + name: 'env_decay', + label: 'Env Decay', + min: 0, + max: 1, + default: 0.3, + curve: 'square', + group: 'envelope', + }, + { + name: 'p21', + label: 'Param 21', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'general', + }, + { + name: 'p22', + label: 'Param 22', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'general', + }, + { + name: 'p23', + label: 'Param 23', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'general', + }, + { + name: 'p24', + label: 'Param 24', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'general', + }, + { + name: 'p25', + label: 'Param 25', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'general', + }, + { + name: 'shape_gain', + label: 'Sine Shape Gain', + min: 0, + max: 1, + default: 0, + curve: 'square', + group: 'shaper', + }, + { + name: 'shape_asym', + label: 'Sine Shape Asym', + min: 0, + max: 1, + default: 0, + curve: 'square', + group: 'shaper', + }, + { + name: 'shape_mix', + label: 'Sine Shape Mix', + min: 0, + max: 1, + default: 0, + curve: 'linear', + group: 'shaper', + }, + { + name: 'rm_gain', + label: 'Ring Mod Gain', + min: 0, + max: 1, + default: 0, + curve: 'square', + group: 'shaper', + }, + { + name: 'env_attack', + label: 'Env Attack', + min: 0, + max: 1, + default: 0, + curve: 'linear', + group: 'envelope', + }, + { + name: 'env_sustain', + label: 'Env Sustain', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'envelope', + }, + { + name: 'env_release', + label: 'Env Release', + min: 0, + max: 1, + default: 0.3, + curve: 'linear', + group: 'envelope', + }, + ], + voice_spaces: [ + 'Ellipticacacia', + 'Rowantares', + 'Neemeda', + 'Aquillow', + 'Magnetarch', + 'Elderstar', + 'Ipeleiades', + ], + ui: { + primary_input: 'xy_pad', + show_voice_space_selector: true, + show_synth_visualizer: true, + }, +}; diff --git a/codegen/tests/golden_test.ts b/codegen/tests/golden_test.ts new file mode 100644 index 0000000..ba2777e --- /dev/null +++ b/codegen/tests/golden_test.ts @@ -0,0 +1,119 @@ +#!/usr/bin/env bun +/** + * Golden test: re-runs codegen and asserts that the freshly-generated + * paf_synth_schema.{hpp,ts} files are byte-identical to the snapshots in + * codegen/tests/golden/. + * + * If you change the codegen template intentionally, regenerate the goldens: + * bun run generate.ts + * cp ../nisps/modes/generated/paf_synth_schema.hpp tests/golden/ + * cp ../playground/src/modes/generated/paf_synth_schema.ts tests/golden/ + */ + +import { readFileSync, existsSync } from "node:fs"; +import { join, dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { main as runGenerate } from "../generate.ts"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(__dirname, "..", ".."); +const GOLDEN_DIR = join(__dirname, "golden"); + +interface Case { + name: string; + generatedPath: string; + goldenPath: string; +} + +const CASES: Case[] = [ + { + name: "paf_synth_schema.hpp", + generatedPath: join(REPO_ROOT, "nisps", "modes", "generated", "paf_synth_schema.hpp"), + goldenPath: join(GOLDEN_DIR, "paf_synth_schema.hpp"), + }, + { + name: "paf_synth_schema.ts", + generatedPath: join(REPO_ROOT, "playground", "src", "modes", "generated", "paf_synth_schema.ts"), + goldenPath: join(GOLDEN_DIR, "paf_synth_schema.ts"), + }, +]; + +function diff(name: string, expected: string, actual: string): string { + const e = expected.split("\n"); + const a = actual.split("\n"); + const out: string[] = []; + out.push(`mismatch in ${name}:`); + const maxLen = Math.max(e.length, a.length); + let mismatches = 0; + for (let i = 0; i < maxLen; i++) { + if (e[i] !== a[i]) { + mismatches++; + if (mismatches <= 10) { + out.push(` L${i + 1}:`); + out.push(` expected: ${JSON.stringify(e[i] ?? "")}`); + out.push(` actual : ${JSON.stringify(a[i] ?? "")}`); + } + } + } + if (mismatches > 10) { + out.push(` ... and ${mismatches - 10} more line mismatches`); + } + return out.join("\n"); +} + +function run(): number { + console.log("running codegen…"); + const code = runGenerate(); + if (code !== 0) { + console.error(`codegen exited ${code}; aborting golden test`); + return code; + } + + let failed = 0; + for (const c of CASES) { + if (!existsSync(c.goldenPath)) { + console.error(`MISSING golden file: ${c.goldenPath}`); + failed++; + continue; + } + if (!existsSync(c.generatedPath)) { + console.error(`MISSING generated file: ${c.generatedPath}`); + failed++; + continue; + } + const expected = readFileSync(c.goldenPath, "utf8"); + const actual = readFileSync(c.generatedPath, "utf8"); + if (expected !== actual) { + console.error(diff(c.name, expected, actual)); + failed++; + } else { + console.log(` ok ${c.name}`); + } + } + + // Idempotency check: re-run codegen, files must still match goldens. + console.log("re-running codegen for idempotency check…"); + const code2 = runGenerate(); + if (code2 !== 0) { + console.error(`second codegen run exited ${code2}; aborting`); + return code2; + } + for (const c of CASES) { + if (!existsSync(c.goldenPath) || !existsSync(c.generatedPath)) continue; + const expected = readFileSync(c.goldenPath, "utf8"); + const actual = readFileSync(c.generatedPath, "utf8"); + if (expected !== actual) { + console.error(`idempotency: ${c.name} drifted on second run`); + failed++; + } + } + + if (failed > 0) { + console.error(`\n${failed} golden test(s) failed.`); + return 1; + } + console.log("\nall golden tests passed."); + return 0; +} + +process.exit(run()); diff --git a/codegen/tsconfig.json b/codegen/tsconfig.json new file mode 100644 index 0000000..2e9bf8f --- /dev/null +++ b/codegen/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "strict": true, + "noImplicitAny": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "types": ["bun-types"] + }, + "include": ["**/*.ts"] +}