diff --git a/demos/realtime_motion_graph_web/web/tests/unit/configContractDrift.test.ts b/demos/realtime_motion_graph_web/web/tests/unit/configContractDrift.test.ts index 64e1fedf..18103cd9 100644 --- a/demos/realtime_motion_graph_web/web/tests/unit/configContractDrift.test.ts +++ b/demos/realtime_motion_graph_web/web/tests/unit/configContractDrift.test.ts @@ -29,6 +29,7 @@ import * as sdk from "@demon/client"; // and guards main() behind an argv check, so importing it here runs no I/O. import { renderConfigContractHpp, + renderConfigDefaultsJson, renderControlsContractHpp, } from "../../../../../packages/demon-client/scripts/genConfigTypes.mjs"; @@ -53,6 +54,11 @@ describe("generated C++ config/controls contracts", () => { expect(readCommitted("controlsContract.gen.hpp")).toBe(expected); }); + it("configDefaults.gen.json matches the SDK (regenerate if this fails)", () => { + const expected = renderConfigDefaultsJson(sdk); + expect(readCommitted("configDefaults.gen.json")).toBe(expected); + }); + // ── Coverage assertions (the caveat plan 06 flags: "pick one and test it") ── it("emits the COMPLETE top-level key surface (KNOWN_TOP_LEVEL_KEYS + inputs)", () => { @@ -92,6 +98,44 @@ describe("generated C++ config/controls contracts", () => { for (const v of sdk.SERIALIZED_INPUT_KINDS) expect(hpp).toContain(`= "${v}";`); }); + it("emits a typed defaults constant AND a table row for EVERY control", () => { + // The defaults:: namespace is the numeric-facts surface (plan 7.3): C++ + // hosts consume DEFAULT_CONFIG values from it instead of re-declaring + // them, so a control missing from the table would silently fall back to a + // host-local literal again. + const hpp = renderConfigContractHpp(sdk); + for (const [key, value] of Object.entries(sdk.DEFAULT_CONFIG.controls)) { + expect(hpp).toContain(`{ ${JSON.stringify(key)}, ValueKind::`); + if (typeof value === "string") { + expect(hpp).toContain(`= ${JSON.stringify(value)};`); + } + } + expect(hpp).toContain( + `inline constexpr int kValueCount = ${Object.keys(sdk.DEFAULT_CONFIG.controls).length};`, + ); + }); + + it("emits every channel_ranges row with its min/max/reverse facts", () => { + const hpp = renderConfigContractHpp(sdk); + for (const ch of Object.keys(sdk.DEFAULT_CONFIG.channel_ranges)) { + expect(hpp).toMatch( + new RegExp(`\\{ ${JSON.stringify(ch)}, [0-9.]+, [0-9.]+, (true|false) \\},`), + ); + } + expect(hpp).toContain( + `inline constexpr int kRangeCount = ${Object.keys(sdk.DEFAULT_CONFIG.channel_ranges).length};`, + ); + }); + + it("configDefaults.gen.json is exactly serializeConfig(DEFAULT_CONFIG)", () => { + // The JSON artifact doubles as a byte-exact serialization fixture in + // downstream repos (rtmg-vst pins its DemonExport capture against it), so + // guard the CONTENT as well as the committed bytes. + const artifact = renderConfigDefaultsJson(sdk); + expect(artifact.endsWith("\n")).toBe(true); + expect(JSON.parse(artifact)).toEqual(sdk.serializeConfig(sdk.DEFAULT_CONFIG)); + }); + it("projects the controls lexicon + display-name overrides verbatim", () => { const hpp = renderControlsContractHpp(sdk); for (const [, label] of Object.entries(sdk.TERMS)) { diff --git a/packages/demon-client/scripts/genConfigTypes.mjs b/packages/demon-client/scripts/genConfigTypes.mjs index ff8a84ae..d8d99c24 100644 --- a/packages/demon-client/scripts/genConfigTypes.mjs +++ b/packages/demon-client/scripts/genConfigTypes.mjs @@ -10,10 +10,18 @@ // // types/configContract.gen.hpp — config field-key/enum/version constants // (demon::config::*) the VST keys its -// DemonExport (de)serialization off of. +// DemonExport (de)serialization off of, +// plus the DEFAULT_CONFIG VALUES as typed +// constexpr facts (demon::config::defaults) +// so C++ hosts consume the defaults instead +// of re-declaring them. // types/controlsContract.gen.hpp — TERMS + CONTROL_DISPLAY_NAMES + // CONTROL_DESCRIPTIONS (demon::controls::*) // the VST APVTS param names key off of. +// types/configDefaults.gen.json — serializeConfig(DEFAULT_CONFIG), the same +// value facts as one machine-readable JSON +// artifact for JS/TS consumers (a web UI's +// hand-synced tables, test fixtures). // // Both headers are CONSTANTS-ONLY and JSON-library-agnostic, exactly like the // wire header: no structs, no nlohmann. The VST keeps its own juce::var @@ -91,6 +99,27 @@ function strConst(name, value) { return `inline constexpr const char* ${kName(name)} = ${cppStr(value)};`; } +/** A C++ `double` literal from a JS number. JSON has one number type, so every + * numeric default is emitted as double (no int/double flip when a default + * moves between integral and fractional); integral values get a `.0` so the + * literal reads as floating-point. */ +function cppNum(value) { + const s = String(value); + return Number.isInteger(value) && !/[eE.]/.test(s) ? `${s}.0` : s; +} + +/** One typed `inline constexpr kFoo = ...;` line, + * the C++ type picked from the JS scalar's runtime type. */ +function typedConst(name, value) { + if (typeof value === "boolean") { + return `inline constexpr bool ${kName(name)} = ${value};`; + } + if (typeof value === "number") { + return `inline constexpr double ${kName(name)} = ${cppNum(value)};`; + } + return strConst(name, value); +} + /** A flat namespace of string-key constants from an array of names, each * constant's VALUE being the name itself (field-key vocabulary). */ function keyNamespace(name, keys) { @@ -113,6 +142,104 @@ function valueNamespace(name, entries) { // here, so the drift guard genuinely fails if any option set changes upstream // without a regenerate. +// ── demon::config::defaults — DEFAULT_CONFIG value facts ──────────────────── + +/** The `defaults` namespace body: DEFAULT_CONFIG's VALUES as typed constexpr + * constants (bool / double / const char* by runtime type), plus two constexpr + * POD tables — the ordered controls list and the channel_ranges rows — so a + * consumer can iterate the facts as well as reference them by name. Coverage: + * engine scalars, prompts, controls, channel_ranges, and the top-level + * seed / swap_source_mode. Not emitted: web.* (the browser demo's own block) + * and enabled_loras (empty array in DEFAULT_CONFIG). */ +function renderDefaultsNamespace(cfg) { + const controlEntries = Object.entries(cfg.controls); + const controlRow = ([key, value]) => { + if (typeof value === "boolean") { + return ` { ${cppStr(key)}, ValueKind::Boolean, 0.0, ${value}, nullptr },`; + } + if (typeof value === "number") { + return ` { ${cppStr(key)}, ValueKind::Number, ${cppNum(value)}, false, nullptr },`; + } + return ` { ${cppStr(key)}, ValueKind::String, 0.0, false, ${cppStr(value)} },`; + }; + + const controls = ns( + "controls", + [ + "// DEFAULT_CONFIG.controls values, one typed constant per knob.", + ...controlEntries.map(([k, v]) => typedConst(k, v)), + "", + "// The same values as one ordered table (DEFAULT_CONFIG insertion order,", + "// which is also JSON.stringify emission order), for consumers that", + "// iterate the control set instead of naming each constant.", + "enum class ValueKind { Number, Boolean, String };", + "struct ControlValue {", + " const char* key;", + " ValueKind kind;", + " double number; // valid when kind == Number", + " bool boolean; // valid when kind == Boolean", + " const char* string; // valid when kind == String", + "};", + `inline constexpr ControlValue kValues[] = {\n${controlEntries + .map(controlRow) + .join("\n")}\n};`, + `inline constexpr int kValueCount = ${controlEntries.length};`, + ].join("\n"), + ); + + const rangeEntries = Object.entries(cfg.channel_ranges); + const channelRanges = ns( + "channel_ranges", + [ + "// DEFAULT_CONFIG.channel_ranges rows ({ min, max, reverse } per channel),", + "// in DEFAULT_CONFIG insertion order.", + "struct ChannelRange {", + " const char* channel;", + " double min;", + " double max;", + " bool reverse;", + "};", + `inline constexpr ChannelRange kRanges[] = {\n${rangeEntries + .map( + ([ch, r]) => + ` { ${cppStr(ch)}, ${cppNum(r.min)}, ${cppNum(r.max)}, ${r.reverse} },`, + ) + .join("\n")}\n};`, + `inline constexpr int kRangeCount = ${rangeEntries.length};`, + ].join("\n"), + ); + + return ns( + "defaults", + [ + "// Top-level scalar defaults.", + typedConst("seed", cfg.seed), + typedConst("swap_source_mode", cfg.swap_source_mode), + "", + "// engine.* scalar defaults (enabled_loras is [] in DEFAULT_CONFIG; an", + "// empty array has no value constant).", + ns( + "engine", + Object.entries(cfg.engine) + .filter(([, v]) => !Array.isArray(v)) + .map(([k, v]) => typedConst(k, v)) + .join("\n"), + ), + "", + ns( + "prompts", + Object.entries(cfg.prompts) + .map(([k, v]) => typedConst(k, v)) + .join("\n"), + ), + "", + controls, + "", + channelRanges, + ].join("\n"), + ); +} + // ── configContract.gen.hpp ────────────────────────────────────────────────── const CONFIG_HPP_HEADER = `\ @@ -127,11 +254,16 @@ const CONFIG_HPP_HEADER = `\ // Drift-guarded by web/tests/unit/configContractDrift.test.ts // (a stale copy fails CI). // -// Constants-ONLY and JSON-library-agnostic: this header declares NO structs and -// pulls in NO JSON dependency. It provides the config VOCABULARY as string -// constants — JSON field keys, enum option values, and the schema version — so a -// C++ client (the rtmg-vst plugin) references generated names instead of -// hand-copied literals while keeping its own juce::var (de)serialization. +// Constants-ONLY and JSON-library-agnostic: this header pulls in NO JSON +// dependency and declares no serialization machinery. It provides the config +// VOCABULARY as string constants — JSON field keys, enum option values, and the +// schema version — so a C++ client (the rtmg-vst plugin) references generated +// names instead of hand-copied literals while keeping its own juce::var +// (de)serialization. It also provides the DEFAULT_CONFIG VALUES as typed +// constexpr facts (demon::config::defaults), including two small constexpr POD +// tables for iteration, so C++ hosts consume the defaults instead of +// re-declaring them. The same value facts ship as machine-readable JSON in +// types/configDefaults.gen.json for JS/TS consumers. // // The preset file IS a web DemonExport: an RtmgConfig plus an optional top-level // \`inputs\` (SerializedInputs). Field-key coverage of NESTED objects mirrors @@ -254,6 +386,11 @@ function renderConfigContractHpp(sdk) { ), ].join("\n"), ), + "// ── DEFAULT_CONFIG values (defaults::*) ──\n" + + "// The VALUE facts of DEFAULT_CONFIG — control defaults, prompts, engine\n" + + "// scalars, channel-range min/max/reverse — as typed constexpr constants,\n" + + "// so a C++ host CONSUMES the defaults instead of re-declaring them.", + renderDefaultsNamespace(cfg), ]; const body = blocks.join("\n\n"); @@ -348,6 +485,17 @@ function renderControlsContractHpp(sdk) { ).replace(/\r\n/g, "\n"); } +// ── configDefaults.gen.json ───────────────────────────────────────────────── + +/** The DEFAULT_CONFIG value facts as one machine-readable JSON artifact: + * exactly `JSON.stringify(serializeConfig(DEFAULT_CONFIG))` + newline — the + * same bytes a frontend captures when it serializes an untouched default + * config, so downstream repos can use one file as BOTH a consumable defaults + * table (web UIs) and a byte-exact serialization fixture (drift tests). */ +function renderConfigDefaultsJson(sdk) { + return JSON.stringify(sdk.serializeConfig(sdk.DEFAULT_CONFIG)) + "\n"; +} + // ── CLI ───────────────────────────────────────────────────────────────────── function typesDir() { @@ -368,9 +516,18 @@ function main() { const controlsPath = join(out, "controlsContract.gen.hpp"); writeFileSync(controlsPath, controlsText, { encoding: "utf-8" }); console.log(`wrote ${controlsPath} (${controlsText.length} bytes)`); + + const defaultsText = renderConfigDefaultsJson(sdk); + const defaultsPath = join(out, "configDefaults.gen.json"); + writeFileSync(defaultsPath, defaultsText, { encoding: "utf-8" }); + console.log(`wrote ${defaultsPath} (${defaultsText.length} bytes)`); } -export { renderConfigContractHpp, renderControlsContractHpp }; +export { + renderConfigContractHpp, + renderControlsContractHpp, + renderConfigDefaultsJson, +}; if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { main(); diff --git a/packages/demon-client/types/configContract.gen.hpp b/packages/demon-client/types/configContract.gen.hpp index 0f99fe33..ee4a7b85 100644 --- a/packages/demon-client/types/configContract.gen.hpp +++ b/packages/demon-client/types/configContract.gen.hpp @@ -9,11 +9,16 @@ // Drift-guarded by web/tests/unit/configContractDrift.test.ts // (a stale copy fails CI). // -// Constants-ONLY and JSON-library-agnostic: this header declares NO structs and -// pulls in NO JSON dependency. It provides the config VOCABULARY as string -// constants — JSON field keys, enum option values, and the schema version — so a -// C++ client (the rtmg-vst plugin) references generated names instead of -// hand-copied literals while keeping its own juce::var (de)serialization. +// Constants-ONLY and JSON-library-agnostic: this header pulls in NO JSON +// dependency and declares no serialization machinery. It provides the config +// VOCABULARY as string constants — JSON field keys, enum option values, and the +// schema version — so a C++ client (the rtmg-vst plugin) references generated +// names instead of hand-copied literals while keeping its own juce::var +// (de)serialization. It also provides the DEFAULT_CONFIG VALUES as typed +// constexpr facts (demon::config::defaults), including two small constexpr POD +// tables for iteration, so C++ hosts consume the defaults instead of +// re-declaring them. The same value facts ship as machine-readable JSON in +// types/configDefaults.gen.json for JS/TS consumers. // // The preset file IS a web DemonExport: an RtmgConfig plus an optional top-level // `inputs` (SerializedInputs). Field-key coverage of NESTED objects mirrors @@ -305,4 +310,156 @@ namespace inputs { } // namespace inputs +// ── DEFAULT_CONFIG values (defaults::*) ── +// The VALUE facts of DEFAULT_CONFIG — control defaults, prompts, engine +// scalars, channel-range min/max/reverse — as typed constexpr constants, +// so a C++ host CONSUMES the defaults instead of re-declaring them. + +namespace defaults { + + // Top-level scalar defaults. + inline constexpr double kSeed = 0.0; + inline constexpr const char* kSwapSourceMode = "instruments"; + + // engine.* scalar defaults (enabled_loras is [] in DEFAULT_CONFIG; an + // empty array has no value constant). + namespace engine { + + inline constexpr bool kSde = false; + inline constexpr bool kLora = true; + inline constexpr double kDepth = 4.0; + inline constexpr double kVaeWindow = 0.36; + inline constexpr double kCrop = 0.0; + inline constexpr double kSteps = 8.0; + inline constexpr bool kFastVae = false; + inline constexpr bool kWalkWindow = false; + inline constexpr double kWalkWindowS = 60.0; + inline constexpr double kLeadFloorS = 0.25; + inline constexpr double kLeadCeilingS = 1.35; + inline constexpr double kLeadReleaseTauS = 1.5; + inline constexpr double kMaxSourceDurationS = 120.0; + inline constexpr const char* kKey = "G# minor"; + inline constexpr const char* kTimeSignature = "4"; + inline constexpr bool kAutoPrependLoraTriggers = true; + inline constexpr bool kShowIncompatibleLoras = false; + + } // namespace engine + + namespace prompts { + + inline constexpr const char* kA = "heavy dubstep, deathstep, afxdump, growl heavy bass distortion"; + inline constexpr const char* kB = "daft punk style, beautiful, four to the floor, angelic"; + inline constexpr double kBlend = 0.4; + + } // namespace prompts + + namespace controls { + + // DEFAULT_CONFIG.controls values, one typed constant per knob. + inline constexpr double kDenoise = 0.7; + inline constexpr double kHintStrength = 1.0; + inline constexpr double kFeedback = 0.0; + inline constexpr double kFeedbackDepth = 1.0; + inline constexpr double kShift = 3.5; + inline constexpr double kChG0 = 1.0; + inline constexpr double kChG1 = 1.0; + inline constexpr double kChG2 = 1.0; + inline constexpr double kChG3 = 1.0; + inline constexpr double kChG4 = 1.0; + inline constexpr double kChG5 = 1.0; + inline constexpr double kChG6 = 1.0; + inline constexpr double kChG7 = 1.0; + inline constexpr double kCh13 = 1.0; + inline constexpr double kCh14 = 1.0; + inline constexpr double kCh19 = 1.0; + inline constexpr double kCh23 = 1.0; + inline constexpr double kCh29 = 1.0; + inline constexpr double kCh56 = 1.0; + inline constexpr double kDcwScaler = 0.05; + inline constexpr double kDcwHighScaler = 0.02; + inline constexpr bool kDcwEnabled = true; + inline constexpr const char* kDcwMode = "double"; + inline constexpr const char* kDcwWavelet = "haar"; + inline constexpr double kLoraDefaultStrength = 1.4; + inline constexpr double kGuidanceScale = 7.0; + inline constexpr double kCfgRescale = 0.0; + inline constexpr const char* kRcfgMode = "off"; + + // The same values as one ordered table (DEFAULT_CONFIG insertion order, + // which is also JSON.stringify emission order), for consumers that + // iterate the control set instead of naming each constant. + enum class ValueKind { Number, Boolean, String }; + struct ControlValue { + const char* key; + ValueKind kind; + double number; // valid when kind == Number + bool boolean; // valid when kind == Boolean + const char* string; // valid when kind == String + }; + inline constexpr ControlValue kValues[] = { + { "denoise", ValueKind::Number, 0.7, false, nullptr }, + { "hint_strength", ValueKind::Number, 1.0, false, nullptr }, + { "feedback", ValueKind::Number, 0.0, false, nullptr }, + { "feedback_depth", ValueKind::Number, 1.0, false, nullptr }, + { "shift", ValueKind::Number, 3.5, false, nullptr }, + { "ch_g0", ValueKind::Number, 1.0, false, nullptr }, + { "ch_g1", ValueKind::Number, 1.0, false, nullptr }, + { "ch_g2", ValueKind::Number, 1.0, false, nullptr }, + { "ch_g3", ValueKind::Number, 1.0, false, nullptr }, + { "ch_g4", ValueKind::Number, 1.0, false, nullptr }, + { "ch_g5", ValueKind::Number, 1.0, false, nullptr }, + { "ch_g6", ValueKind::Number, 1.0, false, nullptr }, + { "ch_g7", ValueKind::Number, 1.0, false, nullptr }, + { "ch13", ValueKind::Number, 1.0, false, nullptr }, + { "ch14", ValueKind::Number, 1.0, false, nullptr }, + { "ch19", ValueKind::Number, 1.0, false, nullptr }, + { "ch23", ValueKind::Number, 1.0, false, nullptr }, + { "ch29", ValueKind::Number, 1.0, false, nullptr }, + { "ch56", ValueKind::Number, 1.0, false, nullptr }, + { "dcw_scaler", ValueKind::Number, 0.05, false, nullptr }, + { "dcw_high_scaler", ValueKind::Number, 0.02, false, nullptr }, + { "dcw_enabled", ValueKind::Boolean, 0.0, true, nullptr }, + { "dcw_mode", ValueKind::String, 0.0, false, "double" }, + { "dcw_wavelet", ValueKind::String, 0.0, false, "haar" }, + { "lora_default_strength", ValueKind::Number, 1.4, false, nullptr }, + { "guidance_scale", ValueKind::Number, 7.0, false, nullptr }, + { "cfg_rescale", ValueKind::Number, 0.0, false, nullptr }, + { "rcfg_mode", ValueKind::String, 0.0, false, "off" }, + }; + inline constexpr int kValueCount = 28; + + } // namespace controls + + namespace channel_ranges { + + // DEFAULT_CONFIG.channel_ranges rows ({ min, max, reverse } per channel), + // in DEFAULT_CONFIG insertion order. + struct ChannelRange { + const char* channel; + double min; + double max; + bool reverse; + }; + inline constexpr ChannelRange kRanges[] = { + { "ch_g0", 0.0, 2.2, false }, + { "ch_g1", 0.0, 2.0, false }, + { "ch_g2", 0.0, 2.3, true }, + { "ch_g3", 0.0, 2.0, false }, + { "ch_g4", 0.0, 2.5, false }, + { "ch_g5", 0.0, 2.0, false }, + { "ch_g6", 0.0, 2.0, true }, + { "ch_g7", 0.0, 2.0, true }, + { "ch13", 0.0, 2.0, true }, + { "ch14", 0.0, 2.3, false }, + { "ch19", 0.0, 2.5, false }, + { "ch23", 0.0, 2.45, false }, + { "ch29", 0.0, 2.0, false }, + { "ch56", 0.0, 2.0, false }, + }; + inline constexpr int kRangeCount = 14; + + } // namespace channel_ranges + +} // namespace defaults + } // namespace demon::config diff --git a/packages/demon-client/types/configDefaults.gen.json b/packages/demon-client/types/configDefaults.gen.json new file mode 100644 index 00000000..1f5d948f --- /dev/null +++ b/packages/demon-client/types/configDefaults.gen.json @@ -0,0 +1 @@ +{"version":1,"engine":{"sde":false,"lora":true,"depth":4,"vae_window":0.36,"crop":0,"steps":8,"fast_vae":false,"walk_window":false,"walk_window_s":60,"lead_floor_s":0.25,"lead_ceiling_s":1.35,"lead_release_tau_s":1.5,"max_source_duration_s":120,"key":"G# minor","time_signature":"4","enabled_loras":[],"auto_prepend_lora_triggers":true,"show_incompatible_loras":false},"prompts":{"a":"heavy dubstep, deathstep, afxdump, growl heavy bass distortion","b":"daft punk style, beautiful, four to the floor, angelic","blend":0.4},"controls":{"denoise":0.7,"hint_strength":1,"feedback":0,"feedback_depth":1,"shift":3.5,"ch_g0":1,"ch_g1":1,"ch_g2":1,"ch_g3":1,"ch_g4":1,"ch_g5":1,"ch_g6":1,"ch_g7":1,"ch13":1,"ch14":1,"ch19":1,"ch23":1,"ch29":1,"ch56":1,"dcw_scaler":0.05,"dcw_high_scaler":0.02,"dcw_enabled":true,"dcw_mode":"double","dcw_wavelet":"haar","lora_default_strength":1.4,"guidance_scale":7,"cfg_rescale":0,"rcfg_mode":"off"},"channel_ranges":{"ch_g0":{"min":0,"max":2.2,"reverse":false},"ch_g1":{"min":0,"max":2,"reverse":false},"ch_g2":{"min":0,"max":2.3,"reverse":true},"ch_g3":{"min":0,"max":2,"reverse":false},"ch_g4":{"min":0,"max":2.5,"reverse":false},"ch_g5":{"min":0,"max":2,"reverse":false},"ch_g6":{"min":0,"max":2,"reverse":true},"ch_g7":{"min":0,"max":2,"reverse":true},"ch13":{"min":0,"max":2,"reverse":true},"ch14":{"min":0,"max":2.3,"reverse":false},"ch19":{"min":0,"max":2.5,"reverse":false},"ch23":{"min":0,"max":2.45,"reverse":false},"ch29":{"min":0,"max":2,"reverse":false},"ch56":{"min":0,"max":2,"reverse":false}},"seed":0,"swap_source_mode":"instruments","web":{"effects":{"parallax_strength":0.4,"bloom_on_kick":0.3,"bloom_threshold":0.15,"warp_strength":0.4},"audio":{"lufs_enabled":false,"lufs_window_sec":3,"lufs_metric":"lufs","lufs_peak_headroom":4,"lufs_silence_floor_db":30,"lufs_silence_floor_hysteresis_db":6},"reset_seconds":0,"denoise_session_gate":{"enabled":true,"glide_ms":700},"restart_song_on_swap":true}}