feat(nisps-core,vcv): complete Phases 6 + 7 — persistence, derived outputs

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
This commit is contained in:
w1n5t0n 2026-03-28 01:27:19 +02:00
parent 6a76f15736
commit 74c52fadc7
3 changed files with 285 additions and 3 deletions

View file

@ -54,6 +54,26 @@ public:
// speed: noise magnitude, spread: 0 = flat noise, 1 = Xavier-scaled + weight decay // speed: noise magnitude, spread: 0 = flat noise, 1 = Xavier-scaled + weight decay
void move_weights(Float speed, Float spread); void move_weights(Float speed, Float spread);
// ── Serialization accessors ───────────────────────────────────────
// Weight access (delegates to MLP)
typename MLP<Float>::mlp_weights get_weights() const;
void set_weights(typename MLP<Float>::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<std::vector<Float>> get_example_features() const;
std::vector<std::vector<Float>> get_example_labels() const;
// Bulk-load examples (clears existing, adds all)
void load_examples(const std::vector<std::vector<Float>>& features,
const std::vector<std::vector<Float>>& 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 // Optional logging
void set_logger(LogFn fn) { log_fn_ = fn; } void set_logger(LogFn fn) { log_fn_ = fn; }

View file

@ -1,6 +1,9 @@
#ifndef NISPS_IML_IMPL_HPP #ifndef NISPS_IML_IMPL_HPP
#define NISPS_IML_IMPL_HPP #define NISPS_IML_IMPL_HPP
#include <limits>
#include <cmath>
namespace nisps { namespace nisps {
template<typename Float> template<typename Float>
@ -226,6 +229,84 @@ void IML<Float>::train() {
log("Training complete."); log("Training complete.");
} }
// ── Serialization accessors ───────────────────────────────────────
template<typename Float>
typename MLP<Float>::mlp_weights IML<Float>::get_weights() const {
return mlp_->GetWeights();
}
template<typename Float>
void IML<Float>::set_weights(typename MLP<Float>::mlp_weights& weights) {
mlp_->SetWeights(weights);
}
template<typename Float>
size_t IML<Float>::get_example_count() const {
Dataset::DatasetVector* feats;
Dataset::DatasetVector* labels;
const_cast<Dataset*>(dataset_.get())->Fetch(feats, labels);
return feats ? feats->size() : 0;
}
template<typename Float>
size_t IML<Float>::get_max_examples() const {
return Dataset::kMax_examples;
}
template<typename Float>
std::vector<std::vector<Float>> IML<Float>::get_example_features() const {
auto feats = const_cast<Dataset*>(dataset_.get())->GetFeatures(false);
std::vector<std::vector<Float>> result;
result.reserve(feats.size());
for (auto& f : feats) {
result.emplace_back(f.begin(), f.end());
}
return result;
}
template<typename Float>
std::vector<std::vector<Float>> IML<Float>::get_example_labels() const {
auto& labels = const_cast<Dataset*>(dataset_.get())->GetLabels();
std::vector<std::vector<Float>> result;
result.reserve(labels.size());
for (auto& l : labels) {
result.emplace_back(l.begin(), l.end());
}
return result;
}
template<typename Float>
void IML<Float>::load_examples(const std::vector<std::vector<Float>>& features,
const std::vector<std::vector<Float>>& labels) {
dataset_->Clear();
size_t count = std::min(features.size(), labels.size());
for (size_t i = 0; i < count; i++) {
std::vector<float> feat(features[i].begin(), features[i].end());
std::vector<float> label(labels[i].begin(), labels[i].end());
dataset_->Add(feat, label);
}
}
template<typename Float>
Float IML<Float>::nearest_example_distance(const Float* input, size_t n_in) const {
auto feats = const_cast<Dataset*>(dataset_.get())->GetFeatures(false);
if (feats.empty()) return static_cast<Float>(-1);
Float minDist = std::numeric_limits<Float>::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<Float>(f[d]) - input[d];
dist += diff * diff;
}
dist = std::sqrt(dist);
if (dist < minDist) minDist = dist;
}
return minDist;
}
} // namespace nisps } // namespace nisps
#endif // NISPS_IML_IMPL_HPP #endif // NISPS_IML_IMPL_HPP

View file

@ -1,10 +1,12 @@
#include "plugin.hpp" #include "plugin.hpp"
#include <nisps/nisps.hpp> #include <nisps/nisps.hpp>
#include <osdialog.h>
#include <thread> #include <thread>
#include <atomic> #include <atomic>
#include <mutex> #include <mutex>
#include <condition_variable> #include <condition_variable>
#include <functional> #include <functional>
#include <fstream>
static constexpr int NUM_ML_INPUTS = 2; static constexpr int NUM_ML_INPUTS = 2;
static constexpr int NUM_ML_OUTPUTS = 12; 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 outputRangeUnipolar[NUM_ML_OUTPUTS] = {}; // true = 0-10V, false = ±5V
bool inputRangeUnipolar[MAX_ML_INPUTS] = {}; // true = 0-10V, false = ±5V bool inputRangeUnipolar[MAX_ML_INPUTS] = {}; // true = 0-10V, false = ±5V
float clearHoldTime = 0.f; 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 ────────────────────────────────────────────────────── // ── Triggers ──────────────────────────────────────────────────────
dsp::BooleanTrigger randTrigger; dsp::BooleanTrigger randTrigger;
@ -186,6 +191,18 @@ struct MEMLNaut : Module {
iml.move_weights(job.noiseLevel, job.spread); 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); swapReady.store(true);
isTraining.store(false); isTraining.store(false);
@ -340,6 +357,8 @@ struct MEMLNaut : Module {
// Read and normalize inputs // Read and normalize inputs
float x = normalizeInput(INPUT_X, 0); float x = normalizeInput(INPUT_X, 0);
float y = normalizeInput(INPUT_Y, 1); float y = normalizeInput(INPUT_Y, 1);
lastInputs[0] = x;
lastInputs[1] = y;
iml.set_input(0, x); iml.set_input(0, x);
iml.set_input(1, y); iml.set_input(1, y);
@ -410,14 +429,15 @@ struct MEMLNaut : Module {
} }
outputs[OUTPUT_DELTA].setVoltage(std::sqrt(delta) * 10.f); outputs[OUTPUT_DELTA].setVoltage(std::sqrt(delta) * 10.f);
// Novelty + Confidence (placeholder — computed on training thread in Phase 7) // Novelty + Confidence (computed on background thread, cached)
outputs[OUTPUT_NOVELTY].setVoltage(10.f); // default: everything is novel outputs[OUTPUT_NOVELTY].setVoltage(cachedNovelty);
outputs[OUTPUT_CONFIDENCE].setVoltage(0.f); // default: no confidence outputs[OUTPUT_CONFIDENCE].setVoltage(cachedConfidence);
} }
// ── Serialization ───────────────────────────────────────────────── // ── Serialization ─────────────────────────────────────────────────
json_t* dataToJson() override { json_t* dataToJson() override {
json_t* root = json_object(); 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, "noiseLevel", json_real(noiseLevel));
json_object_set_new(root, "slewMs", json_real(slewMs)); json_object_set_new(root, "slewMs", json_real(slewMs));
@ -435,6 +455,52 @@ struct MEMLNaut : Module {
} }
json_object_set_new(root, "inputRangeUnipolar", inRanges); 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; return root;
} }
@ -445,6 +511,7 @@ struct MEMLNaut : Module {
if ((j = json_object_get(root, "slewMs"))) if ((j = json_object_get(root, "slewMs")))
slewMs = json_real_value(j); slewMs = json_real_value(j);
// Output ranges
json_t* outRanges = json_object_get(root, "outputRangeUnipolar"); json_t* outRanges = json_object_get(root, "outputRangeUnipolar");
if (outRanges) { if (outRanges) {
for (int i = 0; i < NUM_ML_OUTPUTS && i < (int)json_array_size(outRanges); i++) { 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"); json_t* inRanges = json_object_get(root, "inputRangeUnipolar");
if (inRanges) { if (inRanges) {
for (int i = 0; i < MAX_ML_INPUTS && i < (int)json_array_size(inRanges); i++) { 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)); 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<float>::mlp_weights weights;
for (size_t li = 0; li < json_array_size(jWeights); li++) {
json_t* jLayer = json_array_get(jWeights, li);
std::vector<std::vector<float>> layer;
for (size_t ni = 0; ni < json_array_size(jLayer); ni++) {
json_t* jNode = json_array_get(jLayer, ni);
std::vector<float> 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<std::vector<float>> features, labels;
for (size_t i = 0; i < json_array_size(jFeatures); i++) {
json_t* jF = json_array_get(jFeatures, i);
std::vector<float> 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<float> 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<char>(file)),
std::istreambuf_iterator<char>());
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);
}));
} }
}; };