diff --git a/nisps-core/include/nisps/iml.hpp b/nisps-core/include/nisps/iml.hpp index 80a36d6..6b9a7b9 100644 --- a/nisps-core/include/nisps/iml.hpp +++ b/nisps-core/include/nisps/iml.hpp @@ -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; } diff --git a/nisps-core/include/nisps/iml_impl.hpp b/nisps-core/include/nisps/iml_impl.hpp index b5fd90b..6458208 100644 --- a/nisps-core/include/nisps/iml_impl.hpp +++ b/nisps-core/include/nisps/iml_impl.hpp @@ -162,6 +162,33 @@ void IML::randomise_weights() { } } +template +void IML::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 input_with_bias = input_state_; + input_with_bias.push_back(static_cast(1.0)); + std::vector output(n_outputs_); + mlp_->GetOutput(input_with_bias, &output); + output_state_ = output; + + log("Weights randomised (spread)."); + } +} + +template +void IML::move_weights(Float speed, Float spread) { + mlp_->MoveWeightsSpread(speed, spread); + + // Run inference to show effect of perturbation + input_updated_ = true; + process(); +} + template void IML::train() { // Restore weights if they were randomised diff --git a/nisps-core/include/nisps/mlp.hpp b/nisps-core/include/nisps/mlp.hpp index 279979b..cfa6dc7 100644 --- a/nisps-core/include/nisps/mlp.hpp +++ b/nisps-core/include/nisps/mlp.hpp @@ -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 * diff --git a/nisps-core/include/nisps/mlp_impl.hpp b/nisps-core/include/nisps/mlp_impl.hpp index adda712..6d17d3b 100644 --- a/nisps-core/include/nisps/mlp_impl.hpp +++ b/nisps-core/include/nisps/mlp_impl.hpp @@ -22,6 +22,7 @@ #include #include #include +#include #include // #define SAFE_MODE @@ -807,6 +808,24 @@ void MLP::DrawWeights(float scale) // assert(m_layers[0].m_nodes[0].m_weights[0] != before); } +template +void MLP::DrawWeightsSpread(T spread) { + utils::gen_rand gen; + + for (size_t n = 0; n < m_layers.size(); n++) { + const size_t fanIn = m_layers_nodes[n]; + const T xavierScale = static_cast(1.0) / std::sqrt(static_cast(fanIn)); + const T scale = static_cast(1.0) * (static_cast(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(0); + } + } +} + template void MLP::MoveWeights(T speed) { @@ -830,6 +849,32 @@ void MLP::MoveWeights(T speed) assert(m_layers[0].m_nodes[0].m_weights[0] != before); } +template +void MLP::MoveWeightsSpread(T speed, T spread) { + const T decay = static_cast(1.0) - static_cast(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(1.0) / std::sqrt(static_cast(fanIn)); + const T layerScale = static_cast(1.0) * (static_cast(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(0); + for (int i = 0; i < 3; i++) { + accum += static_cast(rand()) / static_cast(RAND_MAX) * static_cast(2) - static_cast(1); + } + m_layers[n].m_nodes[k].m_weights[j] += static_cast(3) * accum * speed * layerScale; + } + } + } +} + template void MLP::InitXavier() { for(auto & layer : m_layers) { diff --git a/nisps-core/test/main.cpp b/nisps-core/test/main.cpp index cde05c5..36419a8 100644 --- a/nisps-core/test/main.cpp +++ b/nisps-core/test/main.cpp @@ -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 layers = {3, 8, 4}; + std::vector activs = { + nisps::ACTIVATION_FUNCTIONS::RELU, + nisps::ACTIVATION_FUNCTIONS::SIGMOID + }; + nisps::MLP 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 layers = {3, 8, 4}; + std::vector activs = { + nisps::ACTIVATION_FUNCTIONS::RELU, + nisps::ACTIVATION_FUNCTIONS::SIGMOID + }; + nisps::MLP 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 layers = {3, 8, 4}; + std::vector activs = { + nisps::ACTIVATION_FUNCTIONS::RELU, + nisps::ACTIVATION_FUNCTIONS::SIGMOID + }; + nisps::MLP 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 layers = {3, 8, 4}; + std::vector activs = { + nisps::ACTIVATION_FUNCTIONS::RELU, + nisps::ACTIVATION_FUNCTIONS::SIGMOID + }; + nisps::MLP 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 iml(2, 4, {8}); + iml.set_logger(log_callback); + iml.set_mode(nisps::IML::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"; diff --git a/vcv/Makefile b/vcv/Makefile new file mode 100644 index 0000000..d715a9d --- /dev/null +++ b/vcv/Makefile @@ -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)) diff --git a/vcv/plugin.json b/vcv/plugin.json new file mode 100644 index 0000000..02d98ab --- /dev/null +++ b/vcv/plugin.json @@ -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"] + } + ] +} diff --git a/vcv/res/MEMLNaut.svg b/vcv/res/MEMLNaut.svg new file mode 100644 index 0000000..2eaf451 --- /dev/null +++ b/vcv/res/MEMLNaut.svg @@ -0,0 +1,5 @@ + + + MEMLNaut + NISPS v0.1 + diff --git a/vcv/src/MEMLNaut.cpp b/vcv/src/MEMLNaut.cpp new file mode 100644 index 0000000..fe5c9a2 --- /dev/null +++ b/vcv/src/MEMLNaut.cpp @@ -0,0 +1,58 @@ +#include "plugin.hpp" +#include + +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) > 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(mm2px(Vec(8.0, 20.0)), module, MEMLNaut::INPUT_X)); + addInput(createInputCentered(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(mm2px(Vec(20.0, 20.0 + i * 12.0)), module, MEMLNaut::OUTPUT_1 + i)); + addOutput(createOutputCentered(mm2px(Vec(32.0, 20.0 + i * 12.0)), module, MEMLNaut::OUTPUT_1 + 6 + i)); + } + } +}; + +Model* modelMEMLNaut = createModel("MEMLNaut"); diff --git a/vcv/src/plugin.cpp b/vcv/src/plugin.cpp new file mode 100644 index 0000000..72010d4 --- /dev/null +++ b/vcv/src/plugin.cpp @@ -0,0 +1,11 @@ +#include "plugin.hpp" + +Plugin* pluginInstance; + +extern Model* modelMEMLNaut; + +void init(Plugin* p) { + pluginInstance = p; + + p->addModel(modelMEMLNaut); +} diff --git a/vcv/src/plugin.hpp b/vcv/src/plugin.hpp new file mode 100644 index 0000000..ded005b --- /dev/null +++ b/vcv/src/plugin.hpp @@ -0,0 +1,6 @@ +#pragma once +#include + +using namespace rack; + +extern Plugin* pluginInstance;