From e8cb8e41954fd092bd07b26a6cd19149ea249615 Mon Sep 17 00:00:00 2001 From: w1n5t0n Date: Fri, 3 Apr 2026 18:03:12 +0100 Subject: [PATCH] =?UTF-8?q?feat(playground):=20EOC=20NISPS=20Linked=20mode?= =?UTF-8?q?=20=E2=80=94=20dual=20MLP=20from=20same=20controller=20(meml-vq?= =?UTF-8?q?g)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- playground/js/a-app.js | 124 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 113 insertions(+), 11 deletions(-) diff --git a/playground/js/a-app.js b/playground/js/a-app.js index 3a0147f..291e254 100644 --- a/playground/js/a-app.js +++ b/playground/js/a-app.js @@ -95,6 +95,10 @@ let outputMode = 'visual'; let eocChain = null; let _eocInited = false; // guard: init once per AudioContext lifetime +// EOC Linked mode — second IML driven by the same joystick, independent training +let imlEoc = null; // second IML for EOC params (Linked mode) +let eocTrainingTarget = 'synth'; // 'synth' | 'eoc' — which MLP RL feedback targets + // ---- MIDI CC state ---- let midiOutput = null; // Storage key scoped per engine so CC assignments don't bleed between engines. @@ -1050,9 +1054,22 @@ async function init() { EOCChainUI.init(eocChain, eocDrawerBody); } - // Log EOC structural changes; future NISPS modes will act on this event - window.addEventListener('eoc:change', () => { - console.log('[EOC] chain changed, paramCount:', eocChain.paramCount); + // Log EOC structural changes; Linked mode acts on this event + window.addEventListener('eoc:change', async (e) => { + const reason = e.detail?.reason; + console.log('[EOC] chain changed, paramCount:', eocChain.paramCount, reason ? `(${reason})` : ''); + + if (reason === 'nispsMode-changed') { + if (eocChain.nispsMode === 'linked') { + await initEocIML(); + } else { + destroyEocIML(); + eocTrainingTarget = 'synth'; + } + } else if (eocChain.nispsMode === 'linked' && imlEoc) { + // Module added/removed: recreate EOC IML with updated param count + await initEocIML(); + } }); // Debug probe — exposed on window when ?debug=1 is in the URL. @@ -1107,6 +1124,14 @@ async function init() { }; } + // EOC Linked-mode probe — exposed unconditionally so the training-target UI + // (meml-0r0) can wire buttons without requiring ?debug=1. + window.__nispsEoc = { + get trainingTarget() { return eocTrainingTarget; }, + setTrainingTarget(t) { eocTrainingTarget = t; }, + get imlEoc() { return imlEoc; }, + }; + // Start animation requestAnimationFrame(animate); } @@ -1773,6 +1798,17 @@ function onJoystickMove() { syncRawParamsFromOutputs(outputs); + // EOC Linked mode: run EOC IML with same joystick inputs + if (imlEoc && eocChain?.nispsMode === 'linked') { + imlEoc.setInput(0, joyX); + imlEoc.setInput(1, joyY); + imlEoc.process(); + const eocOutputs = imlEoc.getOutputs(); + for (let i = 0; i < eocOutputs.length; i++) { + eocChain.setParam(i, eocOutputs[i]); + } + } + // Trail joyTrail.push({ x: joyX, y: joyY, t: Date.now() }); if (joyTrail.length > 30) joyTrail.shift(); @@ -2256,33 +2292,65 @@ function updateUndoButton() { } // ---- RL mode ---- + +/** + * Return the IML instance that RL feedback should target. + * In Linked mode the user may direct feedback to the EOC IML instead. + */ +function _rlTarget() { + if (eocChain?.nispsMode === 'linked' && eocTrainingTarget === 'eoc' && imlEoc) { + return imlEoc; + } + return iml; +} + function onThumbsUp() { - if (iml.isTraining) return; + const target = _rlTarget(); + if (!target || target.isTraining) return; pushUndoSnapshot(); const inputs = getCurrentInputs(); const outputs = [...rawParamValues]; - iml.addExample(inputs, outputs); + target.addExample(inputs, outputs); noiseLevel *= rlExplorationDecay; noiseLevel = Math.max(noiseLevel, 0.005); flash('btn-thumbsup'); updateNoiseRing(); - trainModelAsync(); + // Only run async training on the target; synth IML uses trainModelAsync, EOC IML trains directly + if (target === iml) { + trainModelAsync(); + } else { + target.trainAsync(({ loss }) => { + updateStatus(); + }); + } } function onThumbsDown() { + const target = _rlTarget(); + if (!target) return; + pushUndoSnapshot(); const noiseCap = 0.3 * (1 - spreadLevel) + 0.05 * spreadLevel; noiseLevel = Math.min(noiseLevel * 1.5, noiseCap); - iml.moveWeights(noiseLevel, spreadLevel); + target.moveWeights(noiseLevel, spreadLevel); - const outputs = iml.getOutputs(); - routeOutputs(outputs); - updateHeatmap(outputs); - syncRawParamsFromOutputs(outputs); + // Re-route outputs from whichever IML was affected + if (target === iml) { + const outputs = iml.getOutputs(); + routeOutputs(outputs); + updateHeatmap(outputs); + syncRawParamsFromOutputs(outputs); + } else if (imlEoc && eocChain?.nispsMode === 'linked') { + imlEoc.process(); + const eocOutputs = imlEoc.getOutputs(); + for (let i = 0; i < eocOutputs.length; i++) { + eocChain.setParam(i, eocOutputs[i]); + } + } updateStatus(); updateNoiseRing(); flash('btn-thumbsdown'); @@ -3391,6 +3459,34 @@ function flash(id) { setTimeout(() => el.classList.remove('flash'), 250); } +// ---- EOC Linked-mode IML helpers ---- + +/** + * Create (or recreate) the EOC IML instance for Linked mode. + * Uses a smaller network than the synth IML since EOC params are more independent. + */ +async function initEocIML() { + if (imlEoc) { imlEoc.destroy(); imlEoc = null; } + if (!eocChain || eocChain.paramCount === 0) return; + imlEoc = await WasmIML.create( + N_JOY_INPUTS, + eocChain.paramCount, + [16, 32], // smaller network — EOC params are more independent + 1000, 1.0, 0.00001, + ); + imlEoc.randomiseWeights(spreadLevel); + console.log(`[EOC] Linked IML created — ${eocChain.paramCount} outputs`); +} + +/** Destroy the EOC IML instance (e.g. when leaving Linked mode). */ +function destroyEocIML() { + if (imlEoc) { + imlEoc.destroy(); + imlEoc = null; + console.log('[EOC] Linked IML destroyed'); + } +} + // ---- Persistence ---- function saveState() { try { @@ -3417,6 +3513,7 @@ function saveState() { params: m.paramMeta.map((_, i) => m.getCurrentParamValue(i)), })) : [], eocNispsMode: eocChain ? eocChain.nispsMode : 'bypass', + eocTrainingTarget, }; localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch (e) { @@ -3549,6 +3646,11 @@ async function loadState() { try { eocChain.nispsMode = state.eocNispsMode; } catch (_) { /* invalid mode in old save */ } } + // Restore EOC training target + if (state.eocTrainingTarget === 'synth' || state.eocTrainingTarget === 'eoc') { + eocTrainingTarget = state.eocTrainingTarget; + } + console.log(`[NISPS] Restored ${state.features?.length || 0} joy examples, ${state.handFeatures?.length || 0} hand examples from storage`); } catch (e) { console.warn('[NISPS] Failed to load state:', e);