#include "plugin.hpp" #include #include #include #include #include #include static constexpr int NUM_ML_INPUTS = 2; static constexpr int NUM_ML_OUTPUTS = 12; static constexpr int MAX_ML_INPUTS = 8; // ── Background job types ────────────────────────────────────────────── enum class JobType { Train, Perturb }; struct Job { JobType type; float noiseLevel; float spread; }; // ── MEMLNaut Module ─────────────────────────────────────────────────── struct MEMLNaut : Module { enum ParamId { PARAM_SPREAD, PARAM_RATE, PARAM_RAND, PARAM_THUMBS_UP, PARAM_THUMBS_DOWN, PARAM_LEARN, PARAM_CLEAR, PARAM_ATTEN_1, // 12 attenuverters PARAM_ATTEN_LAST = PARAM_ATTEN_1 + NUM_ML_OUTPUTS - 1, PARAMS_LEN }; enum InputId { INPUT_X, INPUT_Y, // IN 3–8 reserved for configurable inputs (future) INPUT_SPREAD_CV, INPUT_LEARN_GATE, INPUT_TRIG_POS, INPUT_TRIG_NEG, 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, OUTPUT_MEAN, OUTPUT_STD, OUTPUT_DELTA, OUTPUT_NOVELTY, OUTPUT_CONFIDENCE, OUTPUTS_LEN }; enum LightId { LIGHT_LEARN, LIGHT_TRAINING, LIGHT_OUT_1, // 12 output LEDs LIGHT_OUT_LAST = LIGHT_OUT_1 + NUM_ML_OUTPUTS - 1, LIGHTS_LEN }; // ── ML Engine ───────────────────────────────────────────────────── nisps::IML iml{NUM_ML_INPUTS, NUM_ML_OUTPUTS, {16, 24, 16}}; // ── State ───────────────────────────────────────────────────────── float noiseLevel = 0.1f; float cachedOutputs[NUM_ML_OUTPUTS] = {}; float prevOutputs[NUM_ML_OUTPUTS] = {}; float slewOutputs[NUM_ML_OUTPUTS] = {}; float crossfadeProgress = 1.f; // 1 = no crossfade active float slewMs = 10.f; int sampleCounter = 0; bool outputRangeUnipolar[NUM_ML_OUTPUTS] = {}; // true = 0-10V, false = ±5V bool inputRangeUnipolar[MAX_ML_INPUTS] = {}; // true = 0-10V, false = ±5V float clearHoldTime = 0.f; // ── Triggers ────────────────────────────────────────────────────── dsp::BooleanTrigger randTrigger; dsp::BooleanTrigger thumbsUpTrigger; dsp::BooleanTrigger thumbsDownTrigger; dsp::SchmittTrigger trigPosTrigger; dsp::SchmittTrigger trigNegTrigger; // ── Background thread ───────────────────────────────────────────── std::thread workerThread; std::mutex jobMutex; std::condition_variable jobCv; std::atomic shouldStop{false}; std::atomic swapReady{false}; std::atomic isTraining{false}; Job currentJob{}; bool hasJob = false; // Pending examples buffer (for rapid feedback queueing) std::vector pendingInputs; std::vector pendingOutputs; bool hasPending = false; MEMLNaut() { config(PARAMS_LEN, INPUTS_LEN, OUTPUTS_LEN, LIGHTS_LEN); // Knobs configParam(PARAM_SPREAD, 0.f, 1.f, 0.6f, "Spread", "%", 0.f, 100.f); configParam(PARAM_RATE, 0.f, 1.f, 0.5f, "Inference rate"); // Buttons configButton(PARAM_RAND, "Randomize weights"); configButton(PARAM_THUMBS_UP, "Thumbs up (+)"); configButton(PARAM_THUMBS_DOWN, "Thumbs down (−)"); configSwitch(PARAM_LEARN, 0.f, 1.f, 0.f, "Learn enable", {"Off", "On"}); configButton(PARAM_CLEAR, "Clear (long-press)"); // Attenuverters for (int i = 0; i < NUM_ML_OUTPUTS; i++) { configParam(PARAM_ATTEN_1 + i, -1.f, 1.f, 1.f, string::f("Out %d attenuverter", i + 1), "%", 0.f, 100.f); } // Inputs configInput(INPUT_X, "X"); configInput(INPUT_Y, "Y"); configInput(INPUT_SPREAD_CV, "Spread CV"); configInput(INPUT_LEARN_GATE, "Learn gate"); configInput(INPUT_TRIG_POS, "+ trigger"); configInput(INPUT_TRIG_NEG, "− trigger"); // Outputs for (int i = 0; i < NUM_ML_OUTPUTS; i++) { configOutput(OUTPUT_1 + i, string::f("Out %d", i + 1)); } configOutput(OUTPUT_MEAN, "Mean"); configOutput(OUTPUT_STD, "Std deviation"); configOutput(OUTPUT_DELTA, "Delta (rate of change)"); configOutput(OUTPUT_NOVELTY, "Novelty"); configOutput(OUTPUT_CONFIDENCE, "Confidence"); // Init ranges to unipolar for (int i = 0; i < NUM_ML_OUTPUTS; i++) outputRangeUnipolar[i] = true; for (int i = 0; i < MAX_ML_INPUTS; i++) inputRangeUnipolar[i] = true; // Randomize with default spread iml.set_mode(nisps::IML::Mode::Training); iml.randomise_weights(0.6f); iml.set_mode(nisps::IML::Mode::Inference); // Start worker thread workerThread = std::thread(&MEMLNaut::workerLoop, this); } ~MEMLNaut() { shouldStop.store(true); jobCv.notify_one(); if (workerThread.joinable()) { workerThread.join(); } } // ── Background worker ───────────────────────────────────────────── void workerLoop() { while (!shouldStop.load()) { Job job; { std::unique_lock lock(jobMutex); jobCv.wait(lock, [&] { return hasJob || shouldStop.load(); }); if (shouldStop.load()) break; job = currentJob; hasJob = false; } isTraining.store(true); // NOTE: Full double-buffering requires IML weight get/set API // (filed for follow-up). For now, training and perturbation // operate directly on iml. The audio thread reads outputs // (which are a cached copy), so this is safe for outputs // but not for concurrent inference. The RATE decimation // means inference doesn't run every sample, reducing // collision probability. Proper double-buffering is Phase 3 // follow-up work. if (job.type == JobType::Train) { iml.set_mode(nisps::IML::Mode::Training); iml.set_mode(nisps::IML::Mode::Inference); } else { iml.move_weights(job.noiseLevel, job.spread); } swapReady.store(true); isTraining.store(false); // Check for pending work { std::unique_lock lock(jobMutex); if (hasPending) { hasPending = false; hasJob = true; // Pending becomes current job (already set) } } } } void enqueueJob(JobType type, float noise = 0.f, float spread = 0.f) { std::unique_lock lock(jobMutex); if (hasJob || isTraining.load()) { // Queue as pending (max depth 1, latest wins) hasPending = true; currentJob = {type, noise, spread}; } else { currentJob = {type, noise, spread}; hasJob = true; jobCv.notify_one(); } } // ── Helper: read spread with CV modulation ──────────────────────── float getSpread() { float spread = params[PARAM_SPREAD].getValue(); if (inputs[INPUT_SPREAD_CV].isConnected()) { spread += inputs[INPUT_SPREAD_CV].getVoltage() / 10.f; } return clamp(spread, 0.f, 1.f); } // ── Helper: is learning enabled ─────────────────────────────────── bool isLearnEnabled() { bool toggle = params[PARAM_LEARN].getValue() > 0.5f; bool gate = inputs[INPUT_LEARN_GATE].isConnected() && inputs[INPUT_LEARN_GATE].getVoltage() > 1.f; return toggle || gate; } // ── Helper: normalize input CV ──────────────────────────────────── float normalizeInput(int inputId, int rangeIdx) { float v = inputs[inputId].getVoltage(); if (inputRangeUnipolar[rangeIdx]) { return clamp(v / 10.f, 0.f, 1.f); } else { return clamp((v + 5.f) / 10.f, 0.f, 1.f); } } // ── Helper: scale output to CV ──────────────────────────────────── float outputToVoltage(float val01, int outIdx) { float atten = params[PARAM_ATTEN_1 + outIdx].getValue(); if (outputRangeUnipolar[outIdx]) { return val01 * 10.f * atten; } else { return (val01 - 0.5f) * 10.f * atten; } } // ── Process ─────────────────────────────────────────────────────── void process(const ProcessArgs& args) override { float spread = getSpread(); bool learn = isLearnEnabled(); // ── Learn LED ───────────────────────────────────────────────── lights[LIGHT_LEARN].setBrightness(learn ? 1.f : 0.f); lights[LIGHT_TRAINING].setBrightness(isTraining.load() ? 1.f : 0.f); // ── Handle weight change notification from background thread ── if (swapReady.load()) { swapReady.store(false); // Start crossfade: save current outputs as "old" for (int i = 0; i < NUM_ML_OUTPUTS; i++) { prevOutputs[i] = cachedOutputs[i]; } crossfadeProgress = 0.f; } // ── Handle RAND button ──────────────────────────────────────── if (randTrigger.process(params[PARAM_RAND].getValue() > 0.f)) { iml.set_mode(nisps::IML::Mode::Training); iml.randomise_weights(spread); iml.set_mode(nisps::IML::Mode::Inference); } // ── Handle CLEAR button (long-press ~1s) ───────────────────── if (params[PARAM_CLEAR].getValue() > 0.f) { clearHoldTime += args.sampleTime; if (clearHoldTime >= 1.f) { iml.set_mode(nisps::IML::Mode::Training); iml.clear_dataset(); iml.randomise_weights(spread); iml.set_mode(nisps::IML::Mode::Inference); noiseLevel = 0.1f; clearHoldTime = 0.f; } } else { clearHoldTime = 0.f; } // ── Handle RL feedback (only when learning) ─────────────────── if (learn) { bool thumbsUp = thumbsUpTrigger.process( params[PARAM_THUMBS_UP].getValue() > 0.f); bool trigPos = trigPosTrigger.process( inputs[INPUT_TRIG_POS].getVoltage()); if (thumbsUp || trigPos) { // Capture current input → output as training example const float* curOuts = iml.get_outputs(); float curInputs[2] = { normalizeInput(INPUT_X, 0), normalizeInput(INPUT_Y, 1) }; iml.set_mode(nisps::IML::Mode::Training); iml.add_example(curInputs, 2, curOuts, NUM_ML_OUTPUTS); iml.set_mode(nisps::IML::Mode::Inference); // Enqueue training enqueueJob(JobType::Train); // Decay noise noiseLevel *= 0.97f; } bool thumbsDown = thumbsDownTrigger.process( params[PARAM_THUMBS_DOWN].getValue() > 0.f); bool trigNeg = trigNegTrigger.process( inputs[INPUT_TRIG_NEG].getVoltage()); if (thumbsDown || trigNeg) { float noiseCap = 0.3f * (1.f - spread) + 0.05f * spread; noiseLevel = std::min(noiseLevel * 1.5f, noiseCap); // Perturb directly (simple for now — enqueue for full thread safety later) iml.move_weights(noiseLevel, spread); } } // ── Inference rate decimation ───────────────────────────────── float rate = params[PARAM_RATE].getValue(); // Map 0→1 to period: 256 samples (block rate) → 1 sample (audio rate) // Exponential mapping for perceptual linearity int period = std::max(1, (int)(256.f * std::pow(1.f / 256.f, rate))); sampleCounter++; bool runInference = (sampleCounter >= period); if (runInference) { sampleCounter = 0; // Read and normalize inputs float x = normalizeInput(INPUT_X, 0); float y = normalizeInput(INPUT_Y, 1); iml.set_input(0, x); iml.set_input(1, y); iml.process(); const float* outs = iml.get_outputs(); for (int i = 0; i < NUM_ML_OUTPUTS; i++) { cachedOutputs[i] = outs[i]; } } // ── Crossfade after weight swap ─────────────────────────────── float effectiveOutputs[NUM_ML_OUTPUTS]; if (crossfadeProgress < 1.f) { float slewSamples = std::max(1.f, slewMs * 0.001f * args.sampleRate); crossfadeProgress += 1.f / slewSamples; if (crossfadeProgress > 1.f) crossfadeProgress = 1.f; for (int i = 0; i < NUM_ML_OUTPUTS; i++) { effectiveOutputs[i] = prevOutputs[i] + crossfadeProgress * (cachedOutputs[i] - prevOutputs[i]); } } else { for (int i = 0; i < NUM_ML_OUTPUTS; i++) { effectiveOutputs[i] = cachedOutputs[i]; } } // ── Interpolate between inference steps (slew) ──────────────── if (!runInference && period > 1) { float alpha = (float)sampleCounter / (float)period; for (int i = 0; i < NUM_ML_OUTPUTS; i++) { slewOutputs[i] += alpha * (effectiveOutputs[i] - slewOutputs[i]); } } else { for (int i = 0; i < NUM_ML_OUTPUTS; i++) { slewOutputs[i] = effectiveOutputs[i]; } } // ── Write raw outputs with attenuverters ────────────────────── for (int i = 0; i < NUM_ML_OUTPUTS; i++) { outputs[OUTPUT_1 + i].setVoltage(outputToVoltage(slewOutputs[i], i)); lights[LIGHT_OUT_1 + i].setBrightness(slewOutputs[i]); } // ── Derived outputs ─────────────────────────────────────────── // Mean float mean = 0.f; for (int i = 0; i < NUM_ML_OUTPUTS; i++) mean += slewOutputs[i]; mean /= NUM_ML_OUTPUTS; outputs[OUTPUT_MEAN].setVoltage(mean * 10.f); // STD float variance = 0.f; for (int i = 0; i < NUM_ML_OUTPUTS; i++) { float d = slewOutputs[i] - mean; variance += d * d; } float stddev = std::sqrt(variance / NUM_ML_OUTPUTS); outputs[OUTPUT_STD].setVoltage(stddev * 10.f); // Delta (L2 norm of change) static float lastOutputs[NUM_ML_OUTPUTS] = {}; float delta = 0.f; for (int i = 0; i < NUM_ML_OUTPUTS; i++) { float d = slewOutputs[i] - lastOutputs[i]; delta += d * d; lastOutputs[i] = slewOutputs[i]; } 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 } // ── Serialization ───────────────────────────────────────────────── json_t* dataToJson() override { json_t* root = json_object(); json_object_set_new(root, "noiseLevel", json_real(noiseLevel)); json_object_set_new(root, "slewMs", json_real(slewMs)); // Output ranges json_t* outRanges = json_array(); for (int i = 0; i < NUM_ML_OUTPUTS; i++) { json_array_append_new(outRanges, json_boolean(outputRangeUnipolar[i])); } json_object_set_new(root, "outputRangeUnipolar", outRanges); // Input ranges json_t* inRanges = json_array(); for (int i = 0; i < MAX_ML_INPUTS; i++) { json_array_append_new(inRanges, json_boolean(inputRangeUnipolar[i])); } json_object_set_new(root, "inputRangeUnipolar", inRanges); return root; } void dataFromJson(json_t* root) override { json_t* j; if ((j = json_object_get(root, "noiseLevel"))) noiseLevel = json_real_value(j); if ((j = json_object_get(root, "slewMs"))) slewMs = json_real_value(j); 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++) { outputRangeUnipolar[i] = json_boolean_value(json_array_get(outRanges, i)); } } 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)); } } } }; // ── NanoVG Bar Graph Display ────────────────────────────────────────── struct MEMLNautDisplay : LedDisplay { MEMLNaut* module = nullptr; void drawLayer(const DrawArgs& args, int layer) override { if (layer != 1 || !module) return; nvgSave(args.vg); float w = box.size.x; float h = box.size.y; float barW = (w - 4.f) / NUM_ML_OUTPUTS; float margin = 2.f; // Background nvgBeginPath(args.vg); nvgRect(args.vg, 0, 0, w, h); nvgFillColor(args.vg, nvgRGB(0x10, 0x10, 0x18)); nvgFill(args.vg); // Output bars for (int i = 0; i < NUM_ML_OUTPUTS; i++) { float val = module->slewOutputs[i]; float barH = val * (h - 16.f); // Color: hue based on output index float hue = (float)i / NUM_ML_OUTPUTS; NVGcolor color = nvgHSLA(hue, 0.7f, 0.5f, 200); nvgBeginPath(args.vg); nvgRect(args.vg, margin + i * barW, h - 8.f - barH, barW - 1.f, barH); nvgFillColor(args.vg, color); nvgFill(args.vg); } // Status text nvgFontSize(args.vg, 8.f); nvgFillColor(args.vg, nvgRGB(0xa0, 0xa0, 0xa0)); nvgTextAlign(args.vg, NVG_ALIGN_LEFT | NVG_ALIGN_TOP); char buf[64]; snprintf(buf, sizeof(buf), "N:%.3f", module->noiseLevel); nvgText(args.vg, 2.f, 1.f, buf, nullptr); if (module->isTraining.load()) { nvgFillColor(args.vg, nvgRGB(0xff, 0xa0, 0x00)); nvgText(args.vg, w - 24.f, 1.f, "TRAIN", nullptr); } nvgRestore(args.vg); } }; // ── Widget ──────────────────────────────────────────────────────────── struct MEMLNautWidget : ModuleWidget { MEMLNautWidget(MEMLNaut* module) { setModule(module); setPanel(createPanel(asset::plugin(pluginInstance, "res/MEMLNaut.svg"))); float col1 = 8.f; // left column float col2 = 20.f; // center-left float col3 = 32.f; // center-right // float col4 = 44.f; // right column (for wide panel) float y = 14.f; // ── Display ─────────────────────────────────────────────────── MEMLNautDisplay* display = createWidget(mm2px(Vec(2.f, y))); display->box.size = mm2px(Vec(36.f, 18.f)); display->module = module; addChild(display); y += 22.f; // ── SPREAD + RATE knobs ─────────────────────────────────────── addParam(createParamCentered(mm2px(Vec(col1, y)), module, MEMLNaut::PARAM_SPREAD)); addInput(createInputCentered(mm2px(Vec(col2, y)), module, MEMLNaut::INPUT_SPREAD_CV)); addParam(createParamCentered(mm2px(Vec(col3, y)), module, MEMLNaut::PARAM_RATE)); y += 10.f; // ── Buttons row: + − LEARN RAND CLEAR ──────────────────────── addParam(createParamCentered(mm2px(Vec(col1 - 2.f, y)), module, MEMLNaut::PARAM_THUMBS_UP)); addParam(createParamCentered(mm2px(Vec(col1 + 6.f, y)), module, MEMLNaut::PARAM_THUMBS_DOWN)); addParam(createParamCentered(mm2px(Vec(col2 + 2.f, y)), module, MEMLNaut::PARAM_LEARN)); addChild(createLightCentered>(mm2px(Vec(col2 + 2.f, y - 4.f)), module, MEMLNaut::LIGHT_LEARN)); addParam(createParamCentered(mm2px(Vec(col3, y)), module, MEMLNaut::PARAM_RAND)); addParam(createParamCentered(mm2px(Vec(col3 + 8.f, y)), module, MEMLNaut::PARAM_CLEAR)); addChild(createLightCentered>(mm2px(Vec(col3 + 8.f, y - 4.f)), module, MEMLNaut::LIGHT_TRAINING)); y += 10.f; // ── Trigger / gate inputs ───────────────────────────────────── addInput(createInputCentered(mm2px(Vec(col1, y)), module, MEMLNaut::INPUT_X)); addInput(createInputCentered(mm2px(Vec(col2, y)), module, MEMLNaut::INPUT_Y)); addInput(createInputCentered(mm2px(Vec(col3, y)), module, MEMLNaut::INPUT_LEARN_GATE)); y += 8.f; addInput(createInputCentered(mm2px(Vec(col1, y)), module, MEMLNaut::INPUT_TRIG_POS)); addInput(createInputCentered(mm2px(Vec(col2, y)), module, MEMLNaut::INPUT_TRIG_NEG)); y += 10.f; // ── Outputs: 3 columns of 4, with attenuverter + LED + jack ── for (int i = 0; i < NUM_ML_OUTPUTS; i++) { int col = i % 3; int row = i / 3; float ox = 6.f + col * 13.f; float oy = y + row * 9.f; addParam(createParamCentered(mm2px(Vec(ox, oy)), module, MEMLNaut::PARAM_ATTEN_1 + i)); addChild(createLightCentered>(mm2px(Vec(ox + 4.5f, oy)), module, MEMLNaut::LIGHT_OUT_1 + i)); addOutput(createOutputCentered(mm2px(Vec(ox + 9.f, oy)), module, MEMLNaut::OUTPUT_1 + i)); } y += 4 * 9.f + 2.f; // ── Derived outputs ─────────────────────────────────────────── float dox = 4.f; addOutput(createOutputCentered(mm2px(Vec(dox, y)), module, MEMLNaut::OUTPUT_MEAN)); addOutput(createOutputCentered(mm2px(Vec(dox + 8.f, y)), module, MEMLNaut::OUTPUT_STD)); addOutput(createOutputCentered(mm2px(Vec(dox + 16.f, y)), module, MEMLNaut::OUTPUT_DELTA)); addOutput(createOutputCentered(mm2px(Vec(dox + 24.f, y)), module, MEMLNaut::OUTPUT_NOVELTY)); addOutput(createOutputCentered(mm2px(Vec(dox + 32.f, y)), module, MEMLNaut::OUTPUT_CONFIDENCE)); } void appendContextMenu(Menu* menu) override { MEMLNaut* module = dynamic_cast(this->module); if (!module) return; menu->addChild(new MenuSeparator); menu->addChild(createMenuLabel("Output ranges")); for (int i = 0; i < NUM_ML_OUTPUTS; i++) { menu->addChild(createCheckMenuItem( string::f("Out %d: Bipolar (±5V)", i + 1), "", [=]() { return !module->outputRangeUnipolar[i]; }, [=]() { module->outputRangeUnipolar[i] = !module->outputRangeUnipolar[i]; } )); } menu->addChild(new MenuSeparator); menu->addChild(createMenuLabel("Input ranges")); std::string inputNames[] = {"X", "Y"}; for (int i = 0; i < NUM_ML_INPUTS; i++) { menu->addChild(createCheckMenuItem( string::f("Input %s: Bipolar (±5V)", inputNames[i].c_str()), "", [=]() { return !module->inputRangeUnipolar[i]; }, [=]() { module->inputRangeUnipolar[i] = !module->inputRangeUnipolar[i]; } )); } menu->addChild(new MenuSeparator); menu->addChild(createMenuLabel("Slew")); menu->addChild(createSubmenuItem("Output slew", string::f("%.0f ms", module->slewMs), [=](Menu* childMenu) { for (float ms : {0.f, 5.f, 10.f, 20.f, 50.f, 100.f}) { childMenu->addChild(createCheckMenuItem( string::f("%.0f ms", ms), "", [=]() { return module->slewMs == ms; }, [=]() { module->slewMs = ms; } )); } })); } }; Model* modelMEMLNaut = createModel("MEMLNaut");