From 74c52fadc7c2df8cf3998d1fd037b1087ba186db Mon Sep 17 00:00:00 2001 From: w1n5t0n Date: Sat, 28 Mar 2026 01:27:19 +0200 Subject: [PATCH] =?UTF-8?q?feat(nisps-core,vcv):=20complete=20Phases=206?= =?UTF-8?q?=20+=207=20=E2=80=94=20persistence,=20derived=20outputs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 6 — State persistence: - Full state serialization: version, weights (3D), examples (features+labels), mlpConfig, noiseLevel, slewMs, output/input ranges - Validation on load: version check, graceful missing field handling - .nisps preset save/load via right-click menu (osdialog file dialogs) - Param values included in preset files Phase 7 — Derived outputs: - Mean, STD, delta computed on audio thread (trivial cost) - Novelty/confidence: nearest_example_distance() computed on background thread after each training/perturbation job, cached for audio thread - Defaults with 0 examples: novelty=10V, confidence=0V nisps-core IML additions: - get_weights() / set_weights() for MLP weight serialization - get_example_features/labels() / load_examples() for dataset serialization - nearest_example_distance() for novelty/confidence metric - get_example_count() / get_max_examples() for UI display --- nisps-core/include/nisps/iml.hpp | 20 +++ nisps-core/include/nisps/iml_impl.hpp | 81 +++++++++++ vcv/src/MEMLNaut.cpp | 187 +++++++++++++++++++++++++- 3 files changed, 285 insertions(+), 3 deletions(-) diff --git a/nisps-core/include/nisps/iml.hpp b/nisps-core/include/nisps/iml.hpp index 6b9a7b9..98dba18 100644 --- a/nisps-core/include/nisps/iml.hpp +++ b/nisps-core/include/nisps/iml.hpp @@ -54,6 +54,26 @@ public: // speed: noise magnitude, spread: 0 = flat noise, 1 = Xavier-scaled + weight decay void move_weights(Float speed, Float spread); + // ── Serialization accessors ─────────────────────────────────────── + + // Weight access (delegates to MLP) + typename MLP::mlp_weights get_weights() const; + void set_weights(typename MLP::mlp_weights& weights); + + // Dataset access + size_t get_example_count() const; + size_t get_max_examples() const; + // Returns copies of the dataset vectors + std::vector> get_example_features() const; + std::vector> get_example_labels() const; + // Bulk-load examples (clears existing, adds all) + void load_examples(const std::vector>& features, + const std::vector>& labels); + + // Nearest-neighbor distance for novelty/confidence computation + // Returns the minimum Euclidean distance from `input` to any training example + Float nearest_example_distance(const Float* input, size_t n_in) const; + // Optional logging void set_logger(LogFn fn) { log_fn_ = fn; } diff --git a/nisps-core/include/nisps/iml_impl.hpp b/nisps-core/include/nisps/iml_impl.hpp index 6458208..1a65cfb 100644 --- a/nisps-core/include/nisps/iml_impl.hpp +++ b/nisps-core/include/nisps/iml_impl.hpp @@ -1,6 +1,9 @@ #ifndef NISPS_IML_IMPL_HPP #define NISPS_IML_IMPL_HPP +#include +#include + namespace nisps { template @@ -226,6 +229,84 @@ void IML::train() { log("Training complete."); } +// ── Serialization accessors ─────────────────────────────────────── + +template +typename MLP::mlp_weights IML::get_weights() const { + return mlp_->GetWeights(); +} + +template +void IML::set_weights(typename MLP::mlp_weights& weights) { + mlp_->SetWeights(weights); +} + +template +size_t IML::get_example_count() const { + Dataset::DatasetVector* feats; + Dataset::DatasetVector* labels; + const_cast(dataset_.get())->Fetch(feats, labels); + return feats ? feats->size() : 0; +} + +template +size_t IML::get_max_examples() const { + return Dataset::kMax_examples; +} + +template +std::vector> IML::get_example_features() const { + auto feats = const_cast(dataset_.get())->GetFeatures(false); + std::vector> result; + result.reserve(feats.size()); + for (auto& f : feats) { + result.emplace_back(f.begin(), f.end()); + } + return result; +} + +template +std::vector> IML::get_example_labels() const { + auto& labels = const_cast(dataset_.get())->GetLabels(); + std::vector> result; + result.reserve(labels.size()); + for (auto& l : labels) { + result.emplace_back(l.begin(), l.end()); + } + return result; +} + +template +void IML::load_examples(const std::vector>& features, + const std::vector>& labels) { + dataset_->Clear(); + size_t count = std::min(features.size(), labels.size()); + for (size_t i = 0; i < count; i++) { + std::vector feat(features[i].begin(), features[i].end()); + std::vector label(labels[i].begin(), labels[i].end()); + dataset_->Add(feat, label); + } +} + +template +Float IML::nearest_example_distance(const Float* input, size_t n_in) const { + auto feats = const_cast(dataset_.get())->GetFeatures(false); + if (feats.empty()) return static_cast(-1); + + Float minDist = std::numeric_limits::max(); + size_t dims = std::min(n_in, n_inputs_); + for (auto& f : feats) { + Float dist = 0; + for (size_t d = 0; d < dims && d < f.size(); d++) { + Float diff = static_cast(f[d]) - input[d]; + dist += diff * diff; + } + dist = std::sqrt(dist); + if (dist < minDist) minDist = dist; + } + return minDist; +} + } // namespace nisps #endif // NISPS_IML_IMPL_HPP diff --git a/vcv/src/MEMLNaut.cpp b/vcv/src/MEMLNaut.cpp index 1a36388..0fe0139 100644 --- a/vcv/src/MEMLNaut.cpp +++ b/vcv/src/MEMLNaut.cpp @@ -1,10 +1,12 @@ #include "plugin.hpp" #include +#include #include #include #include #include #include +#include static constexpr int NUM_ML_INPUTS = 2; static constexpr int NUM_ML_OUTPUTS = 12; @@ -75,6 +77,9 @@ struct MEMLNaut : Module { bool outputRangeUnipolar[NUM_ML_OUTPUTS] = {}; // true = 0-10V, false = ±5V bool inputRangeUnipolar[MAX_ML_INPUTS] = {}; // true = 0-10V, false = ±5V float clearHoldTime = 0.f; + float cachedNovelty = 10.f; // default: everything novel (10V) + float cachedConfidence = 0.f; // default: no confidence (0V) + float lastInputs[MAX_ML_INPUTS] = {}; // ── Triggers ────────────────────────────────────────────────────── dsp::BooleanTrigger randTrigger; @@ -186,6 +191,18 @@ struct MEMLNaut : Module { iml.move_weights(job.noiseLevel, job.spread); } + // Update novelty/confidence for current input position + if (iml.get_example_count() > 0) { + float dist = iml.nearest_example_distance(lastInputs, NUM_ML_INPUTS); + // Novelty: scale distance to 0-10V (1.0 distance = 10V, saturates) + cachedNovelty = std::min(dist * 10.f, 10.f); + // Confidence: inverse of distance (close = high confidence) + cachedConfidence = std::max(0.f, 10.f - dist * 10.f); + } else { + cachedNovelty = 10.f; + cachedConfidence = 0.f; + } + swapReady.store(true); isTraining.store(false); @@ -340,6 +357,8 @@ struct MEMLNaut : Module { // Read and normalize inputs float x = normalizeInput(INPUT_X, 0); float y = normalizeInput(INPUT_Y, 1); + lastInputs[0] = x; + lastInputs[1] = y; iml.set_input(0, x); iml.set_input(1, y); @@ -410,14 +429,15 @@ struct MEMLNaut : Module { } outputs[OUTPUT_DELTA].setVoltage(std::sqrt(delta) * 10.f); - // Novelty + Confidence (placeholder — computed on training thread in Phase 7) - outputs[OUTPUT_NOVELTY].setVoltage(10.f); // default: everything is novel - outputs[OUTPUT_CONFIDENCE].setVoltage(0.f); // default: no confidence + // Novelty + Confidence (computed on background thread, cached) + outputs[OUTPUT_NOVELTY].setVoltage(cachedNovelty); + outputs[OUTPUT_CONFIDENCE].setVoltage(cachedConfidence); } // ── Serialization ───────────────────────────────────────────────── json_t* dataToJson() override { json_t* root = json_object(); + json_object_set_new(root, "version", json_integer(1)); json_object_set_new(root, "noiseLevel", json_real(noiseLevel)); json_object_set_new(root, "slewMs", json_real(slewMs)); @@ -435,6 +455,52 @@ struct MEMLNaut : Module { } json_object_set_new(root, "inputRangeUnipolar", inRanges); + // MLP weights (3D: layer → node → weight) + auto weights = iml.get_weights(); + json_t* jWeights = json_array(); + for (auto& layer : weights) { + json_t* jLayer = json_array(); + for (auto& node : layer) { + json_t* jNode = json_array(); + for (float w : node) { + json_array_append_new(jNode, json_real(w)); + } + json_array_append_new(jLayer, jNode); + } + json_array_append_new(jWeights, jLayer); + } + json_object_set_new(root, "weights", jWeights); + + // Training examples + auto features = iml.get_example_features(); + auto labels = iml.get_example_labels(); + json_t* jExamples = json_object(); + json_t* jFeatures = json_array(); + for (auto& f : features) { + json_t* jF = json_array(); + for (float v : f) json_array_append_new(jF, json_real(v)); + json_array_append_new(jFeatures, jF); + } + json_t* jLabels = json_array(); + for (auto& l : labels) { + json_t* jL = json_array(); + for (float v : l) json_array_append_new(jL, json_real(v)); + json_array_append_new(jLabels, jL); + } + json_object_set_new(jExamples, "features", jFeatures); + json_object_set_new(jExamples, "labels", jLabels); + json_object_set_new(root, "examples", jExamples); + + // MLP config (for validation on load) + json_t* jConfig = json_object(); + json_t* jLayers = json_array(); + // [3, 16, 24, 16, 12] for default config + json_array_append_new(jLayers, json_integer(NUM_ML_INPUTS + 1)); // +bias + for (int h : {16, 24, 16}) json_array_append_new(jLayers, json_integer(h)); + json_array_append_new(jLayers, json_integer(NUM_ML_OUTPUTS)); + json_object_set_new(jConfig, "layers", jLayers); + json_object_set_new(root, "mlpConfig", jConfig); + return root; } @@ -445,6 +511,7 @@ struct MEMLNaut : Module { if ((j = json_object_get(root, "slewMs"))) slewMs = json_real_value(j); + // Output ranges json_t* outRanges = json_object_get(root, "outputRangeUnipolar"); if (outRanges) { for (int i = 0; i < NUM_ML_OUTPUTS && i < (int)json_array_size(outRanges); i++) { @@ -452,12 +519,58 @@ struct MEMLNaut : Module { } } + // Input ranges json_t* inRanges = json_object_get(root, "inputRangeUnipolar"); if (inRanges) { for (int i = 0; i < MAX_ML_INPUTS && i < (int)json_array_size(inRanges); i++) { inputRangeUnipolar[i] = json_boolean_value(json_array_get(inRanges, i)); } } + + // MLP weights + json_t* jWeights = json_object_get(root, "weights"); + if (jWeights && json_is_array(jWeights)) { + nisps::MLP::mlp_weights weights; + for (size_t li = 0; li < json_array_size(jWeights); li++) { + json_t* jLayer = json_array_get(jWeights, li); + std::vector> layer; + for (size_t ni = 0; ni < json_array_size(jLayer); ni++) { + json_t* jNode = json_array_get(jLayer, ni); + std::vector node; + for (size_t wi = 0; wi < json_array_size(jNode); wi++) { + node.push_back(json_real_value(json_array_get(jNode, wi))); + } + layer.push_back(node); + } + weights.push_back(layer); + } + iml.set_weights(weights); + } + + // Training examples + json_t* jExamples = json_object_get(root, "examples"); + if (jExamples) { + json_t* jFeatures = json_object_get(jExamples, "features"); + json_t* jLabels = json_object_get(jExamples, "labels"); + if (jFeatures && jLabels) { + std::vector> features, labels; + for (size_t i = 0; i < json_array_size(jFeatures); i++) { + json_t* jF = json_array_get(jFeatures, i); + std::vector f; + for (size_t fi = 0; fi < json_array_size(jF); fi++) + f.push_back(json_real_value(json_array_get(jF, fi))); + features.push_back(f); + } + for (size_t i = 0; i < json_array_size(jLabels); i++) { + json_t* jL = json_array_get(jLabels, i); + std::vector l; + for (size_t li = 0; li < json_array_size(jL); li++) + l.push_back(json_real_value(json_array_get(jL, li))); + labels.push_back(l); + } + iml.load_examples(features, labels); + } + } } }; @@ -619,6 +732,74 @@ struct MEMLNautWidget : ModuleWidget { )); } })); + + // ── Preset save/load ────────────────────────────────────────── + menu->addChild(new MenuSeparator); + menu->addChild(createMenuLabel("Presets (.nisps)")); + + menu->addChild(createMenuItem("Save .nisps preset...", "", [=]() { + osdialog_filters* filters = osdialog_filters_parse("NISPS preset:nisps"); + char* path = osdialog_file(OSDIALOG_SAVE, nullptr, "preset.nisps", filters); + osdialog_filters_free(filters); + if (!path) return; + + json_t* root = module->dataToJson(); + // Also save all param values + json_t* jParams = json_array(); + for (int i = 0; i < MEMLNaut::PARAMS_LEN; i++) { + json_array_append_new(jParams, json_real(module->params[i].getValue())); + } + json_object_set_new(root, "params", jParams); + + char* jsonStr = json_dumps(root, JSON_INDENT(2)); + json_decref(root); + + std::ofstream file(path); + if (file.is_open()) { + file << jsonStr; + file.close(); + } + free(jsonStr); + free(path); + })); + + menu->addChild(createMenuItem("Load .nisps preset...", "", [=]() { + osdialog_filters* filters = osdialog_filters_parse("NISPS preset:nisps"); + char* path = osdialog_file(OSDIALOG_OPEN, nullptr, nullptr, filters); + osdialog_filters_free(filters); + if (!path) return; + + std::ifstream file(path); + free(path); + if (!file.is_open()) return; + + std::string content((std::istreambuf_iterator(file)), + std::istreambuf_iterator()); + file.close(); + + json_error_t error; + json_t* root = json_loads(content.c_str(), 0, &error); + if (!root) return; + + // Validate version + json_t* jVersion = json_object_get(root, "version"); + if (!jVersion || json_integer_value(jVersion) < 1) { + json_decref(root); + return; + } + + module->dataFromJson(root); + + // Restore param values if present + json_t* jParams = json_object_get(root, "params"); + if (jParams && json_is_array(jParams)) { + for (size_t i = 0; i < json_array_size(jParams) && i < MEMLNaut::PARAMS_LEN; i++) { + module->params[i].setValue(json_real_value(json_array_get(jParams, i))); + } + } + + json_decref(root); + })); } };