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:
parent
cf44abed9e
commit
295221d1b2
14 changed files with 700 additions and 475 deletions
|
|
@ -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">▲</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 -->
|
||||
|
|
|
|||
|
|
@ -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">− 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">− 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">—</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 — the network produces outputs</li>
|
||||
<li>Like it? Press <strong>+ Good</strong></li>
|
||||
<li>Not quite? Press <strong>− 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>− 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>
|
||||
|
|
|
|||
|
|
@ -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">👎</span><span class="key-hint">1</span></button>
|
||||
<button class="rl-btn up" id="btn-thumbs-up" title="Keep this"><span class="rl-icon">👍</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">👍</span> Want something different? <span class="thumb-icon">👎</span></p>
|
||||
<div class="rl-buttons">
|
||||
<button class="rl-btn down" id="btn-thumbs-down" title="Explore more"><span class="rl-icon">👎</span><span class="key-hint">1</span></button>
|
||||
<button class="rl-btn up" id="btn-thumbs-up" title="Keep this"><span class="rl-icon">👍</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 -->
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
157
playground/js/synth/midi-input.js
Normal file
157
playground/js/synth/midi-input.js
Normal 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;
|
||||
}
|
||||
}
|
||||
54
playground/js/ui/controls.js
vendored
54
playground/js/ui/controls.js
vendored
|
|
@ -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">−</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
157
playground/js/ui/gamepad.js
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue