feat(nisps-core,vcv): complete Phase 0 + Phase 1

Phase 0 — spread-aware API ported to nisps-core C++:
- MLP::DrawWeightsSpread(T spread) — interpolate uniform↔Xavier per layer
- MLP::MoveWeightsSpread(T speed, T spread) — per-layer noise + weight decay
- IML::randomise_weights(Float spread) and IML::move_weights(speed, spread)
- 5 unit tests (10/10 total pass)

Phase 1 — VCV Rack 2 plugin skeleton:
- Makefile with C++20, nisps-core include path
- plugin.json manifest
- Empty MEMLNaut module: 2 inputs, 12 outputs, placeholder SVG panel
- static_assert verifies nisps-core headers resolve
- C++20 confirmed working in VCV SDK (8 existing plugins use it)
This commit is contained in:
w1n5t0n 2026-03-28 00:36:46 +02:00
parent 9040886e16
commit d0ba1faaea
11 changed files with 459 additions and 0 deletions

View file

@ -46,6 +46,14 @@ public:
void clear_dataset();
void randomise_weights();
// Spread-aware weight randomization
// spread: 0 = uniform [-1,1], 1 = Xavier-scaled per layer
void randomise_weights(Float spread);
// Spread-aware weight perturbation (for RL exploration)
// speed: noise magnitude, spread: 0 = flat noise, 1 = Xavier-scaled + weight decay
void move_weights(Float speed, Float spread);
// Optional logging
void set_logger(LogFn fn) { log_fn_ = fn; }

View file

@ -162,6 +162,33 @@ void IML<Float>::randomise_weights() {
}
}
template<typename Float>
void IML<Float>::randomise_weights(Float spread) {
if (mode_ == Mode::Training) {
stored_weights_ = mlp_->GetWeights();
mlp_->DrawWeightsSpread(spread);
weights_randomised_ = true;
// Run inference to show effect
std::vector<Float> input_with_bias = input_state_;
input_with_bias.push_back(static_cast<Float>(1.0));
std::vector<Float> output(n_outputs_);
mlp_->GetOutput(input_with_bias, &output);
output_state_ = output;
log("Weights randomised (spread).");
}
}
template<typename Float>
void IML<Float>::move_weights(Float speed, Float spread) {
mlp_->MoveWeightsSpread(speed, spread);
// Run inference to show effect of perturbation
input_updated_ = true;
process();
}
template<typename Float>
void IML<Float>::train() {
// Restore weights if they were randomised

View file

@ -229,6 +229,17 @@ public:
[[deprecated]]
void DrawWeights(float scale=1.f);
/**
* @brief Randomize weights with spread-controlled scaling
* @param spread 0 = uniform [-1,1] (polarised outputs), 1 = Xavier-scaled (centered outputs)
*
* Interpolates weight scale between uniform and Xavier initialization per layer.
* At spread=0: weights are uniform [-1,1] (original behavior).
* At spread=1: weights are scaled by 1/sqrt(fan_in) per layer (Xavier).
* Biases are set to 0.
*/
void DrawWeightsSpread(T spread);
void RandomiseWeightsAndBiasesLin(T weightMin, T weightMax, T biasMin, T biasMax);
void InitXavier();
@ -239,6 +250,17 @@ public:
*/
void MoveWeights(T speed);
/**
* @brief Add Gaussian noise to weights with spread-controlled scaling and decay
* @param speed Base noise standard deviation
* @param spread 0 = flat noise (original), 1 = Xavier-scaled noise with weight decay
*
* At spread=0: noise is uniform across all layers, no weight decay (original behavior).
* At spread=1: noise is scaled by 1/sqrt(fan_in) per layer, weights decay 10% per call.
* Weight decay prevents unbounded magnitude drift from repeated perturbation.
*/
void MoveWeightsSpread(T speed, T spread);
/**
* @brief Enable/disable caching of layer outputs
*

View file

@ -22,6 +22,7 @@
#include <vector>
#include <algorithm>
#include <cassert>
#include <cmath>
#include <random>
// #define SAFE_MODE
@ -807,6 +808,24 @@ void MLP<T>::DrawWeights(float scale)
// assert(m_layers[0].m_nodes[0].m_weights[0] != before);
}
template <typename T>
void MLP<T>::DrawWeightsSpread(T spread) {
utils::gen_rand<T> gen;
for (size_t n = 0; n < m_layers.size(); n++) {
const size_t fanIn = m_layers_nodes[n];
const T xavierScale = static_cast<T>(1.0) / std::sqrt(static_cast<T>(fanIn));
const T scale = static_cast<T>(1.0) * (static_cast<T>(1.0) - spread) + xavierScale * spread;
for (size_t k = 0; k < m_layers[n].m_nodes.size(); k++) {
for (size_t j = 0; j < m_layers[n].m_nodes[k].m_weights.size(); j++) {
m_layers[n].m_nodes[k].m_weights[j] = gen() * scale;
}
m_layers[n].m_nodes[k].m_bias = static_cast<T>(0);
}
}
}
template <typename T>
void MLP<T>::MoveWeights(T speed)
{
@ -830,6 +849,32 @@ void MLP<T>::MoveWeights(T speed)
assert(m_layers[0].m_nodes[0].m_weights[0] != before);
}
template <typename T>
void MLP<T>::MoveWeightsSpread(T speed, T spread) {
const T decay = static_cast<T>(1.0) - static_cast<T>(0.1) * spread;
// spread=0 → decay=1.0 (no decay), spread=1 → decay=0.9
for (size_t n = 0; n < m_layers.size(); n++) {
const size_t fanIn = m_layers_nodes[n];
const T xavierScale = static_cast<T>(1.0) / std::sqrt(static_cast<T>(fanIn));
const T layerScale = static_cast<T>(1.0) * (static_cast<T>(1.0) - spread) + xavierScale * spread;
for (size_t k = 0; k < m_layers[n].m_nodes.size(); k++) {
for (size_t j = 0; j < m_layers[n].m_nodes[k].m_weights.size(); j++) {
// Decay toward zero
m_layers[n].m_nodes[k].m_weights[j] *= decay;
// Sum of 3 uniform randoms × kN_times(3) × speed × layerScale
T accum = static_cast<T>(0);
for (int i = 0; i < 3; i++) {
accum += static_cast<T>(rand()) / static_cast<T>(RAND_MAX) * static_cast<T>(2) - static_cast<T>(1);
}
m_layers[n].m_nodes[k].m_weights[j] += static_cast<T>(3) * accum * speed * layerScale;
}
}
}
}
template <typename T>
void MLP<T>::InitXavier() {
for(auto & layer : m_layers) {

View file

@ -217,6 +217,244 @@ bool test_multi_output_training() {
return true;
}
bool test_draw_weights_spread_zero() {
std::cout << "--- Test: DrawWeightsSpread(0) — uniform [-1, 1] ---\n";
std::vector<size_t> layers = {3, 8, 4};
std::vector<nisps::ACTIVATION_FUNCTIONS> activs = {
nisps::ACTIVATION_FUNCTIONS::RELU,
nisps::ACTIVATION_FUNCTIONS::SIGMOID
};
nisps::MLP<float> mlp(layers, activs);
mlp.DrawWeightsSpread(0.0f);
for (size_t l = 0; l < mlp.m_layers.size(); l++) {
for (size_t k = 0; k < mlp.m_layers[l].m_nodes.size(); k++) {
// Check bias is 0
if (std::abs(mlp.m_layers[l].m_nodes[k].m_bias) > 1e-6f) {
std::cerr << "FAIL: Bias not zero at layer " << l << " node " << k
<< " (got " << mlp.m_layers[l].m_nodes[k].m_bias << ")\n";
return false;
}
for (size_t j = 0; j < mlp.m_layers[l].m_nodes[k].m_weights.size(); j++) {
float w = mlp.m_layers[l].m_nodes[k].m_weights[j];
if (std::isnan(w) || std::isinf(w)) {
std::cerr << "FAIL: NaN/Inf weight at layer " << l << " node " << k << " weight " << j << "\n";
return false;
}
if (w < -1.0f || w > 1.0f) {
std::cerr << "FAIL: Weight " << w << " outside [-1, 1] at layer " << l
<< " node " << k << " weight " << j << "\n";
return false;
}
}
}
}
std::cout << "PASS\n\n";
return true;
}
bool test_draw_weights_spread_one() {
std::cout << "--- Test: DrawWeightsSpread(1) — Xavier-scaled weights ---\n";
std::vector<size_t> layers = {3, 8, 4};
std::vector<nisps::ACTIVATION_FUNCTIONS> activs = {
nisps::ACTIVATION_FUNCTIONS::RELU,
nisps::ACTIVATION_FUNCTIONS::SIGMOID
};
nisps::MLP<float> mlp(layers, activs);
mlp.DrawWeightsSpread(1.0f);
// Layer 0: fan_in=3, xavier=1/sqrt(3)≈0.577
// Layer 1: fan_in=8, xavier=1/sqrt(8)≈0.354
float expected_xavier[] = {
1.0f / std::sqrt(3.0f), // layer 0
1.0f / std::sqrt(8.0f) // layer 1
};
for (size_t l = 0; l < mlp.m_layers.size(); l++) {
float max_abs = 0.0f;
float xavier = expected_xavier[l];
float limit = xavier * 1.1f;
for (size_t k = 0; k < mlp.m_layers[l].m_nodes.size(); k++) {
// Check bias is 0
if (std::abs(mlp.m_layers[l].m_nodes[k].m_bias) > 1e-6f) {
std::cerr << "FAIL: Bias not zero at layer " << l << " node " << k << "\n";
return false;
}
for (size_t j = 0; j < mlp.m_layers[l].m_nodes[k].m_weights.size(); j++) {
float w = mlp.m_layers[l].m_nodes[k].m_weights[j];
float aw = std::abs(w);
if (aw > max_abs) max_abs = aw;
if (aw > limit) {
std::cerr << "FAIL: Weight " << w << " exceeds xavier limit " << limit
<< " at layer " << l << " node " << k << " weight " << j << "\n";
return false;
}
}
}
std::cout << " Layer " << l << ": xavier=" << xavier
<< ", limit=" << limit << ", max|w|=" << max_abs << "\n";
}
std::cout << "PASS\n\n";
return true;
}
bool test_move_weights_spread_decay() {
std::cout << "--- Test: MoveWeightsSpread decay (speed=0, spread=1) ---\n";
std::vector<size_t> layers = {3, 8, 4};
std::vector<nisps::ACTIVATION_FUNCTIONS> activs = {
nisps::ACTIVATION_FUNCTIONS::RELU,
nisps::ACTIVATION_FUNCTIONS::SIGMOID
};
nisps::MLP<float> mlp(layers, activs);
// Set all weights to 1.0 via SetWeights
auto weights = mlp.GetWeights();
for (auto& layer_w : weights) {
for (auto& node_w : layer_w) {
for (auto& w : node_w) {
w = 1.0f;
}
}
}
mlp.SetWeights(weights);
// Call with speed=0 (no noise), spread=1 (full decay: multiply by 0.9)
mlp.MoveWeightsSpread(0.0f, 1.0f);
// Verify weights are approximately 0.9
for (size_t l = 0; l < mlp.m_layers.size(); l++) {
for (size_t k = 0; k < mlp.m_layers[l].m_nodes.size(); k++) {
for (size_t j = 0; j < mlp.m_layers[l].m_nodes[k].m_weights.size(); j++) {
float w = mlp.m_layers[l].m_nodes[k].m_weights[j];
if (std::abs(w - 0.9f) > 0.01f) {
std::cerr << "FAIL: After first decay, weight=" << w
<< " (expected ~0.9) at layer " << l << "\n";
return false;
}
}
}
}
std::cout << " After 1st call: weights ~0.9 (OK)\n";
// Call again: 0.9 * 0.9 = 0.81
mlp.MoveWeightsSpread(0.0f, 1.0f);
for (size_t l = 0; l < mlp.m_layers.size(); l++) {
for (size_t k = 0; k < mlp.m_layers[l].m_nodes.size(); k++) {
for (size_t j = 0; j < mlp.m_layers[l].m_nodes[k].m_weights.size(); j++) {
float w = mlp.m_layers[l].m_nodes[k].m_weights[j];
if (std::abs(w - 0.81f) > 0.01f) {
std::cerr << "FAIL: After second decay, weight=" << w
<< " (expected ~0.81) at layer " << l << "\n";
return false;
}
}
}
}
std::cout << " After 2nd call: weights ~0.81 (OK)\n";
std::cout << "PASS\n\n";
return true;
}
bool test_move_weights_spread_no_decay() {
std::cout << "--- Test: MoveWeightsSpread no decay (speed=0, spread=0) ---\n";
std::vector<size_t> layers = {3, 8, 4};
std::vector<nisps::ACTIVATION_FUNCTIONS> activs = {
nisps::ACTIVATION_FUNCTIONS::RELU,
nisps::ACTIVATION_FUNCTIONS::SIGMOID
};
nisps::MLP<float> mlp(layers, activs);
// Set all weights to 1.0
auto weights = mlp.GetWeights();
for (auto& layer_w : weights) {
for (auto& node_w : layer_w) {
for (auto& w : node_w) {
w = 1.0f;
}
}
}
mlp.SetWeights(weights);
// speed=0, spread=0 → no noise, no decay
mlp.MoveWeightsSpread(0.0f, 0.0f);
for (size_t l = 0; l < mlp.m_layers.size(); l++) {
for (size_t k = 0; k < mlp.m_layers[l].m_nodes.size(); k++) {
for (size_t j = 0; j < mlp.m_layers[l].m_nodes[k].m_weights.size(); j++) {
float w = mlp.m_layers[l].m_nodes[k].m_weights[j];
if (std::abs(w - 1.0f) > 1e-6f) {
std::cerr << "FAIL: Weight changed to " << w
<< " (expected 1.0) at layer " << l << "\n";
return false;
}
}
}
}
std::cout << " All weights still 1.0 (OK)\n";
std::cout << "PASS\n\n";
return true;
}
bool test_iml_spread_api() {
std::cout << "--- Test: IML spread API (randomise_weights / move_weights) ---\n";
nisps::IML<float> iml(2, 4, {8});
iml.set_logger(log_callback);
iml.set_mode(nisps::IML<float>::Mode::Training);
// randomise_weights with spread should not crash
iml.randomise_weights(0.5f);
// Set inputs and process
iml.set_input(0, 0.3f);
iml.set_input(1, 0.7f);
iml.process();
const float* out_before = iml.get_outputs();
float saved[4];
for (int i = 0; i < 4; i++) saved[i] = out_before[i];
// move_weights with spread should not crash and should change outputs
iml.move_weights(0.1f, 0.5f);
iml.process();
const float* out_after = iml.get_outputs();
bool any_changed = false;
for (int i = 0; i < 4; i++) {
if (std::isnan(out_after[i]) || std::isinf(out_after[i])) {
std::cerr << "FAIL: Output " << i << " is NaN/Inf\n";
return false;
}
if (out_after[i] < 0.0f || out_after[i] > 1.0f) {
std::cerr << "FAIL: Output " << i << " = " << out_after[i] << " outside [0, 1]\n";
return false;
}
if (std::abs(out_after[i] - saved[i]) > 1e-6f) {
any_changed = true;
}
}
if (!any_changed) {
std::cerr << "FAIL: move_weights did not change any outputs\n";
return false;
}
std::cout << " Outputs valid and changed after move_weights\n";
std::cout << "PASS\n\n";
return true;
}
int main() {
std::cout << "\n=== NISPS Core Test Suite ===\n\n";
@ -230,6 +468,11 @@ int main() {
run(test_add_example_api());
run(test_training_convergence());
run(test_multi_output_training());
run(test_draw_weights_spread_zero());
run(test_draw_weights_spread_one());
run(test_move_weights_spread_decay());
run(test_move_weights_spread_no_decay());
run(test_iml_spread_api());
std::cout << "=== Results: " << passed << " passed, " << failed << " failed ===\n\n";

15
vcv/Makefile Normal file
View file

@ -0,0 +1,15 @@
RACK_DIR ?= $(HOME)/.local/share/Rack2/Rack-SDK
FLAGS += -std=c++20
FLAGS += -I$(RACK_DIR)/include -I$(RACK_DIR)/dep/include
FLAGS += -I../nisps-core/include
SOURCES += src/plugin.cpp
SOURCES += src/MEMLNaut.cpp
DISTRIBUTABLES += res
include $(RACK_DIR)/plugin.mk
# Remove SDK's default c++11 to avoid conflicting flags
CXXFLAGS := $(filter-out -std=c++11,$(CXXFLAGS))

19
vcv/plugin.json Normal file
View file

@ -0,0 +1,19 @@
{
"slug": "MEMLNaut",
"name": "MEMLNaut",
"version": "0.1.0",
"license": "proprietary",
"brand": "MEMLNaut",
"author": "MEML",
"authorUrl": "https://musicallyembodiedml.github.io",
"pluginUrl": "https://github.com/MusicallyEmbodiedML/MEMLNaut-NISPS",
"sourceUrl": "https://github.com/MusicallyEmbodiedML/MEMLNaut-NISPS",
"modules": [
{
"slug": "MEMLNaut",
"name": "MEMLNaut",
"description": "Neural Interactive Shaping of Parameter Spaces — ML-powered CV mapper with RL feedback",
"tags": ["Controller", "Utility", "Random"]
}
]
}

5
vcv/res/MEMLNaut.svg Normal file
View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="203.2mm" height="128.5mm" viewBox="0 0 203.2 128.5">
<rect width="203.2" height="128.5" fill="#1a1a2e" />
<text x="101.6" y="12" text-anchor="middle" fill="#e0e0e0" font-family="monospace" font-size="6">MEMLNaut</text>
<text x="101.6" y="120" text-anchor="middle" fill="#808080" font-family="monospace" font-size="3">NISPS v0.1</text>
</svg>

After

Width:  |  Height:  |  Size: 395 B

58
vcv/src/MEMLNaut.cpp Normal file
View file

@ -0,0 +1,58 @@
#include "plugin.hpp"
#include <nisps/nisps.hpp>
struct MEMLNaut : Module {
enum ParamId {
PARAMS_LEN
};
enum InputId {
INPUT_X,
INPUT_Y,
INPUTS_LEN
};
enum OutputId {
OUTPUT_1, OUTPUT_2, OUTPUT_3, OUTPUT_4,
OUTPUT_5, OUTPUT_6, OUTPUT_7, OUTPUT_8,
OUTPUT_9, OUTPUT_10, OUTPUT_11, OUTPUT_12,
OUTPUTS_LEN
};
enum LightId {
LIGHTS_LEN
};
MEMLNaut() {
config(PARAMS_LEN, INPUTS_LEN, OUTPUTS_LEN, LIGHTS_LEN);
configInput(INPUT_X, "X");
configInput(INPUT_Y, "Y");
for (int i = 0; i < 12; i++) {
configOutput(OUTPUT_1 + i, string::f("Out %d", i + 1));
}
// Verify nisps-core headers integrate correctly
// (actual IML instance will be added in Phase 2)
static_assert(sizeof(nisps::IML<float>) > 0, "nisps::IML must be a complete type");
}
void process(const ProcessArgs& args) override {
// Empty — Phase 2 will wire up IML inference
}
};
struct MEMLNautWidget : ModuleWidget {
MEMLNautWidget(MEMLNaut* module) {
setModule(module);
setPanel(createPanel(asset::plugin(pluginInstance, "res/MEMLNaut.svg")));
// Inputs (left side)
addInput(createInputCentered<PJ301MPort>(mm2px(Vec(8.0, 20.0)), module, MEMLNaut::INPUT_X));
addInput(createInputCentered<PJ301MPort>(mm2px(Vec(8.0, 32.0)), module, MEMLNaut::INPUT_Y));
// Outputs (right side, 2 columns of 6)
for (int i = 0; i < 6; i++) {
addOutput(createOutputCentered<PJ301MPort>(mm2px(Vec(20.0, 20.0 + i * 12.0)), module, MEMLNaut::OUTPUT_1 + i));
addOutput(createOutputCentered<PJ301MPort>(mm2px(Vec(32.0, 20.0 + i * 12.0)), module, MEMLNaut::OUTPUT_1 + 6 + i));
}
}
};
Model* modelMEMLNaut = createModel<MEMLNaut, MEMLNautWidget>("MEMLNaut");

11
vcv/src/plugin.cpp Normal file
View file

@ -0,0 +1,11 @@
#include "plugin.hpp"
Plugin* pluginInstance;
extern Model* modelMEMLNaut;
void init(Plugin* p) {
pluginInstance = p;
p->addModel(modelMEMLNaut);
}

6
vcv/src/plugin.hpp Normal file
View file

@ -0,0 +1,6 @@
#pragma once
#include <rack.hpp>
using namespace rack;
extern Plugin* pluginInstance;