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).
157 lines
6.1 KiB
JavaScript
157 lines
6.1 KiB
JavaScript
// Control panel: unified action buttons, status display
|
|
|
|
export class Controls {
|
|
constructor(container, callbacks) {
|
|
this.callbacks = callbacks;
|
|
this.el = container;
|
|
this.build();
|
|
}
|
|
|
|
build() {
|
|
this.el.innerHTML = `
|
|
<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 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">Noise: 0.05</span>
|
|
</div>
|
|
<div class="metrics-grid">
|
|
<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>
|
|
<span class="metric-value" id="metric-mean">0.000</span>
|
|
<span class="metric-label">Output Spread</span>
|
|
<span class="metric-value" id="metric-spread">0.000</span>
|
|
<span class="metric-label">Best Loss</span>
|
|
<span class="metric-value" id="metric-best-loss">-</span>
|
|
<span class="metric-label">Train Iters</span>
|
|
<span class="metric-value" id="metric-train-iters">0</span>
|
|
<span class="metric-label">Gamepad</span>
|
|
<span class="metric-value" id="metric-gamepad">Disconnected</span>
|
|
<span class="metric-label">Follow</span>
|
|
<span class="metric-value" id="metric-follow">Off</span>
|
|
</div>
|
|
<div class="loss-plot-wrap">
|
|
<canvas id="loss-plot" width="420" height="86"></canvas>
|
|
</div>
|
|
`;
|
|
|
|
// 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?.());
|
|
}
|
|
|
|
updateStatus(exampleCount, loss, noiseLevel) {
|
|
this.el.querySelector('#status-examples').textContent = `Examples: ${exampleCount}`;
|
|
if (loss !== null && loss !== undefined) {
|
|
this.el.querySelector('#status-loss').textContent = `Loss: ${loss.toFixed(5)}`;
|
|
} else {
|
|
this.el.querySelector('#status-loss').textContent = 'Loss: -';
|
|
}
|
|
if (noiseLevel !== undefined) {
|
|
this.el.querySelector('#status-noise').textContent = `Noise: ${noiseLevel.toFixed(3)}`;
|
|
}
|
|
}
|
|
|
|
updateMetrics(metrics = {}) {
|
|
const set = (id, value) => {
|
|
const el = this.el.querySelector(id);
|
|
if (el) el.textContent = value;
|
|
};
|
|
|
|
if (metrics.joystickX !== undefined && metrics.joystickY !== undefined) {
|
|
set('#metric-joystick', `${metrics.joystickX.toFixed(2)}, ${metrics.joystickY.toFixed(2)}`);
|
|
}
|
|
if (metrics.outputMean !== undefined) set('#metric-mean', metrics.outputMean.toFixed(3));
|
|
if (metrics.outputSpread !== undefined) set('#metric-spread', metrics.outputSpread.toFixed(3));
|
|
if (metrics.bestLoss !== undefined && metrics.bestLoss !== null) {
|
|
set('#metric-best-loss', metrics.bestLoss.toFixed(5));
|
|
}
|
|
if (metrics.totalTrainingIterations !== undefined) {
|
|
set('#metric-train-iters', String(metrics.totalTrainingIterations));
|
|
}
|
|
if (metrics.gamepadConnected !== undefined) {
|
|
set('#metric-gamepad', metrics.gamepadConnected ? 'Connected' : 'Disconnected');
|
|
}
|
|
if (metrics.followMode !== undefined) {
|
|
set('#metric-follow', metrics.followMode ? 'On' : 'Off');
|
|
}
|
|
}
|
|
|
|
updateLossPlot(lossHistory = []) {
|
|
const canvas = this.el.querySelector('#loss-plot');
|
|
if (!canvas) return;
|
|
const ctx = canvas.getContext('2d');
|
|
const width = canvas.width;
|
|
const height = canvas.height;
|
|
|
|
ctx.clearRect(0, 0, width, height);
|
|
ctx.fillStyle = '#121212';
|
|
ctx.fillRect(0, 0, width, height);
|
|
ctx.strokeStyle = '#2b2b2b';
|
|
ctx.strokeRect(0.5, 0.5, width - 1, height - 1);
|
|
|
|
if (!lossHistory.length) {
|
|
ctx.fillStyle = '#666';
|
|
ctx.font = '11px monospace';
|
|
ctx.fillText('Loss history appears after training.', 10, 18);
|
|
return;
|
|
}
|
|
|
|
const points = lossHistory.slice(-420);
|
|
let min = Infinity;
|
|
let max = -Infinity;
|
|
for (const v of points) {
|
|
if (v < min) min = v;
|
|
if (v > max) max = v;
|
|
}
|
|
const range = Math.max(max - min, 1e-9);
|
|
const leftPad = 6;
|
|
const rightPad = 6;
|
|
const topPad = 6;
|
|
const bottomPad = 14;
|
|
const plotW = width - leftPad - rightPad;
|
|
const plotH = height - topPad - bottomPad;
|
|
|
|
ctx.strokeStyle = '#352418';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(leftPad, topPad + plotH / 2);
|
|
ctx.lineTo(width - rightPad, topPad + plotH / 2);
|
|
ctx.stroke();
|
|
|
|
ctx.strokeStyle = '#ff6a00';
|
|
ctx.lineWidth = 1.6;
|
|
ctx.beginPath();
|
|
for (let i = 0; i < points.length; i++) {
|
|
const x = leftPad + (i / Math.max(points.length - 1, 1)) * plotW;
|
|
const yNorm = (points[i] - min) / range;
|
|
const y = topPad + (1 - yNorm) * plotH;
|
|
if (i === 0) ctx.moveTo(x, y);
|
|
else ctx.lineTo(x, y);
|
|
}
|
|
ctx.stroke();
|
|
|
|
ctx.fillStyle = '#8a8a8a';
|
|
ctx.font = '10px monospace';
|
|
ctx.fillText(`min ${min.toFixed(5)}`, leftPad, height - 3);
|
|
ctx.fillText(`max ${max.toFixed(5)}`, width - rightPad - 78, height - 3);
|
|
}
|
|
}
|