Merge Shared + Linked NISPS modes (meml-dqt, meml-vqg)

Resolved eoc:change listener conflict: both modes coexist in same handler.
Shared mode resizes combined MLP; Linked mode manages second IML.
This commit is contained in:
w1n5t0n 2026-04-03 18:16:54 +01:00
commit 5a004c6a3e

View file

@ -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();
// 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);
// 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);