diff --git a/playground/js/a-app.js b/playground/js/a-app.js index 3410e9b..5453317 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. @@ -1070,18 +1074,27 @@ async function init() { EOCChainUI.init(eocChain, eocDrawerBody); } - // Handle EOC structural changes — resize MLP and rebuild heatmap when in Shared mode + // Handle EOC structural changes — Shared mode resizes MLP; Linked mode manages second IML window.addEventListener('eoc:change', async (e) => { const reason = e.detail?.reason; console.log('[EOC] chain changed, reason:', reason, 'paramCount:', eocChain.paramCount, 'nispsMode:', eocChain.nispsMode); - // Only act on nispsMode switches or module add/remove/move — ignore other events + if (reason === 'nispsMode-changed') { + // Linked mode lifecycle + if (eocChain.nispsMode === 'linked') { + await initEocIML(); + } else { + destroyEocIML(); + eocTrainingTarget = 'synth'; + } + } + + // Shared mode: resize MLP and rebuild heatmap on mode switch or module change if (reason === 'nispsMode-changed' || reason?.startsWith('module')) { const newTotal = totalOutputCount(); if (newTotal !== N_OUTPUTS) { await resizeMLP(newTotal); } - // Rebuild heatmap to include EOC params when in shared mode, or revert when leaving if (eocChain.nispsMode === 'shared') { const combinedMeta = [ ...(activeEngine?.paramMeta ?? []), @@ -1093,6 +1106,11 @@ async function init() { } document.getElementById('heatmap-cells')?.parentElement ?.classList.toggle('shared-mode', eocChain.nispsMode === 'shared'); + + // Linked mode: recreate EOC IML when modules change + if (eocChain.nispsMode === 'linked' && imlEoc) { + await initEocIML(); + } } }); @@ -1148,6 +1166,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); } @@ -1814,6 +1840,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(); @@ -2313,33 +2350,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'); @@ -3448,6 +3517,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 { @@ -3474,6 +3571,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) { @@ -3606,6 +3704,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);