memlnaut-nisps/playground/js/app.js
w1n5t0n 21832e0099 fix(playground): prevent 1/2 keys being swallowed by focused sliders
When synth controls (volume, tempo, etc.) have focus after dragging,
keyboard shortcuts 1/2 for thumbs down/up were intercepted by the
range input. Now all four UIs skip shortcut handling when an input,
select, or textarea element has focus.
2026-03-22 11:52:30 +02:00

603 lines
19 KiB
JavaScript

// NISPS Playground - Main application
// Wires IML engine to visual system OR C15 synth with joystick input and unified learning controls
import { IML } from './nisps/iml.js';
import { FlowFieldVisualizer } from './ui/visualizer.js';
import { VirtualJoystick } from './ui/joystick.js';
import { Controls } from './ui/controls.js';
import { ParamDisplay } from './ui/param-display.js';
import { C15Bridge } from './synth/c15-bridge.js';
import { Arpeggiator } from './synth/arpeggiator.js';
import { SYNTH_PARAM_MAP, SYNTH_PARAM_NAMES, SYNTH_PARAM_COLORS, applyTame } from './synth/param-map.js';
import { GamepadInput } from './ui/gamepad.js';
const N_INPUTS = 2;
const N_VISUAL_OUTPUTS = 20;
const N_SYNTH_OUTPUTS = SYNTH_PARAM_MAP.length; // 126
const N_OUTPUTS = N_SYNTH_OUTPUTS; // MLP always produces full output; visual uses first 20
// Visual mode param display config
const VISUAL_PARAM_NAMES = ['Flow', 'Scale', 'Speed', 'Hue', 'Spread', 'Size', 'Trail', 'Turb', 'Attract', 'Radius', 'DispRate', 'DispAmt', 'Lifetime', 'Respawn', 'Advection', 'Inertia', 'Drag', 'Repulse', 'RepCnt', 'RepRate'];
const VISUAL_PARAM_COLORS = ['#ff6a00', '#00ccff', '#ff6600', '#ff00cc', '#ffcc00', '#88ff00', '#0088ff', '#ff3366', '#9bff5f', '#59d3ff', '#ff8f3f', '#a0b7ff', '#f4ff7a', '#ffa8db', '#7dffc8', '#ffd166', '#8ad4ff', '#ff5f5f', '#ffc15f', '#ff8a3d'];
// --- State ---
let iml;
let visualizer;
let joystick;
let controls;
let paramDisplay;
let outputMode = 'visual'; // 'visual' | 'synth'
let noiseLevel = 0.05;
let rlExplorationDecay = 0.97;
let animating = true;
let gamepad;
let gamepadConnected = false;
let followMode = false;
let visualExpanded = false;
let appRoot;
let expandVisualBtn;
// Synth state
let c15 = null;
let arpeggiator = null;
// Devmode: tame level (0 = no mitigation, 1 = strongest)
// Set via URL ?tame=0.7 or window.setTameLevel(0.7)
const _urlParams = new URLSearchParams(location.search);
let tameLevel = parseFloat(_urlParams.get('tame') ?? '1');
if (isNaN(tameLevel)) tameLevel = 1;
tameLevel = Math.max(0, Math.min(1, tameLevel));
let spreadLevel = parseFloat(_urlParams.get('spread') ?? '0.6');
if (isNaN(spreadLevel)) spreadLevel = 0.6;
spreadLevel = Math.max(0, Math.min(1, spreadLevel));
window.setTameLevel = (v) => { tameLevel = Math.max(0, Math.min(1, v)); console.log(`[NISPS] tame=${tameLevel}`); };
window.getTameLevel = () => tameLevel;
// --- Init ---
function init() {
iml = new IML(N_INPUTS, N_OUTPUTS, [32, 48, 64], 1000, 1.0, 0.00001);
iml.setLogger(msg => console.log('[NISPS]', msg));
// Visualizer
appRoot = document.querySelector('.app');
expandVisualBtn = document.getElementById('expand-visual-btn');
const canvas = document.getElementById('visual-canvas');
visualizer = new FlowFieldVisualizer(canvas);
// Joystick
joystick = new VirtualJoystick(document.getElementById('joystick-container'), {
size: 160,
trackpadScale: 2,
springBack: false,
onChange: onJoystickMove,
onFollowModeChange: (enabled) => {
followMode = enabled;
refreshDashboard();
},
});
// Parameter display (start in visual mode with 20 params)
paramDisplay = new ParamDisplay(document.getElementById('param-display'), N_VISUAL_OUTPUTS);
// Controls
controls = new Controls(document.getElementById('controls-container'), {
onAddExample,
onTrain,
onRandomize,
onClear,
onClearExamples,
onThumbsUp,
onThumbsDown,
});
// Resize handling
window.addEventListener('resize', () => {
resizeVisualizerSurface();
});
if (expandVisualBtn) {
expandVisualBtn.addEventListener('click', () => setVisualExpanded(!visualExpanded));
updateExpandButtonState();
}
// Help overlay
const helpBtn = document.getElementById('help-btn');
const helpOverlay = document.getElementById('help-overlay');
if (helpBtn && helpOverlay) {
helpBtn.addEventListener('click', () => helpOverlay.classList.toggle('hidden'));
helpOverlay.addEventListener('click', () => helpOverlay.classList.add('hidden'));
}
// Side panel
initSidePanel();
// Init synth
c15 = new C15Bridge();
c15.onStatusChange = (msg) => {
const el = document.getElementById('synth-status');
if (el) el.textContent = msg;
};
c15.loadParams();
arpeggiator = new Arpeggiator(c15);
// Wire synth controls
initSynthControls();
gamepad = new GamepadInput({
onMove: (x, y) => {
if (paramDisplay.activeBar < 0) {
joystick.setPosition(x, y, { emit: true, touching: true });
}
},
onButton: (btn) => {
if (btn === 'rb') onThumbsUp();
if (btn === 'lb') onThumbsDown();
},
onConnectionChange: (connected) => {
gamepadConnected = connected;
refreshDashboard();
},
});
window.addEventListener('keydown', onKeyDown);
// Run initial inference to populate outputs
iml.setInput(0, 0.5);
iml.setInput(1, 0.5);
iml.process();
visualizer.setParams(iml.getOutputs());
paramDisplay.update(iml.getOutputs());
controls.updateStatus(iml.exampleCount, iml.lastLoss, noiseLevel);
controls.updateLossPlot(iml.lossHistory);
refreshDashboard();
// Start animation
animate();
// Load from localStorage if available
loadState();
}
// --- Side Panel ---
function initSidePanel() {
const panel = document.getElementById('side-panel');
const toggle = document.getElementById('side-panel-toggle');
const close = document.getElementById('side-panel-close');
const backdrop = document.getElementById('side-panel-backdrop');
const openPanel = () => {
panel.classList.add('open');
backdrop.classList.remove('hidden');
};
const closePanel = () => {
panel.classList.remove('open');
backdrop.classList.add('hidden');
};
toggle.addEventListener('click', openPanel);
close.addEventListener('click', closePanel);
backdrop.addEventListener('click', closePanel);
// Tab switching
panel.querySelectorAll('.sp-tab').forEach(tab => {
tab.addEventListener('click', () => {
const mode = tab.dataset.mode;
setOutputMode(mode);
panel.querySelectorAll('.sp-tab').forEach(t => t.classList.toggle('active', t === tab));
});
});
}
function setOutputMode(mode) {
outputMode = mode;
const synthControls = document.getElementById('synth-controls');
const visualInfo = document.getElementById('visual-info');
const presetsVisual = document.getElementById('presets-visual');
const modeBadge = document.getElementById('mode-badge');
const paramContainer = document.getElementById('param-display');
if (mode === 'synth') {
synthControls.classList.remove('hidden');
visualInfo.classList.add('hidden');
presetsVisual.classList.add('hidden');
modeBadge.textContent = 'Synth';
modeBadge.classList.add('synth');
paramContainer.classList.add('synth-mode');
// Rebuild param display with all synth params
paramDisplay.rebuild(N_SYNTH_OUTPUTS, SYNTH_PARAM_NAMES, SYNTH_PARAM_COLORS);
paramDisplay.setDraggable(true);
} else {
synthControls.classList.add('hidden');
visualInfo.classList.remove('hidden');
presetsVisual.classList.remove('hidden');
modeBadge.textContent = 'Visual';
modeBadge.classList.remove('synth');
paramContainer.classList.remove('synth-mode');
// Rebuild param display with visual params (first 20)
paramDisplay.rebuild(N_VISUAL_OUTPUTS, VISUAL_PARAM_NAMES, VISUAL_PARAM_COLORS);
paramDisplay.setDraggable(true);
}
// Re-run inference and route outputs
routeOutputs(iml.getOutputs());
}
function routeOutputs(outputs) {
// Always update visualizer (it's always visible)
visualizer.setParams(outputs);
// If in synth mode, also send to C15 with tame-level constraining
if (outputMode === 'synth' && c15 && c15.running) {
for (let i = 0; i < outputs.length && i < SYNTH_PARAM_MAP.length; i++) {
const value = applyTame(outputs[i], SYNTH_PARAM_MAP[i], tameLevel);
c15.setParameter(SYNTH_PARAM_MAP[i].id, value);
}
}
}
// --- Synth Controls ---
function initSynthControls() {
const startBtn = document.getElementById('synth-start');
const volumeSlider = document.getElementById('synth-volume');
const arpToggle = document.getElementById('arp-toggle');
const arpProgression = document.getElementById('arp-progression');
const arpTempo = document.getElementById('arp-tempo');
const arpOctaves = document.getElementById('arp-octaves');
const arpOffset = document.getElementById('arp-offset');
startBtn.addEventListener('click', async () => {
if (c15.running) {
arpeggiator.stop();
arpToggle.textContent = 'Play';
arpToggle.classList.remove('playing');
await c15.stop();
startBtn.textContent = 'Start Audio';
} else {
await c15.start();
startBtn.textContent = 'Stop Audio';
// Send current NISPS outputs to synth
routeOutputs(iml.getOutputs());
}
});
volumeSlider.addEventListener('input', (e) => {
c15.setMasterVolume(parseFloat(e.target.value));
});
arpToggle.addEventListener('click', () => {
if (!c15.running) return;
if (arpeggiator.playing) {
arpeggiator.stop();
arpToggle.textContent = 'Play';
arpToggle.classList.remove('playing');
} else {
arpeggiator.start();
arpToggle.textContent = 'Stop';
arpToggle.classList.add('playing');
}
});
arpProgression.addEventListener('change', (e) => {
arpeggiator.progression = e.target.value;
});
arpTempo.addEventListener('input', (e) => {
const val = parseInt(e.target.value);
arpeggiator.bpm = val;
document.getElementById('tempo-val').textContent = val;
});
arpOctaves.addEventListener('input', (e) => {
const val = parseInt(e.target.value);
arpeggiator.octaves = val;
document.getElementById('octaves-val').textContent = val;
});
arpOffset.addEventListener('input', (e) => {
const val = parseInt(e.target.value);
arpeggiator.octaveOffset = val;
document.getElementById('offset-val').textContent = val;
});
}
// --- Animation loop ---
function animate() {
if (!animating) return;
gamepad.poll();
visualizer.draw();
requestAnimationFrame(animate);
}
// --- Joystick handler ---
function onJoystickMove(x, y) {
iml.setInput(0, x);
iml.setInput(1, y);
iml.process();
const outputs = iml.getOutputs();
routeOutputs(outputs);
// Only update param display from network in inference (not when user is dragging)
if (paramDisplay.activeBar < 0) {
paramDisplay.update(outputs);
}
refreshDashboard();
}
// --- Examples mode callbacks ---
function onAddExample() {
// Use current joystick position as input, param bar values as desired output
const inputs = [joystick.x, joystick.y];
const outputs = [...paramDisplay.values];
iml.addExample(inputs, outputs);
controls.updateStatus(iml.exampleCount, iml.lastLoss, noiseLevel);
refreshDashboard();
flash('btn-add');
}
function onTrain() {
const loss = trainModel();
if (loss !== null) {
// After training, switch back to inference and update display
const outputs = iml.getOutputs();
routeOutputs(outputs);
paramDisplay.update(outputs);
controls.updateStatus(iml.exampleCount, loss, noiseLevel);
controls.updateLossPlot(iml.lossHistory);
refreshDashboard();
flash('btn-train');
}
}
function onRandomize() {
iml.randomiseWeights(spreadLevel);
iml.setInput(0, joystick.x);
iml.setInput(1, joystick.y);
iml.process();
const outputs = iml.getOutputs();
routeOutputs(outputs);
paramDisplay.update(outputs);
noiseLevel = 0.05; // reset noise
controls.updateStatus(iml.exampleCount, iml.lastLoss, noiseLevel);
refreshDashboard();
}
function onClearExamples() {
iml.clearDataset();
controls.updateStatus(0, iml.lastLoss, noiseLevel);
refreshDashboard();
}
function onClear() {
iml.clearDataset();
iml.lossHistory = [];
iml.bestLoss = null;
iml.totalTrainingIterations = 0;
noiseLevel = 0.05;
controls.updateStatus(0, null, noiseLevel);
controls.updateLossPlot(iml.lossHistory);
refreshDashboard();
clearState();
}
// --- RL mode callbacks ---
function onThumbsUp() {
// Save current input->output mapping as a positive example
const inputs = [joystick.x, joystick.y];
const outputs = [...iml.getOutputs()];
iml.addExample(inputs, outputs);
// Retrain incrementally
trainModel();
const trainedOutputs = iml.getOutputs();
routeOutputs(trainedOutputs);
paramDisplay.update(trainedOutputs);
// Decay noise - more positive examples = less exploration
noiseLevel *= rlExplorationDecay;
noiseLevel = Math.max(noiseLevel, 0.005);
controls.updateStatus(iml.exampleCount, iml.lastLoss, noiseLevel);
controls.updateLossPlot(iml.lossHistory);
refreshDashboard();
flash('btn-thumbsup');
}
function onThumbsDown() {
// Increase noise for more exploration
// spread reduces the noise cap: at spread=1 cap is 0.05 (vs 0.3 at spread=0)
const noiseCap = 0.3 * (1 - spreadLevel) + 0.05 * spreadLevel;
noiseLevel = Math.min(noiseLevel * 1.5, noiseCap);
// Perturb weights (spread scales noise per-layer by 1/sqrt(fan_in))
iml.moveWeights(noiseLevel, spreadLevel);
const outputs = iml.getOutputs();
routeOutputs(outputs);
paramDisplay.update(outputs);
controls.updateStatus(iml.exampleCount, iml.lastLoss, noiseLevel);
refreshDashboard();
flash('btn-thumbsdown');
}
function onKeyDown(e) {
if (e.key === 'Escape' && visualExpanded) {
setVisualExpanded(false);
return;
}
if (!followMode || e.repeat) return;
const tag = e.target.tagName;
if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA') return;
if (e.key === '1' || e.code === 'Numpad1') {
e.preventDefault();
onThumbsDown();
} else if (e.key === '2' || e.code === 'Numpad2') {
e.preventDefault();
onThumbsUp();
}
}
function resizeVisualizerSurface() {
visualizer.resize();
visualizer.initParticles();
}
function updateExpandButtonState() {
if (!expandVisualBtn) return;
expandVisualBtn.textContent = visualExpanded ? 'Collapse' : 'Expand';
expandVisualBtn.title = visualExpanded ? 'Collapse visual area' : 'Expand visual area';
expandVisualBtn.setAttribute('aria-pressed', visualExpanded ? 'true' : 'false');
}
function setVisualExpanded(expanded) {
visualExpanded = expanded;
if (appRoot) {
appRoot.classList.toggle('expanded', visualExpanded);
}
updateExpandButtonState();
// Let CSS layout settle before resizing the drawing surface.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
resizeVisualizerSurface();
});
});
}
// --- Presets ---
// Pad a 20-element visual preset array to N_OUTPUTS with 0.5 defaults
function padPreset(values20) {
const padded = new Array(N_OUTPUTS).fill(0.5);
for (let i = 0; i < values20.length; i++) padded[i] = values20[i];
return padded;
}
window.loadPreset = function(name) {
iml.clearDataset();
if (name === 'calm-to-chaotic') {
// Bottom-left: slow, smooth, cool; top-right: fast, turbulent, warm
iml.addExample([0.1, 0.9], padPreset([0.25, 0.3, 0.1, 0.55, 0.2, 0.3, 0.02, 0.05, 0.9, 0.45, 0.25, 0.2, 0.9, 0.0, 0.0, 0.2, 0.05, 0.0, 0.0, 0.2]));
iml.addExample([0.9, 0.1], padPreset([0.75, 0.7, 0.9, 0.05, 0.8, 0.7, 0.9, 0.95, 0.3, 0.2, 0.85, 0.7, 0.25, 0.55, 1.0, 0.92, 0.02, 0.95, 0.8, 0.85]));
iml.addExample([0.5, 0.5], padPreset([0.5, 0.5, 0.5, 0.3, 0.5, 0.5, 0.4, 0.5, 0.7, 0.6, 0.5, 0.45, 0.55, 0.9, 0.5, 0.65, 0.08, 0.45, 0.4, 0.5]));
} else if (name === 'rainbow-sweep') {
// Left to right sweeps through hues
iml.addExample([0.0, 0.5], padPreset([0.5, 0.5, 0.4, 0.0, 0.3, 0.4, 0.05, 0.3, 0.8, 0.55, 0.4, 0.3, 0.8, 0.0, 0.0, 0.45, 0.08, 0.2, 0.25, 0.45]));
iml.addExample([0.5, 0.5], padPreset([0.5, 0.5, 0.4, 0.5, 0.3, 0.4, 0.05, 0.3, 0.8, 0.55, 0.55, 0.35, 0.7, 0.0, 0.4, 0.45, 0.08, 0.45, 0.4, 0.6]));
iml.addExample([1.0, 0.5], padPreset([0.5, 0.5, 0.4, 1.0, 0.3, 0.4, 0.05, 0.3, 0.8, 0.55, 0.75, 0.45, 0.6, 0.0, 0.8, 0.45, 0.08, 0.7, 0.55, 0.75]));
} else if (name === 'vortex') {
// Center: tight spiral, edges: wide flow
iml.addExample([0.5, 0.5], padPreset([0.0, 0.8, 0.8, 0.6, 0.1, 0.15, 0.02, 1.0, 1.0, 0.3, 0.95, 0.85, 0.25, 1.0, 0.5, 0.95, 0.01, 1.0, 1.0, 1.0]));
iml.addExample([0.0, 0.0], padPreset([0.5, 0.2, 0.3, 0.8, 0.9, 0.6, 0.08, 0.1, 0.35, 0.8, 0.25, 0.15, 0.8, 0.5, 0.2, 0.35, 0.2, 0.15, 0.2, 0.25]));
iml.addExample([1.0, 1.0], padPreset([0.5, 0.2, 0.3, 0.2, 0.9, 0.6, 0.08, 0.1, 0.35, 0.8, 0.25, 0.15, 0.8, 0.5, 0.8, 0.35, 0.2, 0.15, 0.2, 0.25]));
iml.addExample([0.0, 1.0], padPreset([0.3, 0.4, 0.5, 0.4, 0.5, 0.4, 0.05, 0.5, 0.65, 0.5, 0.55, 0.45, 0.45, 0.2, 0.4, 0.7, 0.1, 0.5, 0.4, 0.55]));
iml.addExample([1.0, 0.0], padPreset([0.7, 0.4, 0.5, 0.0, 0.5, 0.4, 0.05, 0.5, 0.65, 0.5, 0.55, 0.45, 0.45, 0.2, 0.9, 0.7, 0.1, 0.5, 0.4, 0.55]));
}
const loss = trainModel();
const outputs = iml.getOutputs();
routeOutputs(outputs);
paramDisplay.update(outputs);
controls.updateStatus(iml.exampleCount, loss, noiseLevel);
controls.updateLossPlot(iml.lossHistory);
refreshDashboard();
};
// --- Persistence ---
function saveState() {
try {
const state = {
features: iml.dataset.features,
labels: iml.dataset.labels,
};
localStorage.setItem('nisps-playground', JSON.stringify(state));
} catch (e) { /* ignore */ }
}
function loadState() {
try {
const data = JSON.parse(localStorage.getItem('nisps-playground'));
if (data && data.features && data.features.length > 0) {
for (let i = 0; i < data.features.length; i++) {
// Pad old saves (20 outputs) to current N_OUTPUTS with 0.5 defaults
const labels = data.labels[i];
if (labels.length < N_OUTPUTS) {
while (labels.length < N_OUTPUTS) labels.push(0.5);
}
iml.addExample(data.features[i], labels);
}
trainModel();
const outputs = iml.getOutputs();
routeOutputs(outputs);
paramDisplay.update(outputs);
controls.updateStatus(iml.exampleCount, iml.lastLoss, noiseLevel);
controls.updateLossPlot(iml.lossHistory);
refreshDashboard();
}
} catch (e) { /* ignore */ }
}
function clearState() {
try { localStorage.removeItem('nisps-playground'); } catch (e) { /* ignore */ }
}
// Auto-save periodically
setInterval(saveState, 10000);
// Visual feedback flash
function flash(id) {
const el = document.getElementById(id);
if (!el) return;
el.classList.add('flash');
setTimeout(() => el.classList.remove('flash'), 200);
}
function trainModel() {
let lastPlotUpdate = 0;
const loss = iml.train({
onIteration: (iter, iterLoss) => {
if (iter - lastPlotUpdate < 8) return;
lastPlotUpdate = iter;
controls.updateLossPlot([...iml.lossHistory, iterLoss]);
},
});
return loss;
}
function refreshDashboard() {
const outputs = iml.getOutputs();
let mean = 0;
for (let i = 0; i < outputs.length; i++) mean += outputs[i];
mean /= Math.max(outputs.length, 1);
let variance = 0;
for (let i = 0; i < outputs.length; i++) {
const diff = outputs[i] - mean;
variance += diff * diff;
}
variance /= Math.max(outputs.length, 1);
controls.updateMetrics({
joystickX: joystick.x,
joystickY: joystick.y,
outputMean: mean,
outputSpread: Math.sqrt(variance),
bestLoss: iml.bestLoss,
totalTrainingIterations: iml.totalTrainingIterations,
gamepadConnected,
followMode,
});
}
// --- Start ---
document.addEventListener('DOMContentLoaded', () => {
init();
// Always enable param dragging
paramDisplay.setDraggable(true);
});