feat(playground): unify learning modes, add MIDI input and gamepad support

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).
This commit is contained in:
w1n5t0n 2026-03-22 11:01:50 +02:00
parent cf44abed9e
commit 295221d1b2
14 changed files with 700 additions and 475 deletions

View file

@ -54,6 +54,7 @@
<canvas id="joy-map" width="160" height="160"></canvas>
<div class="noise-ring" id="noise-ring"></div>
<div class="follow-badge hidden" id="follow-badge">FOLLOW</div>
<div id="gamepad-status" style="color: #ff6a00; font-size: 0.6rem; text-align: center; position: absolute; bottom: -16px; left: 0; right: 0; pointer-events: none;"></div>
</div>
<!-- RL floating buttons -->
@ -66,11 +67,9 @@
<div class="bottom-sheet collapsed" id="bottom-sheet">
<!-- Floating mode bar (sticky inside sheet) -->
<div class="floating-bar" id="floating-bar">
<div class="pill-toggle" id="learning-toggle">
<button class="pill-opt" data-mode="examples">Examples</button>
<button class="pill-opt active" data-mode="rl">RL</button>
</div>
<button class="float-btn accent" id="btn-train-bar" title="Train on examples">Train</button>
<button class="float-btn" id="btn-randomize-bar" title="Randomize weights">Randomize</button>
<button class="float-btn" id="btn-clear-examples-bar" title="Clear examples">Clear Ex</button>
<button class="follow-pill" id="follow-pill" title="Toggle follow mode">Follow</button>
<button class="chevron-btn" id="chevron-btn" title="Expand panel">&#9650;</button>
</div>
@ -91,15 +90,16 @@
</div>
<!-- Examples mode actions -->
<div class="action-row" id="examples-actions" style="display:none;">
<div class="action-row" id="examples-actions">
<button class="action-btn" id="btn-add-example">Add Example</button>
<button class="action-btn accent" id="btn-train">Train</button>
<button class="action-btn dim" id="btn-clear">Clear</button>
<button class="action-btn dim" id="btn-clear-examples">Clear Examples</button>
<button class="action-btn dim" id="btn-clear">Clear All</button>
<button class="action-btn dim" id="btn-randomize">Randomize</button>
</div>
<!-- Preset palette -->
<div class="preset-row" id="preset-row" style="display:none;">
<div class="preset-row" id="preset-row">
<span class="preset-label">Presets</span>
<button class="preset-chip" data-preset="calm-to-chaotic">Calm/Chaos</button>
<button class="preset-chip" data-preset="rainbow-sweep">Rainbow</button>
@ -143,6 +143,14 @@
<label>Oct Offset <span id="offset-val">0</span></label>
<input type="range" id="arp-offset" min="-2" max="3" step="1" value="0">
</div>
<div class="synth-row" id="midi-row" style="display:none">
<label>MIDI Input</label>
<button class="action-btn" id="midi-toggle">Enable</button>
<select id="midi-select" style="flex:1;min-width:0"></select>
</div>
<div class="synth-row" id="midi-status-row" style="display:none">
<span class="synth-status" id="midi-status"></span>
</div>
</div>
<!-- Loss plot -->

View file

@ -85,25 +85,15 @@
<span class="wb-panel-title">Controls</span>
</div>
<!-- Learning mode toggle -->
<div class="wb-mode-toggle">
<button class="wb-mode-btn" id="mode-examples">Examples</button>
<button class="wb-mode-btn active" id="mode-rl">RL</button>
</div>
<!-- Examples mode controls -->
<div class="wb-action-group hidden" id="examples-controls">
<!-- Unified controls -->
<div class="wb-action-group" id="controls">
<button class="wb-btn wb-btn-bad" id="btn-thumbsdown"><span class="wb-btn-label">&minus; Explore</span><span class="wb-key-hint">1</span></button>
<button class="wb-btn wb-btn-good" id="btn-thumbsup"><span class="wb-btn-label">+ Good</span><span class="wb-key-hint">2</span></button>
<button class="wb-btn wb-btn-primary" id="btn-add">Add Example</button>
<button class="wb-btn wb-btn-accent" id="btn-train">Train</button>
<button class="wb-btn" id="btn-randomize">Randomize</button>
<button class="wb-btn wb-btn-danger" id="btn-clear">Clear</button>
</div>
<!-- RL mode controls -->
<div class="wb-action-group" id="rl-controls">
<button class="wb-btn wb-btn-bad" id="btn-thumbsdown"><span class="wb-btn-label">&minus; Explore</span><span class="wb-key-hint">1</span></button>
<button class="wb-btn wb-btn-good" id="btn-thumbsup"><span class="wb-btn-label">+ Good</span><span class="wb-key-hint">2</span></button>
<button class="wb-btn" id="btn-randomize-rl">Randomize</button>
<button class="wb-btn" id="btn-clear-examples">Clear Examples</button>
<button class="wb-btn wb-btn-danger" id="btn-clear">Clear All</button>
</div>
<!-- Synth controls (shown in synth mode) -->
@ -137,6 +127,14 @@
<label>Offset <span id="offset-val">0</span></label>
<input type="range" id="arp-offset" min="-2" max="3" step="1" value="0">
</div>
<div class="wb-synth-row" id="midi-row" style="display:none">
<label>MIDI</label>
<button class="wb-btn" id="midi-toggle">Enable</button>
<select id="midi-select" style="flex:1;min-width:0"></select>
</div>
<div class="wb-synth-row" id="midi-status-row" style="display:none">
<span class="wb-synth-status" id="midi-status"></span>
</div>
</div>
<!-- Loss plot -->
@ -151,6 +149,7 @@
<div class="wb-metric"><span class="wb-metric-label">Loss</span><span class="wb-metric-value" id="metric-loss">&mdash;</span></div>
<div class="wb-metric"><span class="wb-metric-label">Noise</span><span class="wb-metric-value" id="metric-noise">0.050</span></div>
<div class="wb-metric"><span class="wb-metric-label">Joy X,Y</span><span class="wb-metric-value" id="metric-joy">0.50, 0.50</span></div>
<div class="wb-metric"><span class="wb-metric-value" id="gamepad-status" style="color: #ff6a00; font-size: 0.7rem;"></span></div>
</div>
</section>
@ -166,19 +165,12 @@
<p>Train a neural network to map 2D joystick positions to output parameters using interactive machine learning.</p>
<h3>Input Space</h3>
<p>The heatmap shows what the network has learned across the input space. Bright dots show training examples. Drag anywhere on the map to move the input position. Double-click or press Follow to toggle follow mode.</p>
<h3>Examples Mode</h3>
<ol>
<li>Drag on the input space to a position</li>
<li>Expand a parameter group and drag bars to set desired outputs</li>
<li>Press <strong>Add Example</strong></li>
<li>Repeat, then <strong>Train</strong></li>
</ol>
<h3>RL Mode</h3>
<ol>
<li>Move around the input space &mdash; the network produces outputs</li>
<li>Like it? Press <strong>+ Good</strong></li>
<li>Not quite? Press <strong>&minus; Explore</strong> for variation</li>
</ol>
<h3>Learning</h3>
<p>Both approaches can be freely mixed:</p>
<ul>
<li><strong>RL Feedback</strong>: Press <strong>+ Good</strong> to reinforce the current mapping, or <strong>&minus; Explore</strong> to add noise and try variations.</li>
<li><strong>Supervised</strong>: Drag param bars to set desired outputs, then <strong>Add Example</strong> and <strong>Train</strong>.</li>
</ul>
<h3>Output Modes</h3>
<p><strong>Visual</strong>: 20 params control the flow-field particle system. <strong>Synth</strong>: 126 params control a C15 synthesizer engine.</p>
<p style="margin-top:16px;text-align:center;color:#555;">Tap outside to close</p>

View file

@ -63,19 +63,22 @@
<div class="follow-indicator hidden" id="follow-indicator">FOLLOW</div>
<!-- Follow mode toggle pill -->
<button class="follow-pill" id="follow-pill" title="Toggle follow mode (double-click joystick)">Follow</button>
<!-- Gamepad status -->
<div id="gamepad-status" style="color: #ff6a00; font-size: 0.65rem; text-align: center; margin-top: 4px; min-height: 1em;"></div>
</div>
<!-- Teach phase panel -->
<div class="teach-panel" id="teach-panel">
<!-- Teaching mode tabs -->
<div class="teach-tabs">
<button class="teach-tab" data-tab="examples">Examples</button>
<button class="teach-tab active" data-tab="rl">RL Feedback</button>
</div>
<!-- Examples sub-mode -->
<div class="teach-content hidden" id="teach-examples">
<!-- Unified teaching controls -->
<div class="teach-content" id="teach-unified">
<div class="teach-layout">
<!-- RL feedback buttons -->
<div class="rl-buttons">
<button class="rl-btn down" id="btn-thumbs-down" title="Explore more"><span class="rl-icon">&#128078;</span><span class="key-hint">1</span></button>
<button class="rl-btn up" id="btn-thumbs-up" title="Keep this"><span class="rl-icon">&#128077;</span><span class="key-hint">2</span></button>
</div>
<!-- Preset grid -->
<div class="preset-grid" id="preset-grid">
<button class="preset-thumb" data-preset="0">
@ -121,15 +124,6 @@
</div>
</div>
<!-- RL sub-mode -->
<div class="teach-content" id="teach-rl">
<p class="rl-hint" id="rl-hint">Like what you see? <span class="thumb-icon">&#128077;</span> Want something different? <span class="thumb-icon">&#128078;</span></p>
<div class="rl-buttons">
<button class="rl-btn down" id="btn-thumbs-down" title="Explore more"><span class="rl-icon">&#128078;</span><span class="key-hint">1</span></button>
<button class="rl-btn up" id="btn-thumbs-up" title="Keep this"><span class="rl-icon">&#128077;</span><span class="key-hint">2</span></button>
</div>
</div>
<!-- Synth controls section (shown when synth mode active) -->
<div class="synth-section hidden" id="synth-section">
<div class="synth-row">
@ -157,13 +151,22 @@
<label>Tempo <span id="tempo-val">120</span></label>
<input type="range" id="arp-tempo" min="40" max="240" step="1" value="120">
</div>
<div class="synth-row" id="midi-row" style="display:none">
<label>MIDI Input</label>
<button class="action-btn" id="midi-toggle">Enable</button>
<select id="midi-select" style="flex:1;min-width:0"></select>
</div>
<div class="synth-row" id="midi-status-row" style="display:none">
<span class="synth-status" id="midi-status"></span>
</div>
</div>
<!-- Status bar -->
<div class="teach-status" id="teach-status">
<span id="status-text">0 examples</span>
<button class="action-btn dim small" id="btn-randomize">Randomize</button>
<button class="action-btn dim small" id="btn-clear">Clear</button>
<button class="action-btn dim small" id="btn-clear-examples">Clear Examples</button>
<button class="action-btn dim small" id="btn-clear">Clear All</button>
</div>
<!-- Done button -->

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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 = '<option value="">All Inputs</option>' +
inputs.map(i => `<option value="${i.id}">${i.name}</option>`).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);

View file

@ -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);
});

View file

@ -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 = '<option value="">All Inputs</option>' +
inputs.map(i => `<option value="${i.id}">${i.name}</option>`).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', () => {

View file

@ -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 = '<option value="">All Inputs</option>' +
inputs.map(i => `<option value="${i.id}">${i.name}</option>`).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();
}

View file

@ -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;
}
}

View file

@ -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 = `
<div class="controls-mode-toggle">
<button class="mode-btn active" data-mode="examples">Examples</button>
<button class="mode-btn" data-mode="rl">RL Feedback</button>
</div>
<div class="controls-actions" id="controls-examples">
<button class="btn btn-primary" id="btn-add">Add Example</button>
<button class="btn" id="btn-train">Train</button>
<button class="btn" id="btn-randomize">Randomize</button>
<button class="btn btn-danger" id="btn-clear">Clear</button>
</div>
<div class="controls-actions hidden" id="controls-rl">
<div class="controls-actions" id="controls-actions">
<button class="btn btn-good" id="btn-thumbsup">
<span style="font-size:1.4em">+</span>
</button>
<button class="btn btn-bad" id="btn-thumbsdown">
<span style="font-size:1.4em">&minus;</span>
</button>
<button class="btn" id="btn-rl-randomize">Randomize</button>
<button class="btn btn-danger" id="btn-rl-clear">Clear</button>
<button class="btn btn-primary" id="btn-add">Add Example</button>
<button class="btn" id="btn-train">Train</button>
<button class="btn" id="btn-randomize">Randomize</button>
<button class="btn" id="btn-clear-examples">Clear Examples</button>
<button class="btn btn-danger" id="btn-clear">Clear All</button>
</div>
<div class="controls-status">
<span id="status-examples">Examples: 0</span>
<span id="status-loss"></span>
<span id="status-noise" class="hidden">Noise: 0.05</span>
<span id="status-noise">Noise: 0.05</span>
</div>
<div class="metrics-grid">
<span class="metric-label">Mode</span>
<span class="metric-value" id="metric-mode">examples</span>
<span class="metric-label">Joystick</span>
<span class="metric-value" id="metric-joystick">0.50, 0.50</span>
<span class="metric-label">Output Mean</span>
@ -58,33 +48,14 @@ export class Controls {
</div>
`;
// 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)}`);
}

157
playground/js/ui/gamepad.js Normal file
View file

@ -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);
}
}