memlnaut-nisps/codegen/generate.ts

597 lines
19 KiB
TypeScript
Raw Normal View History

#!/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/<mode_id>_schema.hpp
* nisps/modes/generated/schema_types.hpp
* playground/src/modes/generated/<mode_id>_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<T>(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 <array>",
"#include <cstddef>",
"#include <string_view>",
"",
"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<std::string_view, ${schema.ml.input_channels.length}> ${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<std::size_t, ${schema.ml.hidden_layers.length}> ${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<Param, ${constName}ParamCount> ${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<std::string_view, ${constName}VoiceSpaceCount> ${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<T,0> is fine
lines.push(`inline constexpr std::array<std::string_view, 0> ${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<AnySchemaObject>(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<ModeSchema>(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<unknown>(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 || "<root>"} ${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 };