+
+
+ 👎 1
+ 👍 2
+
+
@@ -121,15 +124,6 @@
-
-
-
Like what you see? 👍 Want something different? 👎
-
- 👎 1
- 👍 2
-
-
-
@@ -157,13 +151,22 @@
Tempo 120
+
+ MIDI Input
+ Enable
+
+
+
+
+
0 examples
Randomize
- Clear
+ Clear Examples
+ Clear All
diff --git a/playground/css/a-immersive.css b/playground/css/a-immersive.css
index 6b78cf7..b141e5c 100644
--- a/playground/css/a-immersive.css
+++ b/playground/css/a-immersive.css
@@ -399,6 +399,11 @@ html, body {
color: var(--accent);
}
+.float-btn.accent {
+ border-color: rgba(255, 106, 0, 0.3);
+ color: var(--accent);
+}
+
.follow-pill {
padding: 4px 12px;
border-radius: 6px;
diff --git a/playground/css/b-workbench.css b/playground/css/b-workbench.css
index d549a1f..cd01ecf 100644
--- a/playground/css/b-workbench.css
+++ b/playground/css/b-workbench.css
@@ -506,31 +506,6 @@ body.synth-mode {
gap: 0;
}
-.wb-mode-toggle {
- display: flex;
- padding: 8px 12px 4px;
- gap: 4px;
-}
-
-.wb-mode-btn {
- flex: 1;
- padding: 6px 12px;
- border: 1px solid var(--border-light);
- background: var(--bg);
- color: var(--text-dim);
- font-size: 12px;
- font-family: inherit;
- cursor: pointer;
- border-radius: var(--radius-sm);
- transition: all var(--transition);
-}
-.wb-mode-btn.active {
- background: var(--accent);
- color: var(--bg);
- border-color: var(--accent);
- font-weight: 600;
-}
-
.wb-action-group {
display: flex;
flex-wrap: wrap;
diff --git a/playground/css/c-journey.css b/playground/css/c-journey.css
index 701dd2f..5d3aad0 100644
--- a/playground/css/c-journey.css
+++ b/playground/css/c-journey.css
@@ -327,13 +327,6 @@ html, body {
opacity: 1;
}
-/* Phase 2 RL: left side, same as examples */
-.app.phase-teach[data-teach-mode="rl"] .joystick-area {
- left: 16px;
- transform: none;
- top: calc(40vh + 16px);
-}
-
/* Phase 3: ghost, bottom-center, smaller */
.app.phase-perform .joystick-area {
bottom: calc(60px + var(--safe-bottom));
@@ -362,10 +355,6 @@ html, body {
transition: all var(--transition-fast);
}
-.app[data-teach-mode="rl"] .noise-ring {
- opacity: 0.5;
-}
-
/* ============================================================
Teach panel
============================================================ */
@@ -393,33 +382,6 @@ html, body {
transform: translateY(0);
}
-/* Teach tabs */
-.teach-tabs {
- display: flex;
- gap: 0;
- margin: 12px 0;
- background: rgba(255, 255, 255, 0.04);
- border-radius: var(--radius-pill);
- overflow: hidden;
- border: 1px solid var(--glass-border);
-}
-
-.teach-tab {
- flex: 1;
- padding: 8px 0;
- font-size: 13px;
- color: var(--text-dim);
- background: transparent;
- border: none;
- cursor: pointer;
- transition: all var(--transition-fast);
-}
-
-.teach-tab.active {
- color: var(--accent);
- background: var(--accent-dim);
-}
-
/* Teach content sections */
.teach-content {
transition: opacity var(--transition-fast);
@@ -432,10 +394,6 @@ html, body {
padding-left: 136px; /* room for joystick on left */
}
-.app.phase-teach[data-teach-mode="rl"] .teach-layout {
- padding-left: 136px;
-}
-
/* Preset grid */
.preset-grid {
display: grid;
@@ -546,22 +504,7 @@ html, body {
cursor: pointer;
}
-/* RL sub-mode */
-#teach-rl {
- padding-left: 136px;
-}
-
-.rl-hint {
- text-align: center;
- color: var(--text-dim);
- font-size: 13px;
- margin: 12px 0;
-}
-
-.thumb-icon {
- font-size: 16px;
-}
-
+/* RL buttons (now part of unified teach panel) */
.rl-buttons {
display: flex;
justify-content: center;
diff --git a/playground/css/style.css b/playground/css/style.css
index bacf701..a2e36f3 100644
--- a/playground/css/style.css
+++ b/playground/css/style.css
@@ -538,13 +538,11 @@ html, body {
transform-origin: top left;
}
-.app.expanded .controls-mode-toggle,
.app.expanded .controls-actions {
margin-bottom: 4px;
}
-.app.expanded .btn,
-.app.expanded .mode-btn {
+.app.expanded .btn {
font-size: 10px;
padding: 5px 10px;
border-radius: 6px;
@@ -571,30 +569,6 @@ html, body {
min-width: 0;
}
-.controls-mode-toggle {
- display: flex;
- gap: 4px;
- margin-bottom: 8px;
-}
-
-.mode-btn {
- flex: 1;
- padding: 6px 0;
- background: var(--bg-elevated);
- border: 1px solid #333;
- color: var(--text-dim);
- font-size: 12px;
- border-radius: 6px;
- cursor: pointer;
- transition: all 0.15s;
-}
-
-.mode-btn.active {
- background: rgba(0, 255, 136, 0.1);
- border-color: var(--accent-dim);
- color: var(--accent);
-}
-
.controls-actions {
display: flex;
flex-wrap: wrap;
diff --git a/playground/js/a-app.js b/playground/js/a-app.js
index 9be6394..60915d9 100644
--- a/playground/js/a-app.js
+++ b/playground/js/a-app.js
@@ -5,7 +5,9 @@ import { IML } from './nisps/iml.js';
import { FlowFieldVisualizer } from './ui/visualizer.js';
import { C15Bridge } from './synth/c15-bridge.js';
import { Arpeggiator } from './synth/arpeggiator.js';
+import { MIDIInput } from './synth/midi-input.js';
import { SYNTH_PARAM_MAP, SYNTH_PARAM_NAMES, SYNTH_PARAM_COLORS, applyTame } from './synth/param-map.js';
+import { GamepadInput } from './ui/gamepad.js';
// ---- Constants ----
const N_INPUTS = 2;
@@ -63,8 +65,8 @@ let visualizer;
let synthVisualizer;
let c15 = null;
let arpeggiator = null;
+let midiInput = null;
-let learningMode = 'rl';
let outputMode = 'visual';
let tameLevel = 0;
let spreadLevel = 0.6;
@@ -82,10 +84,7 @@ let joyTrail = [];
let sheetExpanded = false;
// Gamepad
-let gamepadIndex = -1;
-let gamepadButtonsPrev = [];
-let gamepadConnected = false;
-let gamepadLastAxes = [0.5, 0.5];
+let gamepad;
// Raw param slider values (for examples mode advanced editing)
let rawParamValues = new Array(N_OUTPUTS).fill(0.5);
@@ -97,7 +96,6 @@ let $noiseRing, $followBadge;
let $rlButtons, $btnThumbsUp, $btnThumbsDown;
let $bottomSheet, $statusText;
let $sheetContent;
-let $examplesActions, $presetRow;
let $synthPanel, $lossCanvas, $lossCtx;
let $rawParams;
let $floatingBar, $chevronBtn, $followPill;
@@ -297,7 +295,6 @@ class SynthVisualizer {
this.canvas.style.cursor = 'crosshair';
this._onPointerDown = (e) => {
- if (learningMode !== 'examples') return;
const idx = this.hitTest(e.clientX, e.clientY);
if (idx < 0) return;
e.preventDefault();
@@ -385,6 +382,8 @@ function init() {
};
c15.loadParams();
arpeggiator = new Arpeggiator(c15);
+ midiInput = new MIDIInput(c15);
+ initMIDIControls();
// DOM refs
$heatmapCells = document.getElementById('heatmap-cells');
@@ -404,8 +403,6 @@ function init() {
$statusText = document.getElementById('status-text');
$sheetContent = document.getElementById('sheet-content');
- $examplesActions = document.getElementById('examples-actions');
- $presetRow = document.getElementById('preset-row');
$synthPanel = document.getElementById('synth-panel');
$lossCanvas = document.getElementById('loss-canvas');
$lossCtx = $lossCanvas.getContext('2d');
@@ -695,10 +692,7 @@ function onJoystickMove() {
routeOutputs(outputs);
updateHeatmap(outputs);
- // Update raw param sliders if not manually editing
- if (learningMode !== 'examples') {
- syncRawParamsFromOutputs(outputs);
- }
+ syncRawParamsFromOutputs(outputs);
// Trail
joyTrail.push({ x: joyX, y: joyY, t: Date.now() });
@@ -744,16 +738,6 @@ function wireBottomSheet() {
// ---- Controls wiring ----
function wireControls() {
- // Learning mode toggle
- document.querySelectorAll('#learning-toggle .pill-opt').forEach(btn => {
- btn.addEventListener('click', () => {
- document.querySelectorAll('#learning-toggle .pill-opt').forEach(b => b.classList.remove('active'));
- btn.classList.add('active');
- learningMode = btn.dataset.mode;
- updateModeUI();
- });
- });
-
// Output mode toggle (sheet)
document.querySelectorAll('#output-toggle .pill-opt').forEach(btn => {
btn.addEventListener('click', () => {
@@ -773,7 +757,10 @@ function wireControls() {
// Action buttons
document.getElementById('btn-add-example').addEventListener('click', onAddExample);
document.getElementById('btn-train').addEventListener('click', onTrain);
+ document.getElementById('btn-train-bar').addEventListener('click', onTrain);
document.getElementById('btn-clear').addEventListener('click', onClear);
+ document.getElementById('btn-clear-examples').addEventListener('click', onClearExamples);
+ document.getElementById('btn-clear-examples-bar').addEventListener('click', onClearExamples);
document.getElementById('btn-randomize').addEventListener('click', onRandomize);
document.getElementById('btn-randomize-bar').addEventListener('click', onRandomize);
@@ -786,25 +773,8 @@ function wireControls() {
chip.addEventListener('click', () => loadPreset(chip.dataset.preset));
});
- updateModeUI();
-}
-
-function updateModeUI() {
- if (learningMode === 'rl') {
- $rlButtons.classList.remove('hidden');
- $examplesActions.style.display = 'none';
- $presetRow.style.display = 'none';
- updateNoiseRing();
- } else {
- $rlButtons.classList.add('hidden');
- $examplesActions.style.display = '';
- $presetRow.style.display = '';
- $noiseRing.className = 'noise-ring';
- }
- // Enable/disable synth bar interaction based on mode
- if (outputMode === 'synth') {
- synthVisualizer.enableInteraction(learningMode === 'examples');
- }
+ // Initial noise ring update
+ updateNoiseRing();
}
function syncOutputToggles(mode) {
@@ -826,7 +796,7 @@ function setOutputMode(mode) {
$synthVisCanvas.classList.add('active');
heatmapStrip.classList.add('hidden');
synthQuickControls.classList.remove('hidden');
- synthVisualizer.enableInteraction(learningMode === 'examples');
+ synthVisualizer.enableInteraction(true);
} else {
$synthPanel.classList.add('hidden');
$canvas.classList.remove('hidden-canvas');
@@ -842,13 +812,9 @@ function setOutputMode(mode) {
}
function updateNoiseRing() {
- if (learningMode !== 'rl') {
- $noiseRing.className = 'noise-ring';
- return;
- }
if (noiseLevel > 0.15) {
$noiseRing.className = 'noise-ring active high';
- } else if (noiseLevel > 0.03) {
+ } else if (noiseLevel > 0.01) {
$noiseRing.className = 'noise-ring active';
} else {
$noiseRing.className = 'noise-ring';
@@ -892,6 +858,12 @@ function onRandomize() {
updateStatus();
}
+function onClearExamples() {
+ iml.clearDataset();
+ updateStatus();
+ drawJoyMap();
+}
+
function onClear() {
iml.clearDataset();
iml.lossHistory = [];
@@ -911,6 +883,10 @@ function onThumbsUp() {
iml.addExample(inputs, outputs);
trainModel();
+ const trainedOutputs = iml.getOutputs();
+ routeOutputs(trainedOutputs);
+ updateHeatmap(trainedOutputs);
+ syncRawParamsFromOutputs(trainedOutputs);
noiseLevel *= rlExplorationDecay;
noiseLevel = Math.max(noiseLevel, 0.005);
@@ -980,9 +956,7 @@ function updateStatus() {
text += ' \u00b7 untrained';
}
- if (learningMode === 'rl') {
- text += ` \u00b7 noise ${noiseLevel.toFixed(3)}`;
- }
+ text += ` \u00b7 noise ${noiseLevel.toFixed(3)}`;
$statusText.textContent = text;
}
@@ -1129,64 +1103,72 @@ function wireSynthControls() {
});
}
-// ---- Gamepad ----
-function wireGamepad() {
- window.addEventListener('gamepadconnected', () => { gamepadConnected = true; });
- window.addEventListener('gamepaddisconnected', () => { gamepadConnected = false; gamepadIndex = -1; });
+// ---- MIDI Input ----
+async function initMIDIControls() {
+ const row = document.getElementById('midi-row');
+ const statusRow = document.getElementById('midi-status-row');
+ const toggle = document.getElementById('midi-toggle');
+ const select = document.getElementById('midi-select');
+ const statusEl = document.getElementById('midi-status');
+ if (!row || !toggle || !select) return;
+
+ const available = await midiInput.init();
+ if (!available) return;
+
+ row.style.display = '';
+
+ function populateInputs() {
+ const inputs = midiInput.getInputs();
+ select.innerHTML = '
All Inputs ' +
+ inputs.map(i => `
${i.name} `).join('');
+ }
+
+ populateInputs();
+ midiInput.onInputsChange = () => populateInputs();
+
+ midiInput.onStatusChange = (msg) => {
+ if (statusEl) {
+ statusEl.textContent = msg;
+ statusRow.style.display = '';
+ }
+ };
+
+ midiInput.onCC = (cc, value) => {
+ if (cc === 1) { joyX = value; onJoystickMove(); drawJoyMap(); }
+ if (cc === 2) { joyY = value; onJoystickMove(); drawJoyMap(); }
+ };
+
+ toggle.addEventListener('click', () => {
+ if (!c15.running) return;
+ midiInput.toggle();
+ toggle.textContent = midiInput.enabled ? 'Disable' : 'Enable';
+ toggle.classList.toggle('playing', midiInput.enabled);
+ });
+
+ select.addEventListener('change', (e) => {
+ midiInput.selectInput(e.target.value || null);
+ });
}
-function pollGamepad() {
- if (!navigator.getGamepads) return;
- const gamepads = navigator.getGamepads();
- let gp = null;
-
- if (gamepadIndex >= 0 && gamepads[gamepadIndex]?.connected) {
- gp = gamepads[gamepadIndex];
- } else {
- gamepadIndex = -1;
- for (let i = 0; i < gamepads.length; i++) {
- if (gamepads[i]?.connected) {
- gp = gamepads[i];
- gamepadIndex = i;
- break;
- }
- }
- }
-
- 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 = 1 - (axisY + 1) * 0.5;
-
- const moved = Math.abs(mappedX - gamepadLastAxes[0]) > 0.002 ||
- Math.abs(mappedY - gamepadLastAxes[1]) > 0.002;
-
- if (moved) {
- gamepadLastAxes = [mappedX, mappedY];
- joyX = mappedX;
- joyY = mappedY;
- drawJoyMap();
- onJoystickMove();
- }
-
- // 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;
+// ---- Gamepad ----
+function wireGamepad() {
+ gamepad = new GamepadInput({
+ invertY: true,
+ onMove: (x, y) => {
+ joyX = x;
+ joyY = y;
+ drawJoyMap();
+ onJoystickMove();
+ },
+ onButton: (btn) => {
+ if (btn === 'rb') onThumbsUp();
+ if (btn === 'lb') onThumbsDown();
+ },
+ onConnectionChange: (connected) => {
+ const el = document.getElementById('gamepad-status');
+ if (el) el.textContent = connected ? 'Gamepad connected' : '';
+ },
+ });
}
// ---- Keyboard ----
@@ -1203,14 +1185,12 @@ function wireKeyboard() {
}
}
- if (learningMode === 'rl') {
- if (e.key === '1' || e.code === 'Numpad1') {
- e.preventDefault();
- onThumbsDown();
- } else if (e.key === '2' || e.code === 'Numpad2') {
- e.preventDefault();
- onThumbsUp();
- }
+ if (e.key === '1' || e.code === 'Numpad1') {
+ e.preventDefault();
+ onThumbsDown();
+ } else if (e.key === '2' || e.code === 'Numpad2') {
+ e.preventDefault();
+ onThumbsUp();
}
});
}
@@ -1290,7 +1270,7 @@ function onResize() {
// ---- Animation ----
function animate() {
- pollGamepad();
+ if (gamepad) gamepad.poll();
if (outputMode === 'synth') {
synthVisualizer.draw();
} else {
@@ -1314,7 +1294,6 @@ function saveState() {
features: iml.dataset.features,
labels: iml.dataset.labels,
noiseLevel,
- learningMode,
outputMode,
joyX,
joyY,
@@ -1338,21 +1317,13 @@ function loadState() {
}
// Retrain with restored data
trainModel();
+ routeOutputs(iml.getOutputs());
}
if (typeof state.noiseLevel === 'number') noiseLevel = state.noiseLevel;
if (typeof state.joyX === 'number') joyX = state.joyX;
if (typeof state.joyY === 'number') joyY = state.joyY;
- // Restore learning mode
- if (state.learningMode && state.learningMode !== learningMode) {
- learningMode = state.learningMode;
- document.querySelectorAll('#learning-toggle .pill-opt').forEach(b => {
- b.classList.toggle('active', b.dataset.mode === learningMode);
- });
- updateModeUI();
- }
-
// Restore output mode
if (state.outputMode && state.outputMode !== outputMode) {
setOutputMode(state.outputMode);
diff --git a/playground/js/app.js b/playground/js/app.js
index cc84cfb..e01a9bc 100644
--- a/playground/js/app.js
+++ b/playground/js/app.js
@@ -1,5 +1,5 @@
// NISPS Playground - Main application
-// Wires IML engine to visual system OR C15 synth with joystick input and dual learning modes
+// 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';
@@ -9,6 +9,7 @@ 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;
@@ -25,15 +26,12 @@ 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 gamepad;
let gamepadConnected = false;
-let gamepadLastAxes = [0.5, 0.5];
let followMode = false;
let visualExpanded = false;
let appRoot;
@@ -88,9 +86,9 @@ function init() {
onTrain,
onRandomize,
onClear,
+ onClearExamples,
onThumbsUp,
onThumbsDown,
- onModeChange,
});
// Resize handling
@@ -126,8 +124,21 @@ function init() {
// Wire synth controls
initSynthControls();
- window.addEventListener('gamepadconnected', () => refreshDashboard());
- window.addEventListener('gamepaddisconnected', () => refreshDashboard());
+ 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
@@ -197,7 +208,7 @@ function setOutputMode(mode) {
// Rebuild param display with all synth params
paramDisplay.rebuild(N_SYNTH_OUTPUTS, SYNTH_PARAM_NAMES, SYNTH_PARAM_COLORS);
- paramDisplay.setDraggable(learningMode === 'examples');
+ paramDisplay.setDraggable(true);
} else {
synthControls.classList.add('hidden');
visualInfo.classList.remove('hidden');
@@ -208,7 +219,7 @@ function setOutputMode(mode) {
// Rebuild param display with visual params (first 20)
paramDisplay.rebuild(N_VISUAL_OUTPUTS, VISUAL_PARAM_NAMES, VISUAL_PARAM_COLORS);
- paramDisplay.setDraggable(learningMode === 'examples');
+ paramDisplay.setDraggable(true);
}
// Re-run inference and route outputs
@@ -296,7 +307,7 @@ function initSynthControls() {
// --- Animation loop ---
function animate() {
if (!animating) return;
- pollGamepad();
+ gamepad.poll();
visualizer.draw();
requestAnimationFrame(animate);
}
@@ -311,7 +322,7 @@ function onJoystickMove(x, y) {
routeOutputs(outputs);
// Only update param display from network in inference (not when user is dragging)
- if (learningMode !== 'examples' || paramDisplay.activeBar < 0) {
+ if (paramDisplay.activeBar < 0) {
paramDisplay.update(outputs);
}
@@ -356,6 +367,12 @@ function onRandomize() {
refreshDashboard();
}
+function onClearExamples() {
+ iml.clearDataset();
+ controls.updateStatus(0, iml.lastLoss, noiseLevel);
+ refreshDashboard();
+}
+
function onClear() {
iml.clearDataset();
iml.lossHistory = [];
@@ -377,6 +394,9 @@ function onThumbsUp() {
// Retrain incrementally
trainModel();
+ const trainedOutputs = iml.getOutputs();
+ routeOutputs(trainedOutputs);
+ paramDisplay.update(trainedOutputs);
// Decay noise - more positive examples = less exploration
noiseLevel *= rlExplorationDecay;
@@ -405,24 +425,13 @@ function onThumbsDown() {
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 (!followMode || e.repeat) return;
if (e.key === '1' || e.code === 'Numpad1') {
e.preventDefault();
onThumbsDown();
@@ -573,7 +582,6 @@ function refreshDashboard() {
variance /= Math.max(outputs.length, 1);
controls.updateMetrics({
- mode: learningMode,
joystickX: joystick.x,
joystickY: joystick.y,
outputMean: mean,
@@ -585,66 +593,9 @@ function refreshDashboard() {
});
}
-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
+ // Always enable param dragging
paramDisplay.setDraggable(true);
});
diff --git a/playground/js/b-app.js b/playground/js/b-app.js
index ffd186e..055a7ef 100644
--- a/playground/js/b-app.js
+++ b/playground/js/b-app.js
@@ -5,7 +5,9 @@ import { IML } from './nisps/iml.js';
import { FlowFieldVisualizer } from './ui/visualizer.js';
import { C15Bridge } from './synth/c15-bridge.js';
import { Arpeggiator } from './synth/arpeggiator.js';
+import { MIDIInput } from './synth/midi-input.js';
import { SYNTH_PARAM_MAP, SYNTH_PARAM_NAMES, SYNTH_PARAM_COLORS, applyTame } from './synth/param-map.js';
+import { GamepadInput } from './ui/gamepad.js';
// --- Constants ---
const N_INPUTS = 2;
@@ -96,7 +98,6 @@ const spreadLevel = Math.max(0, Math.min(1, parseFloat(_urlParams.get('spread')
// --- State ---
let iml;
let visualizer;
-let learningMode = 'rl';
let outputMode = 'visual';
let noiseLevel = 0.05;
const rlExplorationDecay = 0.97;
@@ -109,9 +110,13 @@ let joyDragging = false;
// Follow mode
let followMode = false;
+// Gamepad
+let gamepad;
+
// Synth
let c15 = null;
let arpeggiator = null;
+let midiInput = null;
let synthVisualizer = null;
// Current param values (for dragging)
@@ -586,7 +591,6 @@ function buildParamGroups() {
// Dragging on param tracks
paramsDiv.addEventListener('pointerdown', (e) => {
- if (learningMode !== 'examples') return;
const track = e.target.closest('.wb-param-track');
if (!track) return;
e.preventDefault();
@@ -693,14 +697,7 @@ function onJoystickMove() {
const outputs = iml.getOutputs();
routeOutputs(outputs);
- // In examples mode, only update bars from network if user isn't dragging
- if (learningMode !== 'examples') {
- updateAllParamBars(outputs);
- } else {
- // Still update param values for display but allow overrides
- // Only update non-dragged params
- updateAllParamBars(outputs);
- }
+ updateAllParamBars(outputs);
const constellationN = outputMode === 'synth' ? N_SYNTH_OUTPUTS : N_VISUAL_OUTPUTS;
drawConstellation(constellationCanvas, outputs, constellationN);
@@ -749,6 +746,12 @@ function onRandomize() {
updateMetrics();
}
+function onClearExamples() {
+ iml.clearDataset();
+ mappingView.update();
+ updateMetrics();
+}
+
function onClear() {
iml.clearDataset();
iml.lossHistory = [];
@@ -766,6 +769,9 @@ function onThumbsUp() {
const outputs = [...iml.getOutputs()];
iml.addExample(inputs, outputs);
trainModel();
+ const trainedOutputs = iml.getOutputs();
+ routeOutputs(trainedOutputs);
+ updateAllParamBars(trainedOutputs);
noiseLevel *= rlExplorationDecay;
noiseLevel = Math.max(noiseLevel, 0.005);
drawLossPlot(lossCanvas, iml.lossHistory);
@@ -930,17 +936,6 @@ function setOutputMode(mode) {
mappingView.update();
}
-// ============================
-// Learning mode switching
-// ============================
-function setLearningMode(mode) {
- learningMode = mode;
- document.getElementById('mode-examples').classList.toggle('active', mode === 'examples');
- document.getElementById('mode-rl').classList.toggle('active', mode === 'rl');
- document.getElementById('examples-controls').classList.toggle('hidden', mode !== 'examples');
- document.getElementById('rl-controls').classList.toggle('hidden', mode !== 'rl');
-}
-
// ============================
// Presets
// ============================
@@ -1052,6 +1047,53 @@ function saveState() {
}
}
+// ---- MIDI Input ----
+async function initMIDIControls() {
+ const row = document.getElementById('midi-row');
+ const statusRow = document.getElementById('midi-status-row');
+ const toggle = document.getElementById('midi-toggle');
+ const select = document.getElementById('midi-select');
+ const statusEl = document.getElementById('midi-status');
+ if (!row || !toggle || !select) return;
+
+ const available = await midiInput.init();
+ if (!available) return;
+
+ row.style.display = '';
+
+ function populateInputs() {
+ const inputs = midiInput.getInputs();
+ select.innerHTML = '
All Inputs ' +
+ inputs.map(i => `
${i.name} `).join('');
+ }
+
+ populateInputs();
+ midiInput.onInputsChange = () => populateInputs();
+
+ midiInput.onStatusChange = (msg) => {
+ if (statusEl) {
+ statusEl.textContent = msg;
+ statusRow.style.display = '';
+ }
+ };
+
+ midiInput.onCC = (cc, value) => {
+ if (cc === 1) { joyX = value; onJoystickMove(); }
+ if (cc === 2) { joyY = value; onJoystickMove(); }
+ };
+
+ toggle.addEventListener('click', () => {
+ if (!c15.running) return;
+ midiInput.toggle();
+ toggle.textContent = midiInput.enabled ? 'MIDI: On' : 'Enable';
+ toggle.classList.toggle('wb-btn-primary', midiInput.enabled);
+ });
+
+ select.addEventListener('change', (e) => {
+ midiInput.selectInput(e.target.value || null);
+ });
+}
+
function loadState() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
@@ -1067,6 +1109,7 @@ function loadState() {
iml.addExample(data.features[i], labels);
}
trainModel();
+ routeOutputs(iml.getOutputs());
return true;
} catch (e) {
console.warn('[NISPS] loadState failed:', e);
@@ -1088,10 +1131,8 @@ function clearState() {
window.addEventListener('keydown', (e) => {
if (e.repeat) return;
// RL shortcuts: 1=negative, 2=positive
- if (learningMode === 'rl') {
- if (e.key === '1' || e.code === 'Numpad1') { e.preventDefault(); onThumbsDown(); }
- if (e.key === '2' || e.code === 'Numpad2') { e.preventDefault(); onThumbsUp(); }
- }
+ if (e.key === '1' || e.code === 'Numpad1') { e.preventDefault(); onThumbsDown(); }
+ if (e.key === '2' || e.code === 'Numpad2') { e.preventDefault(); onThumbsUp(); }
// Follow mode toggle: f
if (e.key === 'f' || e.key === 'F') {
// Don't toggle if user is typing in an input
@@ -1106,6 +1147,7 @@ window.addEventListener('keydown', (e) => {
// ============================
function animate() {
if (!animating) return;
+ if (gamepad) gamepad.poll();
if (outputMode === 'visual') {
visualizer.draw();
} else if (synthVisualizer) {
@@ -1146,13 +1188,9 @@ function init() {
document.getElementById('btn-train').addEventListener('click', onTrain);
document.getElementById('btn-randomize').addEventListener('click', onRandomize);
document.getElementById('btn-clear').addEventListener('click', onClear);
+ document.getElementById('btn-clear-examples').addEventListener('click', onClearExamples);
document.getElementById('btn-thumbsup').addEventListener('click', onThumbsUp);
document.getElementById('btn-thumbsdown').addEventListener('click', onThumbsDown);
- document.getElementById('btn-randomize-rl').addEventListener('click', onRandomize);
-
- // Learning mode — RL is default
- document.getElementById('mode-examples').addEventListener('click', () => setLearningMode('examples'));
- document.getElementById('mode-rl').addEventListener('click', () => setLearningMode('rl'));
// Output mode
document.getElementById('toggle-visual').addEventListener('click', () => setOutputMode('visual'));
@@ -1185,7 +1223,27 @@ function init() {
};
c15.loadParams();
arpeggiator = new Arpeggiator(c15);
+ midiInput = new MIDIInput(c15);
initSynthControls();
+ initMIDIControls();
+
+ // Gamepad
+ gamepad = new GamepadInput({
+ onMove: (x, y) => {
+ joyX = x;
+ joyY = y;
+ onJoystickMove();
+ mappingView.drawOverlay();
+ },
+ onButton: (btn) => {
+ if (btn === 'rb') onThumbsUp();
+ if (btn === 'lb') onThumbsDown();
+ },
+ onConnectionChange: (connected) => {
+ const el = document.getElementById('gamepad-status');
+ if (el) el.textContent = connected ? 'Gamepad connected' : '';
+ },
+ });
// Resize
window.addEventListener('resize', () => {
diff --git a/playground/js/c-app.js b/playground/js/c-app.js
index fa18c65..5c41167 100644
--- a/playground/js/c-app.js
+++ b/playground/js/c-app.js
@@ -5,7 +5,9 @@ import { IML } from './nisps/iml.js';
import { FlowFieldVisualizer } from './ui/visualizer.js';
import { C15Bridge } from './synth/c15-bridge.js';
import { Arpeggiator } from './synth/arpeggiator.js';
+import { MIDIInput } from './synth/midi-input.js';
import { SYNTH_PARAM_MAP, SYNTH_PARAM_NAMES, SYNTH_PARAM_COLORS, applyTame } from './synth/param-map.js';
+import { GamepadInput } from './ui/gamepad.js';
// ============================================================
// Constants
@@ -58,10 +60,11 @@ let iml;
let visualizer;
let c15 = null;
let arpeggiator = null;
+let midiInput = null;
let phase = 'explore'; // 'explore' | 'teach' | 'perform'
let outputMode = 'visual'; // 'visual' | 'synth'
-let teachMode = 'rl'; // 'examples' | 'rl'
+// teachMode removed — Examples and RL controls are always visible together
let selectedPreset = -1;
let noiseLevel = 0.05;
let animating = true;
@@ -69,8 +72,9 @@ let joystickX = 0.5;
let joystickY = 0.5;
let joystickActive = false;
let joystickInteractionTime = 0;
+let gamepad;
let ctasShown = false;
-let rlHintShown = false;
+// rlHintShown removed — no separate RL mode hint needed
let idleTimer = null;
let currentParamNames = VISUAL_PARAM_NAMES;
let currentParamColors = VISUAL_PARAM_COLORS;
@@ -238,6 +242,8 @@ function init() {
};
c15.loadParams();
arpeggiator = new Arpeggiator(c15);
+ midiInput = new MIDIInput(c15);
+ initMIDIControls();
// Build heatmap cells
buildHeatmap();
@@ -251,7 +257,6 @@ function init() {
// Wire events
wirePhaseNav();
wireModeToggle();
- wireTeachTabs();
wirePresetGrid();
wireTeachActions();
wireRLButtons();
@@ -273,6 +278,22 @@ function init() {
}
});
+ // Gamepad
+ gamepad = new GamepadInput({
+ onMove: (x, y) => {
+ updateJoystick(x, y);
+ drawJoystick();
+ },
+ onButton: (btn) => {
+ if (btn === 'rb') onThumbsUp();
+ if (btn === 'lb') onThumbsDown();
+ },
+ onConnectionChange: (connected) => {
+ const el = document.getElementById('gamepad-status');
+ if (el) el.textContent = connected ? 'Gamepad connected' : '';
+ },
+ });
+
// Follow mode wiring
wireFollowMode();
@@ -303,7 +324,7 @@ function init() {
function setPhase(p) {
phase = p;
app.className = `app phase-${p}`;
- if (teachMode) app.dataset.teachMode = teachMode;
+ delete app.dataset.teachMode;
// Update dots
document.querySelectorAll('.phase-dot').forEach(dot => {
@@ -506,27 +527,7 @@ function setOutputMode(mode) {
if (audioCtrl) audioCtrl.classList.toggle('hidden', mode !== 'synth' || phase !== 'perform');
}
-// ============================================================
-// Teach tabs
-// ============================================================
-function wireTeachTabs() {
- document.querySelectorAll('.teach-tab').forEach(tab => {
- tab.addEventListener('click', () => {
- teachMode = tab.dataset.tab;
- document.querySelectorAll('.teach-tab').forEach(t => t.classList.toggle('active', t === tab));
- document.getElementById('teach-examples').classList.toggle('hidden', teachMode !== 'examples');
- document.getElementById('teach-rl').classList.toggle('hidden', teachMode !== 'rl');
- app.dataset.teachMode = teachMode;
-
- // Show RL hint only first time
- if (teachMode === 'rl' && !rlHintShown) {
- rlHintShown = true;
- } else if (teachMode === 'rl') {
- document.getElementById('rl-hint')?.classList.add('hidden');
- }
- });
- });
-}
+// Teach tabs removed — unified teaching panel shows all controls together
// ============================================================
// Preset grid
@@ -627,6 +628,12 @@ function wireTeachActions() {
}, 50);
});
+ document.getElementById('btn-clear-examples')?.addEventListener('click', () => {
+ iml.clearDataset();
+ updateTeachStatus();
+ drawMinimap();
+ });
+
document.getElementById('btn-clear')?.addEventListener('click', () => {
iml.clearDataset();
iml.randomiseWeights(spreadLevel);
@@ -710,11 +717,15 @@ function wireRLButtons() {
function updateNoiseRing() {
const ring = document.getElementById('noise-ring');
if (!ring) return;
- const thickness = 2 + noiseLevel * 20;
- const opacity = 0.2 + noiseLevel * 1.2;
- ring.style.borderWidth = `${thickness}px`;
- ring.style.opacity = Math.min(1, opacity);
- ring.style.inset = `${-(8 + thickness)}px`;
+ if (noiseLevel > 0.01) {
+ const thickness = 2 + noiseLevel * 20;
+ const opacity = 0.2 + noiseLevel * 1.2;
+ ring.style.borderWidth = `${thickness}px`;
+ ring.style.opacity = Math.min(1, opacity);
+ ring.style.inset = `${-(8 + thickness)}px`;
+ } else {
+ ring.style.opacity = '0';
+ }
}
// ============================================================
@@ -781,6 +792,55 @@ function wireSynthControls() {
});
}
+// ============================================================
+// MIDI Input
+// ============================================================
+async function initMIDIControls() {
+ const row = document.getElementById('midi-row');
+ const statusRow = document.getElementById('midi-status-row');
+ const toggle = document.getElementById('midi-toggle');
+ const select = document.getElementById('midi-select');
+ const statusEl = document.getElementById('midi-status');
+ if (!row || !toggle || !select) return;
+
+ const available = await midiInput.init();
+ if (!available) return;
+
+ row.style.display = '';
+
+ function populateInputs() {
+ const inputs = midiInput.getInputs();
+ select.innerHTML = '
All Inputs ' +
+ inputs.map(i => `
${i.name} `).join('');
+ }
+
+ populateInputs();
+ midiInput.onInputsChange = () => populateInputs();
+
+ midiInput.onStatusChange = (msg) => {
+ if (statusEl) {
+ statusEl.textContent = msg;
+ statusRow.style.display = '';
+ }
+ };
+
+ midiInput.onCC = (cc, value) => {
+ if (cc === 1) updateJoystick(value, joystickY);
+ if (cc === 2) updateJoystick(joystickX, value);
+ };
+
+ toggle.addEventListener('click', () => {
+ if (!c15.running) return;
+ midiInput.toggle();
+ toggle.textContent = midiInput.enabled ? 'Disable' : 'Enable';
+ toggle.classList.toggle('playing', midiInput.enabled);
+ });
+
+ select.addEventListener('change', (e) => {
+ midiInput.selectInput(e.target.value || null);
+ });
+}
+
// ============================================================
// Perform UI (idle fading, edit button)
// ============================================================
@@ -1079,6 +1139,7 @@ function onResize() {
// ============================================================
function animate() {
if (!animating) return;
+ if (gamepad) gamepad.poll();
if (outputMode === 'synth') {
synthVisualizer.draw();
@@ -1087,7 +1148,7 @@ function animate() {
}
// Keep minimap updated if in teach phase
- if (phase === 'teach' && teachMode === 'examples') {
+ if (phase === 'teach') {
// Only redraw minimap occasionally
if (Math.random() < 0.05) drawMinimap();
}
diff --git a/playground/js/synth/midi-input.js b/playground/js/synth/midi-input.js
new file mode 100644
index 0000000..c441175
--- /dev/null
+++ b/playground/js/synth/midi-input.js
@@ -0,0 +1,157 @@
+// MIDI Input — receives notes and CCs from external MIDI controllers
+// Uses Web MIDI API, routes note on/off to C15 bridge
+
+export class MIDIInput {
+ constructor(bridge) {
+ this.bridge = bridge;
+ this.enabled = false;
+ this.midiAccess = null;
+ this.selectedInputId = null;
+ this.activeInput = null;
+ this._onStatusChange = null;
+ this._onInputsChange = null;
+ this._onCC = null; // optional: (ccNumber, value01) => void
+ this._boundOnMessage = this._onMessage.bind(this);
+ this._boundOnStateChange = this._onStateChange.bind(this);
+ }
+
+ set onStatusChange(fn) { this._onStatusChange = fn; }
+ set onInputsChange(fn) { this._onInputsChange = fn; }
+ set onCC(fn) { this._onCC = fn; }
+
+ _status(msg) {
+ console.log('[MIDI]', msg);
+ this._onStatusChange?.(msg);
+ }
+
+ /** Request MIDI access. Returns true if available. */
+ async init() {
+ if (this.midiAccess) return true;
+
+ if (!navigator.requestMIDIAccess) {
+ this._status('Web MIDI not supported');
+ return false;
+ }
+
+ try {
+ this.midiAccess = await navigator.requestMIDIAccess({ sysex: false });
+ this.midiAccess.onstatechange = this._boundOnStateChange;
+ this._status('MIDI available');
+ return true;
+ } catch (err) {
+ this._status(`MIDI access denied: ${err.message}`);
+ return false;
+ }
+ }
+
+ /** Get list of available MIDI inputs as [{id, name}] */
+ getInputs() {
+ if (!this.midiAccess) return [];
+ const inputs = [];
+ for (const [id, input] of this.midiAccess.inputs) {
+ inputs.push({ id, name: input.name || `MIDI Input ${id}` });
+ }
+ return inputs;
+ }
+
+ /** Select a specific MIDI input by id, or null for all inputs */
+ selectInput(inputId) {
+ // Disconnect previous
+ this._disconnectInput();
+
+ this.selectedInputId = inputId;
+
+ if (!this.enabled) return;
+ this._connectInput();
+ }
+
+ /** Enable MIDI input routing to the synth */
+ enable() {
+ if (this.enabled) return;
+ this.enabled = true;
+ this._connectInput();
+ this._status('MIDI enabled');
+ }
+
+ /** Disable MIDI input routing */
+ disable() {
+ if (!this.enabled) return;
+ this.enabled = false;
+ this._disconnectInput();
+ this._status('MIDI disabled');
+ }
+
+ toggle() {
+ if (this.enabled) this.disable();
+ else this.enable();
+ }
+
+ _connectInput() {
+ if (!this.midiAccess || !this.enabled) return;
+
+ if (this.selectedInputId === null) {
+ // Listen on all inputs
+ for (const [, input] of this.midiAccess.inputs) {
+ input.onmidimessage = this._boundOnMessage;
+ }
+ this.activeInput = null;
+ } else {
+ const input = this.midiAccess.inputs.get(this.selectedInputId);
+ if (input) {
+ input.onmidimessage = this._boundOnMessage;
+ this.activeInput = input;
+ }
+ }
+ }
+
+ _disconnectInput() {
+ if (!this.midiAccess) return;
+ for (const [, input] of this.midiAccess.inputs) {
+ input.onmidimessage = null;
+ }
+ this.activeInput = null;
+ }
+
+ _onStateChange(e) {
+ // MIDI device connected/disconnected
+ this._onInputsChange?.(this.getInputs());
+
+ if (this.enabled) {
+ // Reconnect in case the selected device was re-plugged
+ this._disconnectInput();
+ this._connectInput();
+ }
+ }
+
+ _onMessage(e) {
+ const [status, data1, data2] = e.data;
+ const type = status & 0xf0;
+
+ switch (type) {
+ case 0x90: // Note On
+ if (data2 > 0) {
+ this.bridge.noteOn(data1, data2 / 127);
+ } else {
+ // Velocity 0 = note off
+ this.bridge.noteOff(data1);
+ }
+ break;
+
+ case 0x80: // Note Off
+ this.bridge.noteOff(data1);
+ break;
+
+ case 0xb0: // Control Change
+ this._onCC?.(data1, data2 / 127);
+ break;
+ }
+ }
+
+ destroy() {
+ this.disable();
+ if (this.midiAccess) {
+ this.midiAccess.onstatechange = null;
+ }
+ this.midiAccess = null;
+ }
+}
diff --git a/playground/js/ui/controls.js b/playground/js/ui/controls.js
index b728765..7eb7412 100644
--- a/playground/js/ui/controls.js
+++ b/playground/js/ui/controls.js
@@ -1,43 +1,33 @@
-// Control panel: mode toggle, buttons, status display
+// Control panel: unified action buttons, status display
export class Controls {
constructor(container, callbacks) {
this.callbacks = callbacks;
- this.mode = 'examples'; // 'examples' or 'rl'
this.el = container;
this.build();
}
build() {
this.el.innerHTML = `
-
- Examples
- RL Feedback
-
-
- Add Example
- Train
- Randomize
- Clear
-
-
+
+
−
- Randomize
- Clear
+ Add Example
+ Train
+ Randomize
+ Clear Examples
+ Clear All
Examples: 0
- Noise: 0.05
+ Noise: 0.05
- Mode
- examples
Joystick
0.50, 0.50
Output Mean
@@ -58,33 +48,14 @@ export class Controls {
`;
- // Mode toggle
- this.el.querySelectorAll('.mode-btn').forEach(btn => {
- btn.addEventListener('click', () => this.setMode(btn.dataset.mode));
- });
-
- // Examples mode buttons
+ // Action buttons
+ this.el.querySelector('#btn-thumbsup').addEventListener('click', () => this.callbacks.onThumbsUp?.());
+ this.el.querySelector('#btn-thumbsdown').addEventListener('click', () => this.callbacks.onThumbsDown?.());
this.el.querySelector('#btn-add').addEventListener('click', () => this.callbacks.onAddExample?.());
this.el.querySelector('#btn-train').addEventListener('click', () => this.callbacks.onTrain?.());
this.el.querySelector('#btn-randomize').addEventListener('click', () => this.callbacks.onRandomize?.());
+ this.el.querySelector('#btn-clear-examples').addEventListener('click', () => this.callbacks.onClearExamples?.());
this.el.querySelector('#btn-clear').addEventListener('click', () => this.callbacks.onClear?.());
-
- // RL mode buttons
- this.el.querySelector('#btn-thumbsup').addEventListener('click', () => this.callbacks.onThumbsUp?.());
- this.el.querySelector('#btn-thumbsdown').addEventListener('click', () => this.callbacks.onThumbsDown?.());
- this.el.querySelector('#btn-rl-randomize').addEventListener('click', () => this.callbacks.onRandomize?.());
- this.el.querySelector('#btn-rl-clear').addEventListener('click', () => this.callbacks.onClear?.());
- }
-
- setMode(mode) {
- this.mode = mode;
- this.el.querySelectorAll('.mode-btn').forEach(btn => {
- btn.classList.toggle('active', btn.dataset.mode === mode);
- });
- this.el.querySelector('#controls-examples').classList.toggle('hidden', mode !== 'examples');
- this.el.querySelector('#controls-rl').classList.toggle('hidden', mode !== 'rl');
- this.el.querySelector('#status-noise').classList.toggle('hidden', mode !== 'rl');
- this.callbacks.onModeChange?.(mode);
}
updateStatus(exampleCount, loss, noiseLevel) {
@@ -105,7 +76,6 @@ export class Controls {
if (el) el.textContent = value;
};
- if (metrics.mode) set('#metric-mode', metrics.mode);
if (metrics.joystickX !== undefined && metrics.joystickY !== undefined) {
set('#metric-joystick', `${metrics.joystickX.toFixed(2)}, ${metrics.joystickY.toFixed(2)}`);
}
diff --git a/playground/js/ui/gamepad.js b/playground/js/ui/gamepad.js
new file mode 100644
index 0000000..82f96ff
--- /dev/null
+++ b/playground/js/ui/gamepad.js
@@ -0,0 +1,157 @@
+// GamepadInput — reusable gamepad polling module for NISPS playground
+// Maps first stick axes to [0,1] with deadzone, polls via rAF, fires callbacks
+
+export class GamepadInput {
+ /**
+ * @param {Object} options
+ * @param {function(number, number)} options.onMove - called with (x, y) in [0,1] when stick moves
+ * @param {function(string)} [options.onButton] - called with button name ('lb'|'rb') on press
+ * @param {function(boolean)} [options.onConnectionChange] - called when gamepad connects/disconnects
+ * @param {number} [options.deadzone=0.08] - axis deadzone threshold
+ * @param {number} [options.moveThreshold=0.002] - minimum mapped change to trigger onMove
+ * @param {number} [options.axisX=0] - gamepad axis index for X
+ * @param {number} [options.axisY=1] - gamepad axis index for Y
+ * @param {boolean} [options.invertY=false] - if true, invert Y axis (up = 0)
+ */
+ constructor(options = {}) {
+ this.onMove = options.onMove || (() => {});
+ this.onButton = options.onButton || null;
+ this.onConnectionChange = options.onConnectionChange || null;
+ this.deadzone = options.deadzone ?? 0.08;
+ this.moveThreshold = options.moveThreshold ?? 0.002;
+ this.axisX = options.axisX ?? 0;
+ this.axisY = options.axisY ?? 1;
+ this.invertY = options.invertY ?? false;
+
+ this.connected = false;
+ this._index = -1;
+ this._lastAxes = [0.5, 0.5];
+ this._buttonsPrev = [];
+ this._polling = false;
+
+ this._onConnected = this._onConnected.bind(this);
+ this._onDisconnected = this._onDisconnected.bind(this);
+
+ window.addEventListener('gamepadconnected', this._onConnected);
+ window.addEventListener('gamepaddisconnected', this._onDisconnected);
+
+ // Check if a gamepad is already connected
+ this._findGamepad();
+ }
+
+ _onConnected(e) {
+ if (this._index < 0) {
+ this._index = e.gamepad.index;
+ }
+ this._setConnected(true);
+ }
+
+ _onDisconnected(e) {
+ if (e.gamepad.index === this._index) {
+ this._index = -1;
+ // Try to find another connected gamepad
+ this._findGamepad();
+ if (this._index < 0) {
+ this._setConnected(false);
+ }
+ }
+ }
+
+ _setConnected(value) {
+ if (this.connected !== value) {
+ this.connected = value;
+ if (this.onConnectionChange) this.onConnectionChange(value);
+ }
+ }
+
+ _findGamepad() {
+ if (!navigator.getGamepads) return;
+ const gamepads = navigator.getGamepads();
+ for (let i = 0; i < gamepads.length; i++) {
+ if (gamepads[i] && gamepads[i].connected) {
+ this._index = i;
+ this._setConnected(true);
+ return;
+ }
+ }
+ }
+
+ /**
+ * Poll the gamepad. Call this once per frame (e.g. inside requestAnimationFrame).
+ * Returns true if stick moved.
+ */
+ poll() {
+ if (!navigator.getGamepads) return false;
+ const gamepads = navigator.getGamepads();
+ let gp = null;
+
+ if (this._index >= 0 && gamepads[this._index]?.connected) {
+ gp = gamepads[this._index];
+ } else {
+ this._index = -1;
+ for (let i = 0; i < gamepads.length; i++) {
+ if (gamepads[i]?.connected) {
+ gp = gamepads[i];
+ this._index = i;
+ break;
+ }
+ }
+ }
+
+ const wasConnected = this.connected;
+ this.connected = !!gp;
+ if (wasConnected !== this.connected) {
+ if (this.onConnectionChange) this.onConnectionChange(this.connected);
+ }
+
+ if (!gp) {
+ this._buttonsPrev = [];
+ return false;
+ }
+
+ // Axes
+ const rawX = gp.axes[this.axisX] || 0;
+ const rawY = gp.axes[this.axisY] || 0;
+ const axisX = Math.abs(rawX) < this.deadzone ? 0 : rawX;
+ const axisY = Math.abs(rawY) < this.deadzone ? 0 : rawY;
+ let mappedX = (axisX + 1) * 0.5;
+ let mappedY = (axisY + 1) * 0.5;
+ if (this.invertY) mappedY = 1 - mappedY;
+
+ const moved = Math.abs(mappedX - this._lastAxes[0]) > this.moveThreshold ||
+ Math.abs(mappedY - this._lastAxes[1]) > this.moveThreshold;
+
+ if (moved) {
+ this._lastAxes = [mappedX, mappedY];
+ this.onMove(mappedX, mappedY);
+ }
+
+ // Buttons: LB=4, RB=5
+ if (this.onButton) {
+ const lbPressed = !!gp.buttons[4]?.pressed;
+ const rbPressed = !!gp.buttons[5]?.pressed;
+ const lbPrev = !!this._buttonsPrev[4];
+ const rbPrev = !!this._buttonsPrev[5];
+
+ if (lbPressed && !lbPrev) this.onButton('lb');
+ if (rbPressed && !rbPrev) this.onButton('rb');
+
+ this._buttonsPrev[4] = lbPressed;
+ this._buttonsPrev[5] = rbPressed;
+ }
+
+ return moved;
+ }
+
+ /** Current mapped X position [0,1] */
+ get x() { return this._lastAxes[0]; }
+
+ /** Current mapped Y position [0,1] */
+ get y() { return this._lastAxes[1]; }
+
+ /** Clean up event listeners */
+ destroy() {
+ window.removeEventListener('gamepadconnected', this._onConnected);
+ window.removeEventListener('gamepaddisconnected', this._onDisconnected);
+ }
+}