Merge Examples/RL tabs into single unified toolbar across all four UIs. Users can now freely mix supervised learning (Add Example + Train) with RL feedback (thumbs up/down) without switching modes. Param bar dragging is always enabled. Add Web MIDI input module (js/synth/midi-input.js) for external MIDI controllers — routes note on/off to C15 synth, CC 1/2 to joystick. Add gamepad module (js/ui/gamepad.js) with auto-detection, deadzone, and axis normalization across all UIs. Fix synth parameter sync: routeOutputs() now called after trainModel() in onThumbsUp and loadState to prevent stale params on first joystick move. Add separate Clear Examples button (dataset only, keeps weights).
601 lines
19 KiB
JavaScript
601 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;
|
|
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);
|
|
});
|