- Insert DynamicsCompressorNode as always-on brick-wall limiter after master gain (threshold -6dB, ratio 20:1, 2ms attack) - Add devmode tame parameter (0-1) controlling range constraining: - ?tame=0.7 URL param (default) or window.setTameLevel(n) - 0 = no mitigation, 1 = strongest constraining - Constrain 20 volume/buildup-sensitive params with safeMin/safeMax: - Envelope release/decay2 times capped to prevent infinite ringing - Envelope gains kept in moderate range - All 5 drive stages (ShpA/B, FB, Out, Cabinet) capped - Output mixer levels given floor and ceiling - Echo feedback and reverb size limited to prevent wash buildup - Fix Comb Filter group count (8, not 9) in comments and color generator
641 lines
20 KiB
JavaScript
641 lines
20 KiB
JavaScript
// NISPS Playground - Main application
|
|
// Wires IML engine to visual system OR C15 synth with joystick input and dual learning modes
|
|
|
|
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';
|
|
|
|
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 = ['#00ff88', '#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 learningMode = 'examples'; // 'examples' | 'rl'
|
|
let outputMode = 'visual'; // 'visual' | 'synth'
|
|
let noiseLevel = 0.05;
|
|
let rlExplorationDecay = 0.97;
|
|
let animating = true;
|
|
let gamepadIndex = -1;
|
|
let gamepadButtonsPrev = [];
|
|
let gamepadConnected = false;
|
|
let gamepadLastAxes = [0.5, 0.5];
|
|
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)
|
|
let tameLevel = parseFloat(new URLSearchParams(location.search).get('tame') ?? '0.7');
|
|
if (isNaN(tameLevel)) tameLevel = 0.7;
|
|
tameLevel = Math.max(0, Math.min(1, tameLevel));
|
|
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,
|
|
onThumbsUp,
|
|
onThumbsDown,
|
|
onModeChange,
|
|
});
|
|
|
|
// 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();
|
|
|
|
window.addEventListener('gamepadconnected', () => refreshDashboard());
|
|
window.addEventListener('gamepaddisconnected', () => 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(learningMode === 'examples');
|
|
} 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(learningMode === 'examples');
|
|
}
|
|
|
|
// 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;
|
|
pollGamepad();
|
|
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 (learningMode !== 'examples' || 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();
|
|
const outputs = iml.getOutputs();
|
|
routeOutputs(outputs);
|
|
paramDisplay.update(outputs);
|
|
noiseLevel = 0.05; // reset noise
|
|
controls.updateStatus(iml.exampleCount, 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();
|
|
|
|
// 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
|
|
noiseLevel = Math.min(noiseLevel * 1.5, 0.3);
|
|
|
|
// Perturb weights
|
|
iml.moveWeights(noiseLevel);
|
|
|
|
const outputs = iml.getOutputs();
|
|
routeOutputs(outputs);
|
|
paramDisplay.update(outputs);
|
|
controls.updateStatus(iml.exampleCount, iml.lastLoss, noiseLevel);
|
|
refreshDashboard();
|
|
flash('btn-thumbsdown');
|
|
}
|
|
|
|
function onModeChange(mode) {
|
|
learningMode = mode;
|
|
if (mode === 'examples') {
|
|
paramDisplay.setDraggable(true);
|
|
} else {
|
|
paramDisplay.setDraggable(false);
|
|
}
|
|
controls.updateStatus(iml.exampleCount, iml.lastLoss, noiseLevel);
|
|
refreshDashboard();
|
|
}
|
|
|
|
function onKeyDown(e) {
|
|
if (e.key === 'Escape' && visualExpanded) {
|
|
setVisualExpanded(false);
|
|
return;
|
|
}
|
|
|
|
if (!followMode || learningMode !== 'rl' || 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({
|
|
mode: learningMode,
|
|
joystickX: joystick.x,
|
|
joystickY: joystick.y,
|
|
outputMean: mean,
|
|
outputSpread: Math.sqrt(variance),
|
|
bestLoss: iml.bestLoss,
|
|
totalTrainingIterations: iml.totalTrainingIterations,
|
|
gamepadConnected,
|
|
followMode,
|
|
});
|
|
}
|
|
|
|
function pollGamepad() {
|
|
if (!navigator.getGamepads) return;
|
|
const gamepads = navigator.getGamepads();
|
|
let gp = null;
|
|
|
|
if (gamepadIndex >= 0 && gamepads[gamepadIndex] && gamepads[gamepadIndex].connected) {
|
|
gp = gamepads[gamepadIndex];
|
|
} else {
|
|
gamepadIndex = -1;
|
|
for (let i = 0; i < gamepads.length; i++) {
|
|
if (gamepads[i] && gamepads[i].connected) {
|
|
gp = gamepads[i];
|
|
gamepadIndex = i;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
const wasConnected = gamepadConnected;
|
|
gamepadConnected = !!gp;
|
|
if (wasConnected !== gamepadConnected) refreshDashboard();
|
|
if (!gp) {
|
|
gamepadButtonsPrev = [];
|
|
return;
|
|
}
|
|
|
|
const deadzone = 0.08;
|
|
const rawX = gp.axes[0] || 0;
|
|
const rawY = gp.axes[1] || 0;
|
|
const axisX = Math.abs(rawX) < deadzone ? 0 : rawX;
|
|
const axisY = Math.abs(rawY) < deadzone ? 0 : rawY;
|
|
const mappedX = (axisX + 1) * 0.5;
|
|
const mappedY = (axisY + 1) * 0.5;
|
|
|
|
const moved = Math.abs(mappedX - gamepadLastAxes[0]) > 0.002 || Math.abs(mappedY - gamepadLastAxes[1]) > 0.002;
|
|
if (moved && paramDisplay.activeBar < 0) {
|
|
gamepadLastAxes = [mappedX, mappedY];
|
|
joystick.setPosition(mappedX, mappedY, { emit: true, touching: true });
|
|
} else if (!moved && joystick.touching) {
|
|
joystick.setPosition(joystick.x, joystick.y, { emit: false, touching: false });
|
|
}
|
|
|
|
// Standard gamepad mapping: LB=4, RB=5
|
|
const lbPressed = !!gp.buttons[4]?.pressed;
|
|
const rbPressed = !!gp.buttons[5]?.pressed;
|
|
const lbPrev = !!gamepadButtonsPrev[4];
|
|
const rbPrev = !!gamepadButtonsPrev[5];
|
|
|
|
if (learningMode === 'rl') {
|
|
if (rbPressed && !rbPrev) onThumbsUp();
|
|
if (lbPressed && !lbPrev) onThumbsDown();
|
|
}
|
|
|
|
gamepadButtonsPrev[4] = lbPressed;
|
|
gamepadButtonsPrev[5] = rbPressed;
|
|
}
|
|
|
|
// --- Start ---
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
init();
|
|
// Start in examples mode with draggable params
|
|
paramDisplay.setDraggable(true);
|
|
});
|