Add codegen tool: schemas -> C++ headers + TS modules
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 <mode_id>_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 <mode_id>_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.
This commit is contained in:
parent
1bb0ec5eed
commit
129e28b207
10 changed files with 1643 additions and 0 deletions
2
codegen/.gitignore
vendored
Normal file
2
codegen/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
node_modules/
|
||||
*.tsbuildinfo
|
||||
67
codegen/README.md
Normal file
67
codegen/README.md
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# MEMLNaut Mode-Schema Codegen
|
||||
|
||||
Bun + TypeScript tool that turns `schemas/modes/*.json` into:
|
||||
|
||||
- C++ headers under `nisps/modes/generated/<mode_id>_schema.hpp` (`constexpr` data, no runtime cost).
|
||||
- TypeScript modules under `playground/src/modes/generated/<mode_id>_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 `<mode_id>.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`).
|
||||
596
codegen/generate.ts
Normal file
596
codegen/generate.ts
Normal file
|
|
@ -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/<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 };
|
||||
19
codegen/package.json
Normal file
19
codegen/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
53
codegen/templates/cpp_schema.hpp.template
Normal file
53
codegen/templates/cpp_schema.hpp.template
Normal file
|
|
@ -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<std::string_view, {{N_INPUTS}}> k{{ModeId}}InputChannels = {{
|
||||
"{{input_channel_0}}",
|
||||
/* ... */
|
||||
}};
|
||||
|
||||
inline constexpr std::array<std::size_t, {{N_HIDDEN}}> 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<Param, k{{ModeId}}ParamCount> 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<std::string_view, k{{ModeId}}VoiceSpaceCount> 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
|
||||
44
codegen/templates/ts_schema.ts.template
Normal file
44
codegen/templates/ts_schema.ts.template
Normal file
|
|
@ -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}},
|
||||
},
|
||||
};
|
||||
353
codegen/tests/golden/paf_synth_schema.hpp
Normal file
353
codegen/tests/golden/paf_synth_schema.hpp
Normal file
|
|
@ -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<std::string_view, 4> kPafSynthInputChannels = {{
|
||||
"joy_x",
|
||||
"joy_y",
|
||||
"joy_z",
|
||||
"joy_w",
|
||||
}};
|
||||
|
||||
inline constexpr std::array<std::size_t, 3> 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<Param, kPafSynthParamCount> 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<std::string_view, kPafSynthVoiceSpaceCount> 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
|
||||
374
codegen/tests/golden/paf_synth_schema.ts
Normal file
374
codegen/tests/golden/paf_synth_schema.ts
Normal file
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
119
codegen/tests/golden_test.ts
Normal file
119
codegen/tests/golden_test.ts
Normal file
|
|
@ -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] ?? "<EOF>")}`);
|
||||
out.push(` actual : ${JSON.stringify(a[i] ?? "<EOF>")}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
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());
|
||||
16
codegen/tsconfig.json
Normal file
16
codegen/tsconfig.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
Loading…
Reference in a new issue