#include #include "../utils/sharedMem.hpp" // Required for READ_VOLATILE, sharedMem constants and PERIODIC_DEBUG #include // Required for Serial, millis, delay #include "../hardware/memlnaut/MEMLNaut.hpp" // Required for MEMLNaut::Instance() // display.hpp is included via InterfaceRL.hpp inline float euclideanDistance(const std::vector& a, const std::vector& b) { float sum = 0.0f; for (size_t i = 0; i < a.size(); ++i) { float diff = a[i] - b[i]; sum += diff * diff; } return sqrtf(sum); } // Protected helper method implementations template void InterfaceRL::_perform_like_action() { static std::vector likemsgs = { "Wow, incredible", "Awesome", "That's amazing", "Unbelievable+", "I love it!!", "More of this", "Yes!!!!", "A-M-A-Z-I-N-G", "Keep going!", "In flow", "I believe in you", "Absolutely brilliant!", "This is perfection!", "Stunning work!", "Pure genius!", "Keep shining!", "Fantastic!", "Incredible vibes!", "Love this journey!", "Super cool!" }; String msg = likemsgs[rand() % likemsgs.size()]; this->storeExperience(1.f, controlInput, action); if (nnOutputsGraphView) nnOutputsGraphView->setLastAction("yes"); DEBUG_PRINTLN(msg); if (msgView) msgView->post(msg); } template void InterfaceRL::_perform_dislike_action() { static std::vector dislikemsgs = { "oh no!", "Get rid of this sound", "Why even bother?", "New sound please!", "No, please no!!!", "Thumbs down", "I'm so sorry", "I'm trying my hardest...", "I'm doing the best I can", "Learning...", "I'll try to do better.", "Still figuring things out.", "Thanks for the feedback.", "Working on it!", "Oops, my bad.", "Learning from this.", "I'll adjust, promise.", "Noted", "Rearranging", "Let's move on!" }; String msg = dislikemsgs[rand() % dislikemsgs.size()]; this->storeExperience(-1.f, controlInput, action); if (nnOutputsGraphView) nnOutputsGraphView->setLastAction("no"); DEBUG_PRINTLN(msg); if (msgView) msgView->post(msg); } template void InterfaceRL::_perform_randomiseRL_action() { this->randomiseTheNetwork(); this->generateAction(true); if (nnOutputsGraphView) nnOutputsGraphView->setLastAction("scramble"); DEBUG_PRINTLN("Randomising networks"); if (msgView) msgView->post("Scrambling the network"); } // Public trigger methods — called from ISR context, so only set a flag. // The actual action runs in the main-loop loopCallback before optimise(). template void InterfaceRL::trigger_like() { pendingLike_ = true; } template void InterfaceRL::trigger_dislike() { pendingDislike_ = true; } // void InterfaceRL::trigger_randomiseRL() { // _perform_randomiseRL_action(); // } template void InterfaceRL::setOptimiseDivisorInterf(float value) { size_t divisor = 1 + (value * 100); String msg; if (divisor > 90) { divisor = 999999; msg = "Optimisation paused"; }else{ msg = "Optimise every " + String(divisor) + " cycles"; } if (msgView) msgView->post(msg); this->setOptimiseDivisor(divisor); DEBUG_PRINTLN(msg); } template void InterfaceRL::bind_RL_interface(INPUT_MODES input_mode, bool joystick4D) { loadInputSource(); if (nnInputsGraphView) nnInputsGraphView->setNumDisplayBars(getActiveInputCount()); // Set up momentary switch callbacks MEMLNaut::Instance()->setMomA1Callback([this]() { if (MEMLNaut::Instance()->getMOMA1State()) { this->trigger_like(); } }); MEMLNaut::Instance()->setMomA2Callback([this]() { if (MEMLNaut::Instance()->getMOMA2State()) { this->trigger_dislike(); } }); MEMLNaut::Instance()->setMomB1Callback([this]() { if (MEMLNaut::Instance()->getMOMB1State()) { _perform_randomiseRL_action(); } }); // B2 held = momentary fast exploration (was: perturb network weights). The button ISR // only dispatches a callback on press, so start the jolt here and detect *release* by // polling getMOMB2State() in the loop callback below. MEMLNaut::Instance()->setMomB2Callback([this]() { if (MEMLNaut::Instance()->getMOMB2State()) { startJolt(); } }); // Always register joystick callbacks — they write to raw_joystick_ // (ignored by assembleInputs() when a non-joystick source is active) MEMLNaut::Instance()->setJoySWCallback([this](bool state) { if (state) { savedAction = action; actionBeingDragged = true; if (nnOutputsGraphView) nnOutputsGraphView->setLastAction("drag"); if (msgView) msgView->post("Where do you want it?"); } else { if (actionBeingDragged) { actionBeingDragged = false; pendingDragStore_ = true; if (nnOutputsGraphView) nnOutputsGraphView->setLastAction("drop"); if (msgView) msgView->post("Here!"); } } }); MEMLNaut::Instance()->setJoyXCallback([this](float value) { raw_joystick_[0] = value; newInput = true; }); MEMLNaut::Instance()->setJoyYCallback([this](float value) { raw_joystick_[1] = value; newInput = true; }); MEMLNaut::Instance()->setJoyZCallback([this](float value) { raw_joystick_[2] = value; newInput = true; }); MEMLNaut::Instance()->setADC3Callback([this](float value) { raw_joystick_[3] = value; newInput = true; }); MEMLNaut::Instance()->setTogB1Callback([this](bool state) { // scr_ref no longer captured directly if (state) { this->_forget_replay_mem_interf(); } }); MEMLNaut::Instance()->setTogA1Callback([this](bool state) { if (state) { savedAction = action; actionBeingDragged = true; if (nnOutputsGraphView) nnOutputsGraphView->setLastAction("drag"); if (msgView) msgView->post("Where do you want it?"); } else { if (actionBeingDragged) { actionBeingDragged = false; pendingDragStore_ = true; // deferred: storeExperience in loopCallback if (nnOutputsGraphView) nnOutputsGraphView->setLastAction("drop"); if (msgView) msgView->post("Here!"); } } }); MEMLNaut::Instance()->setRVX1Callback( rvX1Override ? rvX1Override : RVCallback([this](float value) { this->setRewardScaleInterf(value); })); MEMLNaut::Instance()->setRVY1Callback( rvY1Override ? rvY1Override : RVCallback([this](float value) { this->setLRScale(value); })); MEMLNaut::Instance()->setRVZ1Callback( rvZ1Override ? rvZ1Override : RVCallback([this](float value) { setNoiseLevel(value); })); // Set up loop callback MEMLNaut::Instance()->setLoopCallback([this]() { // Jolt release: the B2 ISR only fires on press, so poll the live pin to end it. if (joltActive_ && !MEMLNaut::Instance()->getMOMB2State()) { stopJolt(); } // Process deferred actions from ISR before touching replayMem in optimise if (pendingLike_) { pendingLike_ = false; _perform_like_action(); } if (pendingDislike_) { pendingDislike_ = false; _perform_dislike_action(); } if (pendingDragStore_) { pendingDragStore_ = false; this->storeExperience(1.f, controlInput, savedAction); if (nnOutputsGraphView) { size_t pos = 0; for (size_t i = 0; i < replayMem.size(); i++) if (replayMem.getItem(i).reward > 0.f) pos++; nnOutputsGraphView->setMemoryCounts(pos, replayMem.size() - pos); } } // Apply a deferred input-source change off the rotary ISR (heap/SPI/flash IO). // Apply in-memory now for a responsive UI, but debounce the flash write: scrolling // through sources would otherwise stall XIP per detent and blank the display. if (pendingInputSourceChange_) { pendingInputSourceChange_ = false; setInputSource(pendingInputSource_, false); inputSourceSaveDueMs_ = millis() + kInputSourceSaveDelayMs; } if (inputSourceSaveDueMs_ != 0 && millis() >= inputSourceSaveDueMs_) { inputSourceSaveDueMs_ = 0; saveInputSource(); // persist once the selection has settled } uint32_t save = spin_lock_blocking(mlpActive); if (joltActive_) { this->stepJolt(); // B2 held: morph weights, learning paused } else { // Ramp learning rate back up after a jolt (0 -> full over ~5s) so training // doesn't immediately drag the net off the jolted sound. if (joltLRRamp_ < 1.f) joltLRRamp_ = std::min(1.f, joltLRRamp_ + kJoltLRRampStep); this->optimiseSometimes(); } this->generateAction(); spin_unlock(mlpActive, save); }); } template void InterfaceRL::setRewardScaleInterf(float value) { this->setRewardScale(value); String msg = "Reward scale: " + String(value); if (msgView) msgView->post(msg); } template void InterfaceRL::_forget_replay_mem_interf() { this->forgetMemory(); if (nnOutputsGraphView) nnOutputsGraphView->setLastAction("forget"); static std::vector forgetmsgs = { "Erasing my memory", "Forgetting everything", "Memory wiped","Thank you Susan?", "Starting afresh", "Why care about the past?","Living in the moment" }; String msg = forgetmsgs[rand() % forgetmsgs.size()]; if (msgView) msgView->post(msg); } template void InterfaceRL::bindMIDI(std::shared_ptr midi_interf, bool enableFootcontroller) { if (midi_interf) { midi_interf->SetCCCallback([this, enableFootcontroller] (uint8_t cc_number, uint8_t cc_value) { // Route CC1-CC8 to raw_midi_ when a MIDI input source is active bool is_midi_source = (input_source_ == INPUT_SOURCE::MIDI_1CC || input_source_ == INPUT_SOURCE::MIDI_3CC || input_source_ == INPUT_SOURCE::MIDI_8CC); if (is_midi_source && cc_number >= 1 && cc_number <= 8) { raw_midi_[cc_number - 1] = static_cast(cc_value) / 127.f; newInput = true; return; } if (!enableFootcontroller) return; Serial.printf("MIDI CC %d: %d\n", cc_number, cc_value); switch(cc_number) { case 1: { if (cc_value > 0) this->_perform_like_action(); break; } case 2: { if (cc_value > 0) this->_perform_dislike_action(); break; } case 3: { if (cc_value > 0) this->_perform_randomiseRL_action(); break; } case 4: { if (cc_value > 0) this->_forget_replay_mem_interf(); break; } case 5: { if (midi5cb) { midi5cb(cc_value); }else{ static constexpr float cc_scale = 1.f/(127.f-20.f); // Less than 20 on cc_value is considered 0 // scale [20..127] to [0.0, 1.0] if (cc_value < 20) { cc_value = 0; } else { cc_value -= 20; // Shift range to [0, 107] } float scale = static_cast(cc_value) * cc_scale; //this->setRewardScaleInterf(scale); this->setNoiseLevel(scale); } break; } case 6: { if (midi6cb) { midi6cb(cc_value); }else{ static constexpr float cc_scale = 1.f/(127.f-20.f); // Less than 20 on cc_value is considered 0 // scale [20..127] to [0.0, 1.0] if (cc_value < 20) { cc_value = 0; } else { cc_value -= 20; // Shift range to [0, 107] } float opt = static_cast(cc_value) * cc_scale; this->setOptimiseDivisorInterf(1.f - opt); } break; } }; }); } midi_ = midi_interf; if (ccSelectView && !ccSelectView->getSelectedCCs().empty()) { midi_->SetParamCCNumbers(ccSelectView->getSelectedCCs()); } } template void InterfaceRL::setup(size_t n_inputs, size_t n_outputs, bool addMessageView) { InterfaceBase::setup(n_inputs, n_outputs); mlpActive = spin_lock_init(spin_lock_claim_unused(true)); // The compile-time network fixes n_outputs == N_OUTPUTS; the runtime arg // is the active mode's kN_Params, which equals N_OUTPUTS by construction. (void)n_outputs; layers_nodes = { n_inputs, 16, 16, n_outputs }; controlInput.resize(layers_nodes[0]); action.resize(n_outputs, 0.5f); // Initialize action vector with default values mappingOutput.resize(n_outputs); //init networks — StaticMLP is a value member (fixed arch, all weights in // the static mode object: no heap). Just initialise its weights. // synthMapping.InitXavier(); synthMapping.RandomiseWeightsAndBiasesLin(-1.2f,0.9f, 0, 0.5); rewardScale = 1.0f; // Default reward scale // randomiseTheNetwork(); // Memory limit replayMem.setMemoryLimit(memoryLimit); ou_noises.reserve(n_outputs); for(size_t i=0; i < n_outputs; i++) { // OU(theta, mu, sigma, dt, x0). theta & dt set the *smoothness*: correlation // time ~= 1/(theta*dt) calls = 1/(0.02*0.004) = 12500 calls ~= 62 s at the 200 Hz // control rate, so the walk drifts in long smooth sweeps rather than per-frame // kicks. sigma (amplitude) starts at 0 and is set by the intensity knob via // setNoiseLevel()/setStationaryStd() on boot-sync. To make sweeps faster/coarser // raise dt; slower/smoother, lower it. ou_noises.push_back(std::make_unique(0.02f, 0.0f, 0.0f, kNoiseDt, 0.0f)); } itemsToRemove.reserve(replayMem.getMemoryLimit()); joltWeightLoc_.reserve(kJoltNumWeights); joltTarget_.reserve(kJoltNumWeights); // GUI if (!nnOutputsGraphView) { nnOutputsGraphView = std::make_shared("RL", n_outputs, 4, TFT_GREEN, 0.f, 1.f); } MEMLNaut::Instance()->disp->AddView(nnOutputsGraphView); nnInputsGraphView = std::make_shared("NN Inputs", n_inputs, 10, TFT_YELLOW, 0.f, 1.f); MEMLNaut::Instance()->disp->AddView(nnInputsGraphView); // memoryStoreModeView = std::make_shared("Mem Mode"); // MEMLNaut::Instance()->disp->AddView(memoryStoreModeView); // memoryStoreModeView->setOptions(memOptions); // memoryStoreModeView->setNewVoiceCallback([this](size_t idx) { // memoryStoreMode = static_cast(idx); // }); if (addMessageView) { msgView = std::make_shared("Messages"); MEMLNaut::Instance()->disp->AddView(msgView); } // 12 slots laid out 6 columns x 2 rows. Size the buttons to fill the screen: width // 10 + 6*43 + 5*10 gap = 318px (of 320); height 78 x 2 rows clears the message line. // Smaller font (2) so slot names fit the narrower buttons. fileSaveView = std::make_shared("Save Model", TFT_BLUE, kNumSlots, 43, 78, TFT_WHITE, std::vector{}, TFT_BLUE, 2 /* fontNum */); fileSaveView->SetOnSelectCallback([this](size_t id) { pendingSaveSlot = static_cast(id) - 1; nameInputView->reset(slotNames[pendingSaveSlot]); MEMLNaut::Instance()->disp->ShowDialog(nameInputView); }); MEMLNaut::Instance()->disp->AddView(fileSaveView); fileLoadView = std::make_shared("Load Model", TFT_PURPLE, kNumSlots, 43, 78, TFT_WHITE, std::vector{}, TFT_PURPLE, 2 /* fontNum */); fileLoadView->SetOnSelectCallback([this](size_t id) { int slotIdx = static_cast(id) - 1; String filename = (slotNames[slotIdx].length() > 0) ? slotNames[slotIdx] : String(id); fileLoadView->SetMessage("Loading " + filename); uint32_t save = spin_lock_blocking(mlpActive); if (MEMLNaut::Instance()->startSD()) { if (this->_load_RL_from_SD(filename)) { fileLoadView->SetMessage("Loaded " + filename); } else { fileLoadView->SetMessage("Failed to load model"); } MEMLNaut::Instance()->stopSD(); } else { fileLoadView->SetMessage("SD card error - is it inserted and formatted?"); } spin_unlock(mlpActive, save); }); MEMLNaut::Instance()->disp->AddView(fileLoadView); nameInputView = std::make_shared("Name"); nameInputView->setCallbacks( [this](const String& name) { if (pendingSaveSlot >= 0 && pendingSaveSlot < kNumSlots) { String displayName = (name.length() > 0) ? name : String(pendingSaveSlot + 1); slotNames[pendingSaveSlot] = name; fileSaveView->updateButtonName(static_cast(pendingSaveSlot), displayName); fileLoadView->updateButtonName(static_cast(pendingSaveSlot), displayName); fileSaveView->SetMessage("Saving as " + displayName); uint32_t save = spin_lock_blocking(mlpActive); if (MEMLNaut::Instance()->startSD()) { _saveSlotNames(); if (this->_save_RL_to_SD(displayName)) { fileSaveView->SetMessage("Saved as " + displayName); } else { fileSaveView->SetMessage("Failed to save model"); } MEMLNaut::Instance()->stopSD(); } else { fileSaveView->SetMessage("SD card error - is it inserted and formatted?"); } spin_unlock(mlpActive, save); } MEMLNaut::Instance()->disp->DismissDialog(); }, [this]() { MEMLNaut::Instance()->disp->DismissDialog(); } ); MEMLNaut::Instance()->disp->RegisterDialog(nameInputView); } template void InterfaceRL::setModeInfo(const String& modeRoot, const String& modeTag) { _modeRoot = modeRoot; _modeTag = modeTag; if (MEMLNaut::Instance()->startSD()) { _loadSlotNames(); MEMLNaut::Instance()->stopSD(); } } template bool InterfaceRL::_save_RL_to_SD(String id) { String dir = "/" + _modeRoot; String path = dir + "/" + id + ".bin"; if (!SD.exists(dir.c_str())) { SD.mkdir(dir.c_str()); } auto file = SD.open(path.c_str(), FILE_WRITE); if (!file) { Serial.println("Failed to open file for writing: " + path); return false; } file.seek(0); MEMLFileHeader header; memcpy(header.magic, "MEML", 4); header.format_version = MEML_FILE_FORMAT_VERSION; memset(header.mode_tag, 0, sizeof(header.mode_tag)); strncpy(header.mode_tag, _modeTag.c_str(), sizeof(header.mode_tag) - 1); std::vector extraData; if (_extraSaveFn) { extraData = _extraSaveFn(); } header.extra_size = static_cast(extraData.size()); if (file.write((const char*)&header, sizeof(header)) != sizeof(header)) { file.close(); return false; } if (!extraData.empty()) { if (file.write(extraData.data(), extraData.size()) != extraData.size()) { file.close(); return false; } } bool success = synthMapping.SaveMLPNetworkToFile(file); file.close(); return success; } template bool InterfaceRL::_load_RL_from_SD(String id) { String path = "/" + _modeRoot + "/" + id + ".bin"; auto file = SD.open(path.c_str(), FILE_READ); if (!file) { Serial.println("File not found: " + path); return false; } MEMLFileHeader header; if (file.read((uint8_t*)&header, sizeof(header)) != sizeof(header)) { file.close(); Serial.println("File too small to contain header"); return false; } if (memcmp(header.magic, "MEML", 4) != 0) { file.close(); Serial.println("Unrecognised file format (bad magic)"); return false; } if (header.format_version > MEML_FILE_FORMAT_VERSION) { file.close(); Serial.println("File saved with newer firmware (version " + String(header.format_version) + ")"); return false; } char expected_tag[17] = {}; strncpy(expected_tag, _modeTag.c_str(), 16); if (memcmp(header.mode_tag, expected_tag, 16) != 0) { char tag_buf[17] = {}; memcpy(tag_buf, header.mode_tag, 16); file.close(); Serial.println(String("Wrong mode: file is for '") + tag_buf + "'"); return false; } if (header.extra_size > 0) { std::vector extraData(header.extra_size); if (file.read(extraData.data(), header.extra_size) != header.extra_size) { file.close(); return false; } if (_extraLoadFn) { _extraLoadFn(extraData.data(), header.extra_size, header.format_version); } } bool success = synthMapping.LoadMLPNetworkFromFile(file); file.close(); // With a StaticMLP the architecture is fixed at compile time and // LoadMLPNetworkFromFile already rejects (returns false) any on-card model // whose geometry/activations don't match — so a loaded model is always // architecture-correct. Keep a defensive rebuild for the mismatch case. if (success && (synthMapping.get_num_inputs() != (int)controlInput.size() || synthMapping.get_num_outputs() != (int)n_outputs_)) { synthMapping.RandomiseWeightsAndBiasesLin(-1.2f, 0.9f, 0, 0.5f); if (msgView) msgView->post("Model incompatible: wrong architecture"); return false; } return success; } template void InterfaceRL::_saveSlotNames() { String dir = "/" + _modeRoot; if (!SD.exists(dir.c_str())) { SD.mkdir(dir.c_str()); } String path = dir + "/slots.txt"; auto file = SD.open(path.c_str(), FILE_WRITE); if (!file) return; file.seek(0); for (int i = 0; i < kNumSlots; i++) { file.println(slotNames[i]); } file.close(); } template void InterfaceRL::_loadSlotNames() { String path = "/" + _modeRoot + "/slots.txt"; auto file = SD.open(path.c_str(), FILE_READ); if (!file) return; for (int i = 0; i < kNumSlots; i++) { String line = file.readStringUntil('\n'); line.trim(); slotNames[i] = line; if (line.length() > 0) { fileSaveView->updateButtonName(static_cast(i), line); fileLoadView->updateButtonName(static_cast(i), line); } } file.close(); } template void InterfaceRL::optimise() { float lossPositive{0.f}; float lossNegative{0.f}; size_t batchSizeNeg=0; const float effLR = learningRateScaled * joltLRRamp_; //positive batch std::vector sample = replayMem.sampleIndices(batchSize); if (sample.size() >1) { //run sample through network size_t batchSizePos=0; float avgRewardPos=0.f; training_pair_t tsPositive; // Pre-allocate to avoid repeated allocations tsPositive.first.reserve(sample.size()); tsPositive.second.reserve(sample.size()); // Positive batch: random sample (diversity for generalisation) for (auto &i : sample) { if (replayMem.getItem(i).reward > 0) { tsPositive.first.push_back(replayMem.getItem(i).input); tsPositive.second.push_back(replayMem.getItem(i).action); batchSizePos++; avgRewardPos += replayMem.getItem(i).reward; } } // Post-jolt recovery: scale the LR by the ramp (0 -> 1 over ~5s after a jolt) so // training eases back in rather than yanking the net off the jolted sound. if (batchSizePos > 0){ avgRewardPos /= static_cast(batchSizePos); lossPositive = synthMapping.TrainBatch(tsPositive, effLR * avgRewardPos, 1, batchSize, 0.f, false); // Serial.printf("[DEBUG] Loss after positive TrainBatch: %f (inf=%d, nan=%d)\n", // lossPositive, std::isinf(lossPositive), std::isnan(lossPositive)); } } // Negative batch: scan ALL negatives so every dislike is guaranteed to push. // No decay — a 'no' pushes at full strength until it's lived kDislikeLifetimeMs, // then it's removed outright. training_pair_t tsNegative; tsNegative.first.reserve(sample.size()); tsNegative.second.reserve(sample.size()); // Single scan over all memory: tally positives (for display + LR ratio) and collect // negatives (expiring any that have outlived kDislikeLifetimeMs). float avgRewardNeg=0.f; size_t totalPosCount=0; const uint32_t now = millis(); for (size_t i = 0; i < replayMem.size(); i++) { float reward = replayMem.getItem(i).reward; if (reward > 0.f) { totalPosCount++; continue; } if ((now - static_cast(replayMem.getTimestamp(i))) >= kDislikeLifetimeMs) { itemsToRemove.push_back(i); // lived its lifetime -> stop pushing, remove continue; } tsNegative.first.push_back(replayMem.getItem(i).input); tsNegative.second.push_back(replayMem.getItem(i).action); batchSizeNeg++; avgRewardNeg += reward; } if (batchSizeNeg > 0){ struct PosCandidate { float dist; size_t idx; }; std::vector candidates; candidates.reserve(replayMem.size()); for (size_t i = 0; i < replayMem.size(); i++) { const auto& item = replayMem.getItem(i); if (item.reward > 0.f) candidates.push_back({euclideanDistance(item.input, controlInput), i}); } std::sort(candidates.begin(), candidates.end(), [](const PosCandidate& a, const PosCandidate& b){ return a.dist < b.dist; }); std::vector meanPositiveAction(action.size(), 0.f); size_t posMemCount = 0; const size_t kUsed = std::min(candidates.size(), kCentroidK); for (size_t ci = 0; ci < kUsed; ci++) { const auto& item = replayMem.getItem(candidates[ci].idx); for (size_t j = 0; j < meanPositiveAction.size(); j++) meanPositiveAction[j] += item.action[j]; posMemCount++; } if (posMemCount > 0) { for (auto& v : meanPositiveAction) v /= static_cast(posMemCount); } avgRewardNeg /= static_cast(batchSizeNeg); // Push each disliked action's training target strongly away from the liked // centroid — or in a random direction when there are no likes yet. No taper: // a 'no' should clearly move the mapping away even from a sound already far // from the liked region (the taper used to kill exactly that case). Bigger // kGeometricPushScale + higher negLRRatio => the sound slides away faster/further. const bool havePositives = (posMemCount > 0); training_pair_t tsGeometric; tsGeometric.first = tsNegative.first; tsGeometric.second.reserve(tsNegative.second.size()); float pushStep = std::clamp(fabsf(avgRewardNeg), 0.25f, 1.0f) * kGeometricPushScale; for (const auto& neg_action : tsNegative.second) { // Fix 3: guard against size mismatch with old saved actions const size_t dimCount = std::min(neg_action.size(), meanPositiveAction.size()); float len = 0.f; std::vector dir(dimCount); for (size_t j = 0; j < dimCount; j++) { dir[j] = neg_action[j] - meanPositiveAction[j]; // meanPositiveAction is 0 when no likes len += dir[j] * dir[j]; } len = sqrtf(len); const bool useRandom = !havePositives || (len <= 1e-4f); std::vector target(neg_action); // copy keeps out-of-range dims intact for (size_t j = 0; j < dimCount; j++) { bool active = activeDims_.empty() || (j < activeDims_.size() && activeDims_[j]); if (!active) continue; float d = useRandom ? (static_cast(rand() & 0xFF) / 127.5f - 1.f) : (dir[j] / len); target[j] = std::clamp(neg_action[j] + d * pushStep, 0.f, 1.f); } tsGeometric.second.push_back(std::move(target)); } // Dynamic LR ratio: push harder when dislikes are rare, gentler when they flood the buffer const float negFraction = static_cast(batchSizeNeg) / static_cast(std::max(batchSizeNeg + totalPosCount, size_t{1})); const float negLRRatio = kNegLRBase - 0.4f * negFraction; lossNegative = synthMapping.TrainBatch(tsGeometric, effLR * negLRRatio, 1, batchSizeNeg, 0.f, false); } // Fix 4: always clear — stale indices corrupt subsequent optimise() calls replayMem.removeItems(itemsToRemove); itemsToRemove.clear(); if (nnOutputsGraphView) { nnOutputsGraphView->setLoss(lossPositive); nnOutputsGraphView->setMemoryCounts(totalPosCount, replayMem.size() - totalPosCount); } } template void InterfaceRL::readAnalysisParameters(std::vector params) { for (size_t i = 0; i < params.size() && i < 6; i++) { raw_ml_[i] = params[i]; } generateAction(true); } template void InterfaceRL::assembleInputs() { switch (input_source_) { case INPUT_SOURCE::JOYSTICK_3D: copyAndZero(raw_joystick_, 3); break; case INPUT_SOURCE::JOYSTICK_4D: copyAndZero(raw_joystick_, 4); break; case INPUT_SOURCE::MACHINE_LISTENING: copyAndZero(raw_ml_, 6); break; case INPUT_SOURCE::MIDI_1CC: copyAndZero(raw_midi_, 1); break; case INPUT_SOURCE::MIDI_3CC: copyAndZero(raw_midi_, 3); break; case INPUT_SOURCE::MIDI_8CC: copyAndZero(raw_midi_, 8); break; case INPUT_SOURCE::COMBINED: memcpy(&controlInput[0], raw_joystick_, 4 * sizeof(float)); memcpy(&controlInput[4], raw_ml_, 6 * sizeof(float)); break; default: break; } } template void InterfaceRL::copyAndZero(const float* src, size_t n) { // Pad the unused input tail with a non-zero constant instead of 0. A constant input // only adds a fixed term (Σ_j W1[i,j]·c) to each hidden unit — i.e. a per-unit layer-1 // bias shift — which spreads effective biases to mixed signs so units switch both on // and off across a single-input sweep (more non-linear, direction-changing mapping). // unusedInputDefault_ is recomputed only on input-mode change (see updateUnusedInputDefault). size_t i = 0; for (; i < n && i < kMaxNNInputs; ++i) controlInput[i] = src[i]; for (; i < kMaxNNInputs; ++i) controlInput[i] = unusedInputDefault_; } template void InterfaceRL::saveInputSource() { FILE* f = fopen(kInputSourceFile, "wb"); if (f) { fwrite(&input_source_, sizeof(input_source_), 1, f); fclose(f); } } template void InterfaceRL::loadInputSource() { FILE* f = fopen(kInputSourceFile, "rb"); if (f) { fread(&input_source_, sizeof(input_source_), 1, f); fclose(f); } updateUnusedInputDefault(); } template void InterfaceRL::addInputSourceView(bool includeCCSelect) { static const String srcNames[] = { "3D Joystick", "4D Joystick", "Machine Listen", "MIDI Mod Whl", "MIDI 3 CC", "MIDI 8 CC", "Combined" }; std::vector available = { INPUT_SOURCE::JOYSTICK_3D, INPUT_SOURCE::JOYSTICK_4D, INPUT_SOURCE::MIDI_1CC, INPUT_SOURCE::MIDI_3CC, INPUT_SOURCE::MIDI_8CC }; if (hasMachineListening_) { available.push_back(INPUT_SOURCE::MACHINE_LISTENING); available.push_back(INPUT_SOURCE::COMBINED); } std::vector opts; for (auto src : available) opts.push_back(srcNames[static_cast(src)]); size_t initialSel = 0; auto it = std::find(available.begin(), available.end(), input_source_); if (it != available.end()) initialSel = std::distance(available.begin(), it); auto view = std::make_shared("Input Source"); view->setOptions(std::span(opts.data(), opts.size())); view->setSelection(initialSel); view->setNewSelectionCallback([this, available](size_t idx) { // Runs in the rotary-encoder ISR — defer the actual switch to the main loop. if (idx < available.size()) requestInputSource(available[idx]); }); MEMLNaut::Instance()->disp->AddView(view); if (includeCCSelect) { size_t maxCC = midi_ ? midi_->getParamCount() : n_outputs_; ccSelectView = std::make_shared(maxCC, "MIDI CC Out"); loadCCNumbers(); ccSelectView->setOnChangeCallback([this](const std::vector& ccs) { if (midi_) midi_->SetParamCCNumbers(ccs); saveCCNumbers(); }); MEMLNaut::Instance()->disp->AddView(ccSelectView); } } template void InterfaceRL::generateAction(bool donthesitate) { if (newInput || donthesitate) { newInput = false; assembleInputs(); if (inputInjectionHook) inputInjectionHook(controlInput); if (!actionBeingDragged) { synthMapping.GetOutput(controlInput, &mappingOutput); for(size_t i=0; i < mappingOutput.size(); i++) { const float noise = ou_noises[i]->sample(); mappingOutput[i] += noise; if (mappingOutput[i] < 0.f) { mappingOutput[i] = fmod(-mappingOutput[i],1.f); // reflect } else if (mappingOutput[i] > 1.f) { mappingOutput[i] = 1.f - fmod(mappingOutput[i], 1.f); // reflect at 1.0 } } } if (paramTransformHook) paramTransformHook(mappingOutput); SendParamsToQueue(mappingOutput); action = mappingOutput; nnOutputsGraphView->UpdateValues(mappingOutput, resetMinMaxFlag); resetMinMaxFlag = false; nnInputsGraphView->UpdateValues(controlInput, false); } } // void InterfaceRL::storeExperience(float reward) { // std::vector state = controlInput; // trainStatelessRLItem trainItem = {state, action, reward}; // state is s_t, action is a_t, reward is r_t, nextState is s_t // replayMem.add(trainItem, millis()); // } template bool InterfaceRL::removeItemsAtDistance(std::vector &experienceState, const float distThreshold, const float reward) { std::vector indicesToRemove; bool accumulated = false; for(size_t i=0; i < replayMem.size(); i++) { trainStatelessRLItem& item = replayMem.getItem(i); float dist = euclideanDistance(item.input, experienceState); if (dist < distThreshold) { if (reward < 0.f && item.reward < 0.f) { // Strengthen existing dislike rather than replacing it item.reward = std::max(item.reward + reward, -1.0f); accumulated = true; } else if (reward < 0.f && item.reward > 0.f) { // A dislike near a like: delete the like so it stops pulling the // model back towards the disliked region. indicesToRemove.push_back(i); if (msgView) msgView->post("Removing nearby like"); } else if (item.reward > 0.f && reward > 0.f) { indicesToRemove.push_back(i); if (msgView) msgView->post("Removing similar memory item"); } } } replayMem.removeItems(indicesToRemove); return accumulated; } template void InterfaceRL::decayItemsAtDistance(std::vector &experienceState, const float distThreshold) { std::vector indicesToRemove; for(size_t i=0; i < replayMem.size(); i++) { trainStatelessRLItem& item = replayMem.getItem(i); float dist = euclideanDistance(item.input, experienceState); if (dist < distThreshold) { float decayFactor = (dist/distThreshold); item.reward *= decayFactor; // Decay reward if (item.reward < 0.05f) { indicesToRemove.push_back(i); } if (msgView) msgView->post("Decaying memory item"); Serial.printf("Decayed item %d reward to %f\n", i, item.reward); } } replayMem.removeItems(indicesToRemove); } template void InterfaceRL::storeExperience(float reward, std::vector &experienceState, std::vector &experienceAction ) { trainStatelessRLItem trainItem = {experienceState, experienceAction, reward * rewardScale}; // state is s_t, action is a_t, reward is r_t, nextState is s_t bool skip_add = false; switch(memoryStoreMode) { case MEMORY_STORE_MODES::ADD: break; case MEMORY_STORE_MODES::REPLACE_5_PERCENT: skip_add = removeItemsAtDistance(experienceState, 0.05f, trainItem.reward); break; case MEMORY_STORE_MODES::REPLACE_10_PERCENT: skip_add = removeItemsAtDistance(experienceState, 0.10f, trainItem.reward); break; case MEMORY_STORE_MODES::REPLACE_15_PERCENT: skip_add = removeItemsAtDistance(experienceState, 0.15f, trainItem.reward); break; case MEMORY_STORE_MODES::REWARD_DECAY_10_PERCENT: decayItemsAtDistance(experienceState, 0.10f); break; case MEMORY_STORE_MODES::REWARD_DECAY_20_PERCENT: decayItemsAtDistance(experienceState, 0.20f); break; } if (!skip_add) replayMem.add(trainItem, millis()); if (nnOutputsGraphView) { size_t pos = 0; for (size_t i = 0; i < replayMem.size(); i++) if (replayMem.getItem(i).reward > 0.f) pos++; nnOutputsGraphView->setMemoryCounts(pos, replayMem.size() - pos); } } template void InterfaceRL::saveCCNumbers() { if (!ccSelectView) return; String path = "/" + _modeRoot + "_cc_numbers.bin"; FILE* f = fopen(path.c_str(), "wb"); if (f) { const auto& ccs = ccSelectView->getSelectedCCs(); fwrite(ccs.data(), 1, ccs.size(), f); fclose(f); } } template void InterfaceRL::loadCCNumbers() { if (!ccSelectView) return; String path = "/" + _modeRoot + "_cc_numbers.bin"; FILE* f = fopen(path.c_str(), "rb"); if (f) { std::vector ccs; uint8_t b; while (fread(&b, 1, 1, f) == 1) ccs.push_back(b); fclose(f); if (!ccs.empty()) { ccSelectView->setSelectedCCs(ccs); return; } } // Default: CC1..n_outputs size_t nDefault = std::min(ccSelectView->getMaxActive(), (size_t)32); std::vector defaults(nDefault); for (size_t i = 0; i < nDefault; i++) defaults[i] = static_cast(i + 1); ccSelectView->setSelectedCCs(defaults); }