2026-04-29 14:29:08 +02:00
|
|
|
#!/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
|
2026-07-18 12:28:16 +02:00
|
|
|
* manifold/src/modes/generated/<mode_id>_schema.ts
|
|
|
|
|
* manifold/src/modes/generated/types.ts
|
|
|
|
|
* manifold/src/modes/generated/index.ts
|
2026-07-13 23:27:56 +02:00
|
|
|
*
|
2026-07-18 12:28:16 +02:00
|
|
|
* (The TS target moved playground → manifold at P5 of
|
|
|
|
|
* docs/specs/plans/one-core-engine-refactor.md.)
|
2026-04-29 14:29:08 +02:00
|
|
|
*
|
refactor(codegen): codegen owns mode identity, per-mode schemas and net dims
Phase 3 (S1, S5, S6, S25, L11, L37, ST11, ST12). Behaviour-preserving by
construction: the diff on the generated directories is PURELY ADDITIVE (217
insertions, 0 deletions), so no emitted constant changed value. This moves where
truth lives; it does not change what truth says.
- S5: nine mode headers each carried a mechanically identical 12-field
positional ParamSchema aggregate. codegen now emits one
`inline constexpr ParamSchema k<Mode>Schema` per mode, and the struct itself
moved into generated/schema_types.hpp. Each param_schema() is a one-line
return.
- S6 + S25: every mode hand-typed its net shape a second time as MLP template
args, duplicating the schema's own dims. codegen emits a `<Mode>MLP` alias
built from the already-emitted constants (not re-literalled), and all nine
modes use it. NMaxExamples still defaults from kDefaultMaxExamples (Phase 2).
- S1: model.ts hand-imported all nine schemas by name and hand-paired each with
its overlay — so the SET of modes was hand-maintained and could silently drift
from codegen. codegen now emits ALL_MODE_SCHEMAS; SCHEMA_MODE_OVERLAYS is
purely display truth (label/glyph/css/order), which stays hand-curated.
- L37: deleted the hand-written modeEngineId switch, which duplicated
schema.engine_id and silently defaulted unknown modes to 'thru'. Routes on
MFMode.engineId with exactly one documented exception: sound_analysis_midi
declares engine_id 'thru' because its ModeBase audio slot really is
NoOpEngine, while it separately drives the real AnalysisEngine.
- L11: ExternalSynthMIDIMode's shape was literal in two places; now named once
in an ext_synth_defaults namespace with an ExtSynthMIDIMLP alias. Full folding
into the JSON pipeline is NOT done and the reason is recorded in-file: it is a
template family over an externally-supplied Device and variable NOut, with no
single (device, NOut) schema to author.
- ST12: extracted codegen/lib.ts for the helpers both generators duplicated, and
corrected the comment that claimed they had to be separate. Proof the
extraction was behaviour-free: regenerating the MIDI-device outputs produces a
byte-identical tree.
- ST11: deleted codegen/templates/ — dead "reference" files no generator reads,
already drifted from the real emitters.
Gates: run-all-tests.sh ALL GREEN; codegen idempotent (re-running both
generators yields no further diff), which is what CI's dirty-diff gate checks.
2026-07-21 14:02:23 +02:00
|
|
|
* Per-mode C++ headers also emit (simplification 2026-07, S1/S5/S6/S25):
|
|
|
|
|
* - `k<Mode>Schema` — the `nisps::ParamSchema` aggregate; each mode's
|
|
|
|
|
* `param_schema()` becomes a one-line `return generated::k<Mode>Schema;`
|
|
|
|
|
* instead of hand-assembling the same 12-field struct.
|
|
|
|
|
* - `<Mode>MLP` — the mode's `nisps::ml::MLP<...>` net-shape alias,
|
|
|
|
|
* built from the constants above instead of a second hand-typed copy of
|
|
|
|
|
* the dims in nisps/modes/*.hpp.
|
|
|
|
|
* The TS `index.ts` also emits `ALL_MODE_SCHEMAS` (every mode schema, in
|
|
|
|
|
* generation order) so manifold's mode catalogue no longer hand-imports each
|
|
|
|
|
* schema by name — see manifold/src/console/model.ts's `SCHEMA_MODES` overlay.
|
|
|
|
|
*
|
2026-04-29 14:29:08 +02:00
|
|
|
* Idempotent: regenerating the same schemas yields byte-identical output.
|
|
|
|
|
* Exits non-zero on validation failure.
|
|
|
|
|
*/
|
|
|
|
|
|
refactor(codegen): codegen owns mode identity, per-mode schemas and net dims
Phase 3 (S1, S5, S6, S25, L11, L37, ST11, ST12). Behaviour-preserving by
construction: the diff on the generated directories is PURELY ADDITIVE (217
insertions, 0 deletions), so no emitted constant changed value. This moves where
truth lives; it does not change what truth says.
- S5: nine mode headers each carried a mechanically identical 12-field
positional ParamSchema aggregate. codegen now emits one
`inline constexpr ParamSchema k<Mode>Schema` per mode, and the struct itself
moved into generated/schema_types.hpp. Each param_schema() is a one-line
return.
- S6 + S25: every mode hand-typed its net shape a second time as MLP template
args, duplicating the schema's own dims. codegen emits a `<Mode>MLP` alias
built from the already-emitted constants (not re-literalled), and all nine
modes use it. NMaxExamples still defaults from kDefaultMaxExamples (Phase 2).
- S1: model.ts hand-imported all nine schemas by name and hand-paired each with
its overlay — so the SET of modes was hand-maintained and could silently drift
from codegen. codegen now emits ALL_MODE_SCHEMAS; SCHEMA_MODE_OVERLAYS is
purely display truth (label/glyph/css/order), which stays hand-curated.
- L37: deleted the hand-written modeEngineId switch, which duplicated
schema.engine_id and silently defaulted unknown modes to 'thru'. Routes on
MFMode.engineId with exactly one documented exception: sound_analysis_midi
declares engine_id 'thru' because its ModeBase audio slot really is
NoOpEngine, while it separately drives the real AnalysisEngine.
- L11: ExternalSynthMIDIMode's shape was literal in two places; now named once
in an ext_synth_defaults namespace with an ExtSynthMIDIMLP alias. Full folding
into the JSON pipeline is NOT done and the reason is recorded in-file: it is a
template family over an externally-supplied Device and variable NOut, with no
single (device, NOut) schema to author.
- ST12: extracted codegen/lib.ts for the helpers both generators duplicated, and
corrected the comment that claimed they had to be separate. Proof the
extraction was behaviour-free: regenerating the MIDI-device outputs produces a
byte-identical tree.
- ST11: deleted codegen/templates/ — dead "reference" files no generator reads,
already drifted from the real emitters.
Gates: run-all-tests.sh ALL GREEN; codegen idempotent (re-running both
generators yields no further diff), which is what CI's dirty-diff gate checks.
2026-07-21 14:02:23 +02:00
|
|
|
import { writeFileSync, readdirSync, existsSync } from "node:fs";
|
2026-04-29 14:29:08 +02:00
|
|
|
import { join, dirname, resolve, basename } from "node:path";
|
|
|
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
|
import Ajv2020, { type AnySchemaObject } from "ajv/dist/2020.js";
|
refactor(codegen): codegen owns mode identity, per-mode schemas and net dims
Phase 3 (S1, S5, S6, S25, L11, L37, ST11, ST12). Behaviour-preserving by
construction: the diff on the generated directories is PURELY ADDITIVE (217
insertions, 0 deletions), so no emitted constant changed value. This moves where
truth lives; it does not change what truth says.
- S5: nine mode headers each carried a mechanically identical 12-field
positional ParamSchema aggregate. codegen now emits one
`inline constexpr ParamSchema k<Mode>Schema` per mode, and the struct itself
moved into generated/schema_types.hpp. Each param_schema() is a one-line
return.
- S6 + S25: every mode hand-typed its net shape a second time as MLP template
args, duplicating the schema's own dims. codegen emits a `<Mode>MLP` alias
built from the already-emitted constants (not re-literalled), and all nine
modes use it. NMaxExamples still defaults from kDefaultMaxExamples (Phase 2).
- S1: model.ts hand-imported all nine schemas by name and hand-paired each with
its overlay — so the SET of modes was hand-maintained and could silently drift
from codegen. codegen now emits ALL_MODE_SCHEMAS; SCHEMA_MODE_OVERLAYS is
purely display truth (label/glyph/css/order), which stays hand-curated.
- L37: deleted the hand-written modeEngineId switch, which duplicated
schema.engine_id and silently defaulted unknown modes to 'thru'. Routes on
MFMode.engineId with exactly one documented exception: sound_analysis_midi
declares engine_id 'thru' because its ModeBase audio slot really is
NoOpEngine, while it separately drives the real AnalysisEngine.
- L11: ExternalSynthMIDIMode's shape was literal in two places; now named once
in an ext_synth_defaults namespace with an ExtSynthMIDIMLP alias. Full folding
into the JSON pipeline is NOT done and the reason is recorded in-file: it is a
template family over an externally-supplied Device and variable NOut, with no
single (device, NOut) schema to author.
- ST12: extracted codegen/lib.ts for the helpers both generators duplicated, and
corrected the comment that claimed they had to be separate. Proof the
extraction was behaviour-free: regenerating the MIDI-device outputs produces a
byte-identical tree.
- ST11: deleted codegen/templates/ — dead "reference" files no generator reads,
already drifted from the real emitters.
Gates: run-all-tests.sh ALL GREEN; codegen idempotent (re-running both
generators yields no further diff), which is what CI's dirty-diff gate checks.
2026-07-21 14:02:23 +02:00
|
|
|
import { ensureDir, readJSON, toPascalCase, cppStringLit, tsStringLit } from "./lib.ts";
|
2026-04-29 14:29:08 +02:00
|
|
|
|
|
|
|
|
// ----- 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");
|
2026-07-18 12:28:16 +02:00
|
|
|
const TS_OUT_DIR = join(REPO_ROOT, "manifold", "src", "modes", "generated");
|
2026-04-29 14:29:08 +02:00
|
|
|
|
|
|
|
|
// ----- Helpers --------------------------------------------------------------
|
refactor(codegen): codegen owns mode identity, per-mode schemas and net dims
Phase 3 (S1, S5, S6, S25, L11, L37, ST11, ST12). Behaviour-preserving by
construction: the diff on the generated directories is PURELY ADDITIVE (217
insertions, 0 deletions), so no emitted constant changed value. This moves where
truth lives; it does not change what truth says.
- S5: nine mode headers each carried a mechanically identical 12-field
positional ParamSchema aggregate. codegen now emits one
`inline constexpr ParamSchema k<Mode>Schema` per mode, and the struct itself
moved into generated/schema_types.hpp. Each param_schema() is a one-line
return.
- S6 + S25: every mode hand-typed its net shape a second time as MLP template
args, duplicating the schema's own dims. codegen emits a `<Mode>MLP` alias
built from the already-emitted constants (not re-literalled), and all nine
modes use it. NMaxExamples still defaults from kDefaultMaxExamples (Phase 2).
- S1: model.ts hand-imported all nine schemas by name and hand-paired each with
its overlay — so the SET of modes was hand-maintained and could silently drift
from codegen. codegen now emits ALL_MODE_SCHEMAS; SCHEMA_MODE_OVERLAYS is
purely display truth (label/glyph/css/order), which stays hand-curated.
- L37: deleted the hand-written modeEngineId switch, which duplicated
schema.engine_id and silently defaulted unknown modes to 'thru'. Routes on
MFMode.engineId with exactly one documented exception: sound_analysis_midi
declares engine_id 'thru' because its ModeBase audio slot really is
NoOpEngine, while it separately drives the real AnalysisEngine.
- L11: ExternalSynthMIDIMode's shape was literal in two places; now named once
in an ext_synth_defaults namespace with an ExtSynthMIDIMLP alias. Full folding
into the JSON pipeline is NOT done and the reason is recorded in-file: it is a
template family over an externally-supplied Device and variable NOut, with no
single (device, NOut) schema to author.
- ST12: extracted codegen/lib.ts for the helpers both generators duplicated, and
corrected the comment that claimed they had to be separate. Proof the
extraction was behaviour-free: regenerating the MIDI-device outputs produces a
byte-identical tree.
- ST11: deleted codegen/templates/ — dead "reference" files no generator reads,
already drifted from the real emitters.
Gates: run-all-tests.sh ALL GREEN; codegen idempotent (re-running both
generators yields no further diff), which is what CI's dirty-diff gate checks.
2026-07-21 14:02:23 +02:00
|
|
|
// ensureDir/readJSON/toPascalCase/cppStringLit/tsStringLit now live in
|
|
|
|
|
// ./lib.ts (ST12, shared with generate-midi-devices.ts).
|
2026-04-29 14:29:08 +02:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Convert mode_id to UPPER_SNAKE for #define guards.
|
|
|
|
|
*/
|
|
|
|
|
function toUpperSnake(snake: string): string {
|
|
|
|
|
return snake.toUpperCase();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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 {
|
2026-04-29 14:43:42 +02:00
|
|
|
// Curve enum lives in nisps/core/math.hpp (namespace `nisps`), lowercase per architecture spec.
|
|
|
|
|
// Re-exported as `nisps::modes::generated::Curve` via `using Curve = ::nisps::Curve;`.
|
2026-04-29 14:29:08 +02:00
|
|
|
switch (c) {
|
2026-04-29 14:43:42 +02:00
|
|
|
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";
|
2026-04-29 14:29:08 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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.",
|
|
|
|
|
"//",
|
2026-04-29 14:43:42 +02:00
|
|
|
"// `Curve` is the authoritative enum from nisps/core/math.hpp; we re-export it",
|
|
|
|
|
"// into this namespace so generated headers can refer to plain `Curve::linear`.",
|
refactor(codegen): codegen owns mode identity, per-mode schemas and net dims
Phase 3 (S1, S5, S6, S25, L11, L37, ST11, ST12). Behaviour-preserving by
construction: the diff on the generated directories is PURELY ADDITIVE (217
insertions, 0 deletions), so no emitted constant changed value. This moves where
truth lives; it does not change what truth says.
- S5: nine mode headers each carried a mechanically identical 12-field
positional ParamSchema aggregate. codegen now emits one
`inline constexpr ParamSchema k<Mode>Schema` per mode, and the struct itself
moved into generated/schema_types.hpp. Each param_schema() is a one-line
return.
- S6 + S25: every mode hand-typed its net shape a second time as MLP template
args, duplicating the schema's own dims. codegen emits a `<Mode>MLP` alias
built from the already-emitted constants (not re-literalled), and all nine
modes use it. NMaxExamples still defaults from kDefaultMaxExamples (Phase 2).
- S1: model.ts hand-imported all nine schemas by name and hand-paired each with
its overlay — so the SET of modes was hand-maintained and could silently drift
from codegen. codegen now emits ALL_MODE_SCHEMAS; SCHEMA_MODE_OVERLAYS is
purely display truth (label/glyph/css/order), which stays hand-curated.
- L37: deleted the hand-written modeEngineId switch, which duplicated
schema.engine_id and silently defaulted unknown modes to 'thru'. Routes on
MFMode.engineId with exactly one documented exception: sound_analysis_midi
declares engine_id 'thru' because its ModeBase audio slot really is
NoOpEngine, while it separately drives the real AnalysisEngine.
- L11: ExternalSynthMIDIMode's shape was literal in two places; now named once
in an ext_synth_defaults namespace with an ExtSynthMIDIMLP alias. Full folding
into the JSON pipeline is NOT done and the reason is recorded in-file: it is a
template family over an externally-supplied Device and variable NOut, with no
single (device, NOut) schema to author.
- ST12: extracted codegen/lib.ts for the helpers both generators duplicated, and
corrected the comment that claimed they had to be separate. Proof the
extraction was behaviour-free: regenerating the MIDI-device outputs produces a
byte-identical tree.
- ST11: deleted codegen/templates/ — dead "reference" files no generator reads,
already drifted from the real emitters.
Gates: run-all-tests.sh ALL GREEN; codegen idempotent (re-running both
generators yields no further diff), which is what CI's dirty-diff gate checks.
2026-07-21 14:02:23 +02:00
|
|
|
"//",
|
|
|
|
|
"// `ParamSchema` lives in the top-level `nisps` namespace, not",
|
|
|
|
|
"// `nisps::modes::generated`: nisps/core/concepts.hpp forward-declares",
|
|
|
|
|
"// `nisps::ParamSchema` and requires `T::param_schema()` to return",
|
|
|
|
|
"// `const nisps::ParamSchema&`, so the definition has to match that",
|
|
|
|
|
"// forward declaration exactly (S5, one-core simplification 2026-07).",
|
2026-04-29 14:29:08 +02:00
|
|
|
"#ifndef NISPS_GENERATED_SCHEMA_TYPES_HPP",
|
|
|
|
|
"#define NISPS_GENERATED_SCHEMA_TYPES_HPP",
|
|
|
|
|
"",
|
|
|
|
|
"#include <array>",
|
|
|
|
|
"#include <cstddef>",
|
refactor(codegen): codegen owns mode identity, per-mode schemas and net dims
Phase 3 (S1, S5, S6, S25, L11, L37, ST11, ST12). Behaviour-preserving by
construction: the diff on the generated directories is PURELY ADDITIVE (217
insertions, 0 deletions), so no emitted constant changed value. This moves where
truth lives; it does not change what truth says.
- S5: nine mode headers each carried a mechanically identical 12-field
positional ParamSchema aggregate. codegen now emits one
`inline constexpr ParamSchema k<Mode>Schema` per mode, and the struct itself
moved into generated/schema_types.hpp. Each param_schema() is a one-line
return.
- S6 + S25: every mode hand-typed its net shape a second time as MLP template
args, duplicating the schema's own dims. codegen emits a `<Mode>MLP` alias
built from the already-emitted constants (not re-literalled), and all nine
modes use it. NMaxExamples still defaults from kDefaultMaxExamples (Phase 2).
- S1: model.ts hand-imported all nine schemas by name and hand-paired each with
its overlay — so the SET of modes was hand-maintained and could silently drift
from codegen. codegen now emits ALL_MODE_SCHEMAS; SCHEMA_MODE_OVERLAYS is
purely display truth (label/glyph/css/order), which stays hand-curated.
- L37: deleted the hand-written modeEngineId switch, which duplicated
schema.engine_id and silently defaulted unknown modes to 'thru'. Routes on
MFMode.engineId with exactly one documented exception: sound_analysis_midi
declares engine_id 'thru' because its ModeBase audio slot really is
NoOpEngine, while it separately drives the real AnalysisEngine.
- L11: ExternalSynthMIDIMode's shape was literal in two places; now named once
in an ext_synth_defaults namespace with an ExtSynthMIDIMLP alias. Full folding
into the JSON pipeline is NOT done and the reason is recorded in-file: it is a
template family over an externally-supplied Device and variable NOut, with no
single (device, NOut) schema to author.
- ST12: extracted codegen/lib.ts for the helpers both generators duplicated, and
corrected the comment that claimed they had to be separate. Proof the
extraction was behaviour-free: regenerating the MIDI-device outputs produces a
byte-identical tree.
- ST11: deleted codegen/templates/ — dead "reference" files no generator reads,
already drifted from the real emitters.
Gates: run-all-tests.sh ALL GREEN; codegen idempotent (re-running both
generators yields no further diff), which is what CI's dirty-diff gate checks.
2026-07-21 14:02:23 +02:00
|
|
|
"#include <span>",
|
2026-04-29 14:29:08 +02:00
|
|
|
"#include <string_view>",
|
|
|
|
|
"",
|
2026-04-29 14:43:42 +02:00
|
|
|
"#include \"../../core/math.hpp\"",
|
|
|
|
|
"",
|
2026-04-29 14:29:08 +02:00
|
|
|
"namespace nisps::modes::generated {",
|
|
|
|
|
"",
|
2026-04-29 14:43:42 +02:00
|
|
|
"using Curve = ::nisps::Curve;",
|
2026-04-29 14:29:08 +02:00
|
|
|
"",
|
|
|
|
|
"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",
|
|
|
|
|
"",
|
refactor(codegen): codegen owns mode identity, per-mode schemas and net dims
Phase 3 (S1, S5, S6, S25, L11, L37, ST11, ST12). Behaviour-preserving by
construction: the diff on the generated directories is PURELY ADDITIVE (217
insertions, 0 deletions), so no emitted constant changed value. This moves where
truth lives; it does not change what truth says.
- S5: nine mode headers each carried a mechanically identical 12-field
positional ParamSchema aggregate. codegen now emits one
`inline constexpr ParamSchema k<Mode>Schema` per mode, and the struct itself
moved into generated/schema_types.hpp. Each param_schema() is a one-line
return.
- S6 + S25: every mode hand-typed its net shape a second time as MLP template
args, duplicating the schema's own dims. codegen emits a `<Mode>MLP` alias
built from the already-emitted constants (not re-literalled), and all nine
modes use it. NMaxExamples still defaults from kDefaultMaxExamples (Phase 2).
- S1: model.ts hand-imported all nine schemas by name and hand-paired each with
its overlay — so the SET of modes was hand-maintained and could silently drift
from codegen. codegen now emits ALL_MODE_SCHEMAS; SCHEMA_MODE_OVERLAYS is
purely display truth (label/glyph/css/order), which stays hand-curated.
- L37: deleted the hand-written modeEngineId switch, which duplicated
schema.engine_id and silently defaulted unknown modes to 'thru'. Routes on
MFMode.engineId with exactly one documented exception: sound_analysis_midi
declares engine_id 'thru' because its ModeBase audio slot really is
NoOpEngine, while it separately drives the real AnalysisEngine.
- L11: ExternalSynthMIDIMode's shape was literal in two places; now named once
in an ext_synth_defaults namespace with an ExtSynthMIDIMLP alias. Full folding
into the JSON pipeline is NOT done and the reason is recorded in-file: it is a
template family over an externally-supplied Device and variable NOut, with no
single (device, NOut) schema to author.
- ST12: extracted codegen/lib.ts for the helpers both generators duplicated, and
corrected the comment that claimed they had to be separate. Proof the
extraction was behaviour-free: regenerating the MIDI-device outputs produces a
byte-identical tree.
- ST11: deleted codegen/templates/ — dead "reference" files no generator reads,
already drifted from the real emitters.
Gates: run-all-tests.sh ALL GREEN; codegen idempotent (re-running both
generators yields no further diff), which is what CI's dirty-diff gate checks.
2026-07-21 14:02:23 +02:00
|
|
|
"namespace nisps {",
|
|
|
|
|
"",
|
|
|
|
|
"// View-style aggregate satisfying the `nisps::Mode` concept's",
|
|
|
|
|
"// `param_schema()` requirement. All members are spans/views into",
|
|
|
|
|
"// compile-time generated arrays; codegen emits one",
|
|
|
|
|
"// `inline constexpr ParamSchema k<Mode>Schema` per mode (see the",
|
|
|
|
|
"// per-mode <mode_id>_schema.hpp in this directory).",
|
|
|
|
|
"struct ParamSchema {",
|
|
|
|
|
" std::string_view mode_id;",
|
|
|
|
|
" std::string_view engine_id;",
|
|
|
|
|
" std::span<const std::string_view> input_channels;",
|
|
|
|
|
" std::size_t input_size;",
|
|
|
|
|
" std::span<const std::size_t> hidden_layers;",
|
|
|
|
|
" std::size_t output_size;",
|
|
|
|
|
" float default_spread;",
|
|
|
|
|
" float default_learning_rate;",
|
|
|
|
|
" std::size_t default_max_iterations;",
|
|
|
|
|
" std::span<const ::nisps::modes::generated::Param> params;",
|
|
|
|
|
" std::span<const std::string_view> voice_spaces;",
|
|
|
|
|
" ::nisps::modes::generated::UIConfig ui;",
|
|
|
|
|
"};",
|
|
|
|
|
"",
|
|
|
|
|
"} // namespace nisps",
|
|
|
|
|
"",
|
2026-04-29 14:29:08 +02:00
|
|
|
"#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\"");
|
refactor(codegen): codegen owns mode identity, per-mode schemas and net dims
Phase 3 (S1, S5, S6, S25, L11, L37, ST11, ST12). Behaviour-preserving by
construction: the diff on the generated directories is PURELY ADDITIVE (217
insertions, 0 deletions), so no emitted constant changed value. This moves where
truth lives; it does not change what truth says.
- S5: nine mode headers each carried a mechanically identical 12-field
positional ParamSchema aggregate. codegen now emits one
`inline constexpr ParamSchema k<Mode>Schema` per mode, and the struct itself
moved into generated/schema_types.hpp. Each param_schema() is a one-line
return.
- S6 + S25: every mode hand-typed its net shape a second time as MLP template
args, duplicating the schema's own dims. codegen emits a `<Mode>MLP` alias
built from the already-emitted constants (not re-literalled), and all nine
modes use it. NMaxExamples still defaults from kDefaultMaxExamples (Phase 2).
- S1: model.ts hand-imported all nine schemas by name and hand-paired each with
its overlay — so the SET of modes was hand-maintained and could silently drift
from codegen. codegen now emits ALL_MODE_SCHEMAS; SCHEMA_MODE_OVERLAYS is
purely display truth (label/glyph/css/order), which stays hand-curated.
- L37: deleted the hand-written modeEngineId switch, which duplicated
schema.engine_id and silently defaulted unknown modes to 'thru'. Routes on
MFMode.engineId with exactly one documented exception: sound_analysis_midi
declares engine_id 'thru' because its ModeBase audio slot really is
NoOpEngine, while it separately drives the real AnalysisEngine.
- L11: ExternalSynthMIDIMode's shape was literal in two places; now named once
in an ext_synth_defaults namespace with an ExtSynthMIDIMLP alias. Full folding
into the JSON pipeline is NOT done and the reason is recorded in-file: it is a
template family over an externally-supplied Device and variable NOut, with no
single (device, NOut) schema to author.
- ST12: extracted codegen/lib.ts for the helpers both generators duplicated, and
corrected the comment that claimed they had to be separate. Proof the
extraction was behaviour-free: regenerating the MIDI-device outputs produces a
byte-identical tree.
- ST11: deleted codegen/templates/ — dead "reference" files no generator reads,
already drifted from the real emitters.
Gates: run-all-tests.sh ALL GREEN; codegen idempotent (re-running both
generators yields no further diff), which is what CI's dirty-diff gate checks.
2026-07-21 14:02:23 +02:00
|
|
|
lines.push("#include \"../../ml/mlp.hpp\"");
|
2026-04-29 14:29:08 +02:00
|
|
|
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("");
|
|
|
|
|
|
refactor(codegen): codegen owns mode identity, per-mode schemas and net dims
Phase 3 (S1, S5, S6, S25, L11, L37, ST11, ST12). Behaviour-preserving by
construction: the diff on the generated directories is PURELY ADDITIVE (217
insertions, 0 deletions), so no emitted constant changed value. This moves where
truth lives; it does not change what truth says.
- S5: nine mode headers each carried a mechanically identical 12-field
positional ParamSchema aggregate. codegen now emits one
`inline constexpr ParamSchema k<Mode>Schema` per mode, and the struct itself
moved into generated/schema_types.hpp. Each param_schema() is a one-line
return.
- S6 + S25: every mode hand-typed its net shape a second time as MLP template
args, duplicating the schema's own dims. codegen emits a `<Mode>MLP` alias
built from the already-emitted constants (not re-literalled), and all nine
modes use it. NMaxExamples still defaults from kDefaultMaxExamples (Phase 2).
- S1: model.ts hand-imported all nine schemas by name and hand-paired each with
its overlay — so the SET of modes was hand-maintained and could silently drift
from codegen. codegen now emits ALL_MODE_SCHEMAS; SCHEMA_MODE_OVERLAYS is
purely display truth (label/glyph/css/order), which stays hand-curated.
- L37: deleted the hand-written modeEngineId switch, which duplicated
schema.engine_id and silently defaulted unknown modes to 'thru'. Routes on
MFMode.engineId with exactly one documented exception: sound_analysis_midi
declares engine_id 'thru' because its ModeBase audio slot really is
NoOpEngine, while it separately drives the real AnalysisEngine.
- L11: ExternalSynthMIDIMode's shape was literal in two places; now named once
in an ext_synth_defaults namespace with an ExtSynthMIDIMLP alias. Full folding
into the JSON pipeline is NOT done and the reason is recorded in-file: it is a
template family over an externally-supplied Device and variable NOut, with no
single (device, NOut) schema to author.
- ST12: extracted codegen/lib.ts for the helpers both generators duplicated, and
corrected the comment that claimed they had to be separate. Proof the
extraction was behaviour-free: regenerating the MIDI-device outputs produces a
byte-identical tree.
- ST11: deleted codegen/templates/ — dead "reference" files no generator reads,
already drifted from the real emitters.
Gates: run-all-tests.sh ALL GREEN; codegen idempotent (re-running both
generators yields no further diff), which is what CI's dirty-diff gate checks.
2026-07-21 14:02:23 +02:00
|
|
|
// Net-shape alias (S6/S25): the mode's MLP<> template args, built from the
|
|
|
|
|
// constants above rather than hand-typed a second time in nisps/modes/*.hpp.
|
|
|
|
|
const modeMlpName = `${toPascalCase(schema.mode_id)}MLP`;
|
|
|
|
|
lines.push(
|
|
|
|
|
`using ${modeMlpName} = ::nisps::ml::MLP<` +
|
|
|
|
|
`${constName}MLConfig.input_size, ` +
|
|
|
|
|
`${constName}HiddenLayers[0], ${constName}HiddenLayers[1], ${constName}HiddenLayers[2], ` +
|
|
|
|
|
`${constName}MLConfig.output_size>;`
|
|
|
|
|
);
|
|
|
|
|
lines.push("");
|
|
|
|
|
|
|
|
|
|
// ParamSchema aggregate (S5): the one-line `param_schema()` body every mode
|
|
|
|
|
// used to hand-assemble as a private `kSchema` positional-init block.
|
|
|
|
|
lines.push(`inline constexpr ::nisps::ParamSchema ${constName}Schema = {`);
|
|
|
|
|
lines.push(` ${constName}ModeId,`);
|
|
|
|
|
lines.push(` ${constName}EngineId,`);
|
|
|
|
|
lines.push(` std::span<const std::string_view>(${constName}InputChannels),`);
|
|
|
|
|
lines.push(` ${constName}MLConfig.input_size,`);
|
|
|
|
|
lines.push(` std::span<const std::size_t>(${constName}HiddenLayers),`);
|
|
|
|
|
lines.push(` ${constName}MLConfig.output_size,`);
|
|
|
|
|
lines.push(` ${constName}MLConfig.default_spread,`);
|
|
|
|
|
lines.push(` ${constName}MLConfig.default_learning_rate,`);
|
|
|
|
|
lines.push(` ${constName}MLConfig.default_max_iterations,`);
|
|
|
|
|
lines.push(` std::span<const Param>(${constName}Params),`);
|
|
|
|
|
lines.push(` std::span<const std::string_view>(${constName}VoiceSpaces),`);
|
|
|
|
|
lines.push(` ${constName}UI,`);
|
|
|
|
|
lines.push("};");
|
|
|
|
|
lines.push("");
|
|
|
|
|
|
2026-04-29 14:29:08 +02:00
|
|
|
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("");
|
refactor(codegen): codegen owns mode identity, per-mode schemas and net dims
Phase 3 (S1, S5, S6, S25, L11, L37, ST11, ST12). Behaviour-preserving by
construction: the diff on the generated directories is PURELY ADDITIVE (217
insertions, 0 deletions), so no emitted constant changed value. This moves where
truth lives; it does not change what truth says.
- S5: nine mode headers each carried a mechanically identical 12-field
positional ParamSchema aggregate. codegen now emits one
`inline constexpr ParamSchema k<Mode>Schema` per mode, and the struct itself
moved into generated/schema_types.hpp. Each param_schema() is a one-line
return.
- S6 + S25: every mode hand-typed its net shape a second time as MLP template
args, duplicating the schema's own dims. codegen emits a `<Mode>MLP` alias
built from the already-emitted constants (not re-literalled), and all nine
modes use it. NMaxExamples still defaults from kDefaultMaxExamples (Phase 2).
- S1: model.ts hand-imported all nine schemas by name and hand-paired each with
its overlay — so the SET of modes was hand-maintained and could silently drift
from codegen. codegen now emits ALL_MODE_SCHEMAS; SCHEMA_MODE_OVERLAYS is
purely display truth (label/glyph/css/order), which stays hand-curated.
- L37: deleted the hand-written modeEngineId switch, which duplicated
schema.engine_id and silently defaulted unknown modes to 'thru'. Routes on
MFMode.engineId with exactly one documented exception: sound_analysis_midi
declares engine_id 'thru' because its ModeBase audio slot really is
NoOpEngine, while it separately drives the real AnalysisEngine.
- L11: ExternalSynthMIDIMode's shape was literal in two places; now named once
in an ext_synth_defaults namespace with an ExtSynthMIDIMLP alias. Full folding
into the JSON pipeline is NOT done and the reason is recorded in-file: it is a
template family over an externally-supplied Device and variable NOut, with no
single (device, NOut) schema to author.
- ST12: extracted codegen/lib.ts for the helpers both generators duplicated, and
corrected the comment that claimed they had to be separate. Proof the
extraction was behaviour-free: regenerating the MIDI-device outputs produces a
byte-identical tree.
- ST11: deleted codegen/templates/ — dead "reference" files no generator reads,
already drifted from the real emitters.
Gates: run-all-tests.sh ALL GREEN; codegen idempotent (re-running both
generators yields no further diff), which is what CI's dirty-diff gate checks.
2026-07-21 14:02:23 +02:00
|
|
|
lines.push("import type { ModeSchema } from './types';");
|
|
|
|
|
for (const id of modeIds) {
|
|
|
|
|
lines.push(`import { ${toPascalCase(id)}Schema } from './${id}_schema';`);
|
|
|
|
|
}
|
|
|
|
|
lines.push("");
|
2026-04-29 14:29:08 +02:00
|
|
|
lines.push("export * from './types';");
|
|
|
|
|
for (const id of modeIds) {
|
|
|
|
|
lines.push(`export * from './${id}_schema';`);
|
|
|
|
|
}
|
|
|
|
|
lines.push("");
|
refactor(codegen): codegen owns mode identity, per-mode schemas and net dims
Phase 3 (S1, S5, S6, S25, L11, L37, ST11, ST12). Behaviour-preserving by
construction: the diff on the generated directories is PURELY ADDITIVE (217
insertions, 0 deletions), so no emitted constant changed value. This moves where
truth lives; it does not change what truth says.
- S5: nine mode headers each carried a mechanically identical 12-field
positional ParamSchema aggregate. codegen now emits one
`inline constexpr ParamSchema k<Mode>Schema` per mode, and the struct itself
moved into generated/schema_types.hpp. Each param_schema() is a one-line
return.
- S6 + S25: every mode hand-typed its net shape a second time as MLP template
args, duplicating the schema's own dims. codegen emits a `<Mode>MLP` alias
built from the already-emitted constants (not re-literalled), and all nine
modes use it. NMaxExamples still defaults from kDefaultMaxExamples (Phase 2).
- S1: model.ts hand-imported all nine schemas by name and hand-paired each with
its overlay — so the SET of modes was hand-maintained and could silently drift
from codegen. codegen now emits ALL_MODE_SCHEMAS; SCHEMA_MODE_OVERLAYS is
purely display truth (label/glyph/css/order), which stays hand-curated.
- L37: deleted the hand-written modeEngineId switch, which duplicated
schema.engine_id and silently defaulted unknown modes to 'thru'. Routes on
MFMode.engineId with exactly one documented exception: sound_analysis_midi
declares engine_id 'thru' because its ModeBase audio slot really is
NoOpEngine, while it separately drives the real AnalysisEngine.
- L11: ExternalSynthMIDIMode's shape was literal in two places; now named once
in an ext_synth_defaults namespace with an ExtSynthMIDIMLP alias. Full folding
into the JSON pipeline is NOT done and the reason is recorded in-file: it is a
template family over an externally-supplied Device and variable NOut, with no
single (device, NOut) schema to author.
- ST12: extracted codegen/lib.ts for the helpers both generators duplicated, and
corrected the comment that claimed they had to be separate. Proof the
extraction was behaviour-free: regenerating the MIDI-device outputs produces a
byte-identical tree.
- ST11: deleted codegen/templates/ — dead "reference" files no generator reads,
already drifted from the real emitters.
Gates: run-all-tests.sh ALL GREEN; codegen idempotent (re-running both
generators yields no further diff), which is what CI's dirty-diff gate checks.
2026-07-21 14:02:23 +02:00
|
|
|
// Mechanically-derived mode-identity registry (S1): every generated mode
|
|
|
|
|
// schema, in generation (mode_id-sorted) order. NOT display order — that
|
|
|
|
|
// ordering is hand-curated overlay truth (manifold/src/console/model.ts's
|
|
|
|
|
// `SCHEMA_MODES`).
|
|
|
|
|
lines.push(
|
|
|
|
|
"/** Every generated mode schema, mode_id-sorted. Mechanically-derived mode-identity truth. */"
|
|
|
|
|
);
|
|
|
|
|
lines.push("export const ALL_MODE_SCHEMAS: readonly ModeSchema[] = [");
|
|
|
|
|
for (const id of modeIds) {
|
|
|
|
|
lines.push(` ${toPascalCase(id)}Schema,`);
|
|
|
|
|
}
|
|
|
|
|
lines.push("];");
|
|
|
|
|
lines.push("");
|
2026-04-29 14:29:08 +02:00
|
|
|
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;
|
|
|
|
|
}
|
2026-07-18 12:28:16 +02:00
|
|
|
// Firmware-fit check (one-core-engine P5): the fixed firmware MLP template
|
|
|
|
|
// is exactly 4 layers (3 hidden); the browser's runtime-shaped MLP caps
|
|
|
|
|
// every dimension at 4096 (bindings kMaxDim).
|
|
|
|
|
if (schema.ml.hidden_layers.length !== 3) {
|
|
|
|
|
console.error(
|
|
|
|
|
`error: ${f}: ml.hidden_layers must have exactly 3 entries ` +
|
|
|
|
|
`(fixed 4-layer topology); got ${schema.ml.hidden_layers.length}`
|
|
|
|
|
);
|
|
|
|
|
errorCount++;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
{
|
|
|
|
|
const dims = [schema.ml.input_size, ...schema.ml.hidden_layers, schema.ml.output_size];
|
|
|
|
|
const bad = dims.find(d => d <= 0 || d > 4096);
|
|
|
|
|
if (bad !== undefined) {
|
|
|
|
|
console.error(`error: ${f}: ml dimension ${bad} outside (0, 4096]`);
|
|
|
|
|
errorCount++;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-29 14:29:08 +02:00
|
|
|
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));
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 12:28:16 +02:00
|
|
|
// 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
|
2026-04-29 14:29:08 +02:00
|
|
|
console.log(`OK ${schemas.length} mode schema(s) processed.`);
|
|
|
|
|
console.log(` C++ -> ${CPP_OUT_DIR}`);
|
2026-07-18 12:28:16 +02:00
|
|
|
console.log(` TS -> ${TS_OUT_DIR}`);
|
2026-04-29 14:29:08 +02:00
|
|
|
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 };
|