Adds a secondary Synth mode (toggled via collapsible left side panel) where the NISPS ML engine's 20 outputs control curated C15 synthesizer parameters (oscillator PM, shapers, filters, reverb, echo, flanger, output mixer) through a WASM AudioWorklet bridge. Includes a chord progression arpeggiator with controls for tempo (BPM), octave range, octave offset, and 4 selectable progressions. The C15 engine runs in an AudioWorklet with lock-free SharedArrayBuffer ring buffer communication. New files: c15/ (WASM assets), js/synth/ (bridge, arpeggiator, param map), serve-coop.py (COOP/COEP headers for SharedArrayBuffer support).
616 lines
19 KiB
JavaScript
616 lines
19 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 } from './synth/param-map.js';
|
|
|
|
const N_INPUTS = 2;
|
|
const N_OUTPUTS = 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;
|
|
|
|
// --- Init ---
|
|
function init() {
|
|
iml = new IML(N_INPUTS, N_OUTPUTS, [10, 10, 14], 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
|
|
paramDisplay = new ParamDisplay(document.getElementById('param-display'), N_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 synth labels/colors
|
|
paramDisplay.setNamesAndColors(SYNTH_PARAM_NAMES, SYNTH_PARAM_COLORS);
|
|
} 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');
|
|
|
|
// Restore visual param labels/colors
|
|
paramDisplay.setNamesAndColors(VISUAL_PARAM_NAMES, VISUAL_PARAM_COLORS);
|
|
}
|
|
|
|
// 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
|
|
if (outputMode === 'synth' && c15 && c15.running) {
|
|
for (let i = 0; i < outputs.length && i < SYNTH_PARAM_MAP.length; i++) {
|
|
c15.setParameter(SYNTH_PARAM_MAP[i].id, outputs[i]);
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- 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 ---
|
|
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], [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], [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], [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], [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], [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], [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], [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], [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], [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], [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], [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++) {
|
|
iml.addExample(data.features[i], data.labels[i]);
|
|
}
|
|
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);
|
|
});
|