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).
1277 lines
41 KiB
JavaScript
1277 lines
41 KiB
JavaScript
// NISPS Workbench (Design B) — Main application
|
|
// Dashboard layout with mapping visualization, merged input space
|
|
|
|
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;
|
|
const N_VISUAL_OUTPUTS = 20;
|
|
const N_SYNTH_OUTPUTS = SYNTH_PARAM_MAP.length; // 126
|
|
const N_OUTPUTS = N_SYNTH_OUTPUTS; // MLP always produces full output; visual uses first 20
|
|
const STORAGE_KEY = 'nisps-b-workbench';
|
|
|
|
const VISUAL_PARAM_NAMES = [
|
|
'Flow', 'Scale', 'Speed', 'Hue', 'Spread', 'Size', 'Trail', 'Turb',
|
|
'Attract', 'Radius', 'DispRate', 'DispAmt', 'Lifetime', 'Respawn',
|
|
'Advection', 'Inertia', 'Drag', 'Repulse', 'RepCnt', 'RepRate'
|
|
];
|
|
const VISUAL_PARAM_COLORS = [
|
|
'#ff6a00', '#00ccff', '#ff6600', '#ff00cc', '#ffcc00', '#88ff00',
|
|
'#0088ff', '#ff3366', '#9bff5f', '#59d3ff', '#ff8f3f', '#a0b7ff',
|
|
'#f4ff7a', '#ffa8db', '#7dffc8', '#ffd166', '#8ad4ff', '#ff5f5f',
|
|
'#ffc15f', '#ff8a3d'
|
|
];
|
|
|
|
// Visual param groups: [groupName, accentColor, [indices]]
|
|
const VISUAL_GROUPS = [
|
|
['Motion', '--group-motion', [0, 2, 14, 15, 16]],
|
|
['Color', '--group-color', [3, 4, 6]],
|
|
['Particles', '--group-particles', [1, 5, 12, 13]],
|
|
['Forces', '--group-forces', [8, 9, 7, 17, 18]],
|
|
['Effects', '--group-effects', [10, 11, 19]],
|
|
];
|
|
|
|
const SYNTH_GROUPS = [
|
|
['Envelope A', '--group-osc', Array.from({length: 7}, (_, i) => i)],
|
|
['Envelope B', '--group-osc', Array.from({length: 7}, (_, i) => i + 7)],
|
|
['Envelope C', '--group-osc', Array.from({length: 6}, (_, i) => i + 14)],
|
|
['Oscillator A','--group-osc', Array.from({length: 5}, (_, i) => i + 20)],
|
|
['Oscillator B','--group-osc', Array.from({length: 5}, (_, i) => i + 25)],
|
|
['Shaper A', '--group-shapers', Array.from({length: 6}, (_, i) => i + 30)],
|
|
['Shaper B', '--group-shapers', Array.from({length: 6}, (_, i) => i + 36)],
|
|
['Comb Filter', '--group-filters', Array.from({length: 8}, (_, i) => i + 42)],
|
|
['SVF Filter', '--group-filters', Array.from({length: 7}, (_, i) => i + 50)],
|
|
['FB Mixer', '--group-effects', Array.from({length: 10}, (_, i) => i + 57)],
|
|
['Out Mixer', '--group-output', Array.from({length: 14}, (_, i) => i + 67)],
|
|
['Cabinet', '--group-effects', Array.from({length: 8}, (_, i) => i + 81)],
|
|
['Flanger', '--group-fx', Array.from({length: 13}, (_, i) => i + 89)],
|
|
['Echo', '--group-fx', Array.from({length: 7}, (_, i) => i + 102)],
|
|
['Reverb', '--group-fx', Array.from({length: 6}, (_, i) => i + 109)],
|
|
['Unison', '--group-output', Array.from({length: 3}, (_, i) => i + 115)],
|
|
// Remaining params (118..125) if any
|
|
...(N_SYNTH_OUTPUTS > 118 ? [['Mono/Extra', '--group-output', Array.from({length: N_SYNTH_OUTPUTS - 118}, (_, i) => i + 118)]] : []),
|
|
];
|
|
|
|
// Heatmap colormap: dark navy -> muted teal -> muted yellow -> muted orange
|
|
function heatmapColor(t) {
|
|
t = Math.max(0, Math.min(1, t));
|
|
let r, g, b;
|
|
if (t < 0.25) {
|
|
// Dark navy -> muted teal
|
|
const s = t / 0.25;
|
|
r = Math.floor(8 + s * 20);
|
|
g = Math.floor(12 + s * 60);
|
|
b = Math.floor(30 + s * 55);
|
|
} else if (t < 0.5) {
|
|
// Muted teal -> muted teal-green
|
|
const s = (t - 0.25) / 0.25;
|
|
r = Math.floor(28 + s * 30);
|
|
g = Math.floor(72 + s * 50);
|
|
b = Math.floor(85 - s * 40);
|
|
} else if (t < 0.75) {
|
|
// Muted teal-green -> muted yellow
|
|
const s = (t - 0.5) / 0.25;
|
|
r = Math.floor(58 + s * 90);
|
|
g = Math.floor(122 + s * 30);
|
|
b = Math.floor(45 - s * 20);
|
|
} else {
|
|
// Muted yellow -> muted orange
|
|
const s = (t - 0.75) / 0.25;
|
|
r = Math.floor(148 + s * 30);
|
|
g = Math.floor(152 - s * 60);
|
|
b = Math.floor(25 - s * 10);
|
|
}
|
|
return `rgb(${r},${g},${b})`;
|
|
}
|
|
|
|
// --- Tame URL param ---
|
|
const _urlParams = new URLSearchParams(window.location.search);
|
|
const tameLevel = parseFloat(_urlParams.get('tame') ?? '1');
|
|
const spreadLevel = Math.max(0, Math.min(1, parseFloat(_urlParams.get('spread') ?? '0.6') || 0.6));
|
|
|
|
// --- State ---
|
|
let iml;
|
|
let visualizer;
|
|
let outputMode = 'visual';
|
|
let noiseLevel = 0.05;
|
|
const rlExplorationDecay = 0.97;
|
|
let animating = true;
|
|
|
|
// Joystick state
|
|
let joyX = 0.5, joyY = 0.5;
|
|
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)
|
|
let paramValues = new Array(N_OUTPUTS).fill(0.5);
|
|
|
|
// Current groups/names/colors based on output mode
|
|
let currentGroups = VISUAL_GROUPS;
|
|
let currentNames = VISUAL_PARAM_NAMES;
|
|
let currentColors = VISUAL_PARAM_COLORS;
|
|
|
|
// DOM caches
|
|
let paramGroupsEl;
|
|
let groupElements = []; // [{el, bodyEl, indices, open}]
|
|
|
|
// ============================
|
|
// Pad preset outputs to N_OUTPUTS
|
|
// ============================
|
|
function padPresetOutputs(outputs) {
|
|
const padded = new Array(N_OUTPUTS).fill(0.5);
|
|
for (let i = 0; i < outputs.length; i++) padded[i] = outputs[i];
|
|
return padded;
|
|
}
|
|
|
|
// ============================
|
|
// Synth Visualizer (parameter landscape)
|
|
// ============================
|
|
const SYNTH_SECTION_COLORS = [
|
|
{ start: 0, count: 7, color: '#4488ff', label: 'Env A' },
|
|
{ start: 7, count: 7, color: '#4488ff', label: 'Env B' },
|
|
{ start: 14, count: 6, color: '#4488ff', label: 'Env C' },
|
|
{ start: 20, count: 5, color: '#ff8844', label: 'Osc A' },
|
|
{ start: 25, count: 5, color: '#ff8844', label: 'Osc B' },
|
|
{ start: 30, count: 6, color: '#ff4466', label: 'Shp A' },
|
|
{ start: 36, count: 6, color: '#ff4466', label: 'Shp B' },
|
|
{ start: 42, count: 8, color: '#44ddaa', label: 'Comb' },
|
|
{ start: 50, count: 7, color: '#44ddaa', label: 'SVF' },
|
|
{ start: 57, count: 10, color: '#ddaa44', label: 'FB Mix' },
|
|
{ start: 67, count: 14, color: '#ddaa44', label: 'Out Mix' },
|
|
{ start: 81, count: 8, color: '#aa88dd', label: 'Cab' },
|
|
{ start: 89, count: 13, color: '#dd88aa', label: 'Flanger' },
|
|
{ start: 102, count: 7, color: '#88aadd', label: 'Echo' },
|
|
{ start: 109, count: 6, color: '#88ddaa', label: 'Reverb' },
|
|
{ start: 115, count: 11, color: '#999999', label: 'Uni/Mono' },
|
|
];
|
|
|
|
class SynthVisualizer {
|
|
constructor(canvas) {
|
|
this.canvas = canvas;
|
|
this.ctx = canvas.getContext('2d');
|
|
this.params = new Array(N_SYNTH_OUTPUTS).fill(0.5);
|
|
this.displayParams = new Array(N_SYNTH_OUTPUTS).fill(0.5);
|
|
this._animId = null;
|
|
this._resize();
|
|
window.addEventListener('resize', () => this._resize());
|
|
}
|
|
|
|
_resize() {
|
|
const rect = this.canvas.parentElement.getBoundingClientRect();
|
|
this.canvas.width = rect.width * (window.devicePixelRatio || 1);
|
|
this.canvas.height = rect.height * (window.devicePixelRatio || 1);
|
|
}
|
|
|
|
setParams(values) {
|
|
for (let i = 0; i < this.params.length && i < values.length; i++) {
|
|
this.params[i] = values[i];
|
|
}
|
|
}
|
|
|
|
draw() {
|
|
const ctx = this.ctx;
|
|
const w = this.canvas.width;
|
|
const h = this.canvas.height;
|
|
const n = N_SYNTH_OUTPUTS;
|
|
|
|
// Lerp display toward target
|
|
for (let i = 0; i < n; i++) {
|
|
this.displayParams[i] += (this.params[i] - this.displayParams[i]) * 0.15;
|
|
}
|
|
|
|
// Background
|
|
ctx.fillStyle = '#0a0a0a';
|
|
ctx.fillRect(0, 0, w, h);
|
|
|
|
const labelHeight = 28 * (window.devicePixelRatio || 1);
|
|
const topPad = 8 * (window.devicePixelRatio || 1);
|
|
const barAreaH = h - labelHeight - topPad;
|
|
const totalBarW = w / n;
|
|
const barW = Math.max(1, totalBarW * 0.75);
|
|
const gap = totalBarW - barW;
|
|
|
|
// Draw section dividers and bars
|
|
let sectionIdx = 0;
|
|
for (const section of SYNTH_SECTION_COLORS) {
|
|
const sx = section.start * totalBarW;
|
|
const sw = section.count * totalBarW;
|
|
|
|
// Section background
|
|
ctx.fillStyle = 'rgba(255,255,255,0.02)';
|
|
if (sectionIdx % 2 === 0) {
|
|
ctx.fillRect(sx, topPad, sw, barAreaH);
|
|
}
|
|
|
|
// Draw bars
|
|
for (let i = section.start; i < section.start + section.count && i < n; i++) {
|
|
const val = this.displayParams[i] || 0;
|
|
const x = i * totalBarW + gap / 2;
|
|
const barH = val * barAreaH;
|
|
const y = topPad + barAreaH - barH;
|
|
|
|
ctx.fillStyle = section.color;
|
|
ctx.globalAlpha = 0.4 + val * 0.6;
|
|
ctx.fillRect(x, y, barW, barH);
|
|
ctx.globalAlpha = 1;
|
|
}
|
|
|
|
// Section label
|
|
const fontSize = Math.max(7, Math.min(10, sw / (section.label.length * 0.7)));
|
|
ctx.fillStyle = section.color;
|
|
ctx.globalAlpha = 0.7;
|
|
ctx.font = `${fontSize * (window.devicePixelRatio || 1)}px monospace`;
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText(section.label, sx + sw / 2, h - 6 * (window.devicePixelRatio || 1));
|
|
ctx.globalAlpha = 1;
|
|
|
|
sectionIdx++;
|
|
}
|
|
|
|
// Divider lines between sections
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.08)';
|
|
ctx.lineWidth = 1;
|
|
for (const section of SYNTH_SECTION_COLORS) {
|
|
if (section.start === 0) continue;
|
|
const x = section.start * totalBarW;
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, topPad);
|
|
ctx.lineTo(x, h - labelHeight);
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================
|
|
// Mapping Heatmap (merged joystick + mapping)
|
|
// ============================
|
|
class MappingView {
|
|
constructor(canvas) {
|
|
this.canvas = canvas;
|
|
this.ctx = canvas.getContext('2d');
|
|
this.gridSize = 20;
|
|
this.dragging = false;
|
|
// Offscreen buffer for the heatmap so drawOverlay can re-composite cheaply
|
|
this._offscreen = document.createElement('canvas');
|
|
this._offscreen.width = canvas.width;
|
|
this._offscreen.height = canvas.height;
|
|
this._offCtx = this._offscreen.getContext('2d');
|
|
|
|
// Pointer interaction for joystick input
|
|
canvas.addEventListener('pointerdown', (e) => this._onDown(e));
|
|
canvas.addEventListener('pointermove', (e) => this._onMove(e));
|
|
canvas.addEventListener('pointerup', (e) => this._onUp(e));
|
|
canvas.addEventListener('pointerleave', (e) => this._onUp(e));
|
|
canvas.addEventListener('pointercancel', (e) => this._onUp(e));
|
|
canvas.addEventListener('dblclick', (e) => {
|
|
e.preventDefault();
|
|
toggleFollowMode();
|
|
});
|
|
}
|
|
|
|
_getPos(e) {
|
|
const rect = this.canvas.getBoundingClientRect();
|
|
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
|
const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
|
|
return [x, y];
|
|
}
|
|
|
|
_onDown(e) {
|
|
e.preventDefault();
|
|
this.canvas.setPointerCapture(e.pointerId);
|
|
this.dragging = true;
|
|
joyDragging = true;
|
|
const [x, y] = this._getPos(e);
|
|
joyX = x; joyY = y;
|
|
onJoystickMove();
|
|
this.drawOverlay();
|
|
}
|
|
|
|
_onMove(e) {
|
|
if (!this.dragging) return;
|
|
e.preventDefault();
|
|
const [x, y] = this._getPos(e);
|
|
joyX = x; joyY = y;
|
|
onJoystickMove();
|
|
this.drawOverlay();
|
|
}
|
|
|
|
_onUp(e) {
|
|
this.dragging = false;
|
|
joyDragging = false;
|
|
}
|
|
|
|
update() {
|
|
const ctx = this._offCtx;
|
|
const w = this._offscreen.width;
|
|
const h = this._offscreen.height;
|
|
const gs = this.gridSize;
|
|
const cellW = w / gs;
|
|
const cellH = h / gs;
|
|
|
|
// Sample grid through network
|
|
const savedInputs = [...iml.inputState];
|
|
|
|
for (let gy = 0; gy < gs; gy++) {
|
|
for (let gx = 0; gx < gs; gx++) {
|
|
const nx = (gx + 0.5) / gs;
|
|
const ny = (gy + 0.5) / gs;
|
|
|
|
iml.setInput(0, nx);
|
|
iml.setInput(1, ny);
|
|
iml.inputUpdated = true;
|
|
iml.process();
|
|
const out = iml.getOutputs();
|
|
|
|
let avg = 0;
|
|
for (let i = 0; i < out.length; i++) avg += out[i];
|
|
avg /= out.length;
|
|
|
|
let maxIdx = 0, maxVal = 0;
|
|
for (let i = 0; i < out.length; i++) {
|
|
if (out[i] > maxVal) { maxVal = out[i]; maxIdx = i; }
|
|
}
|
|
const hueInfluence = maxIdx / out.length;
|
|
|
|
ctx.fillStyle = heatmapColor(avg * 0.8 + hueInfluence * 0.2);
|
|
ctx.fillRect(gx * cellW, gy * cellH, cellW + 0.5, cellH + 0.5);
|
|
}
|
|
}
|
|
|
|
// Draw grid overlay every 5th cell
|
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)';
|
|
ctx.lineWidth = 0.5;
|
|
for (let i = 5; i < gs; i += 5) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(i * cellW, 0);
|
|
ctx.lineTo(i * cellW, h);
|
|
ctx.stroke();
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, i * cellH);
|
|
ctx.lineTo(w, i * cellH);
|
|
ctx.stroke();
|
|
}
|
|
|
|
// Restore inputs
|
|
iml.setInputs(savedInputs);
|
|
iml.inputUpdated = true;
|
|
iml.process();
|
|
|
|
this.drawOverlay();
|
|
}
|
|
|
|
drawOverlay() {
|
|
const ctx = this.ctx;
|
|
const w = this.canvas.width;
|
|
const h = this.canvas.height;
|
|
|
|
// Blit cached heatmap then draw overlay on top
|
|
ctx.clearRect(0, 0, w, h);
|
|
ctx.drawImage(this._offscreen, 0, 0);
|
|
|
|
// Training examples as bright dots
|
|
const features = iml.dataset.features;
|
|
if (features) {
|
|
for (let i = 0; i < features.length; i++) {
|
|
const f = features[i];
|
|
if (!f || f.length < 2) continue;
|
|
const ex = f[0] * w;
|
|
const ey = f[1] * h;
|
|
ctx.fillStyle = '#fff';
|
|
ctx.globalAlpha = 0.9;
|
|
ctx.beginPath();
|
|
ctx.arc(ex, ey, 4, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.strokeStyle = '#000';
|
|
ctx.lineWidth = 1;
|
|
ctx.stroke();
|
|
ctx.globalAlpha = 1;
|
|
}
|
|
}
|
|
|
|
// Current joystick position as crosshair
|
|
const px = joyX * w;
|
|
const py = joyY * h;
|
|
const accent = outputMode === 'synth' ? '#ff8c00' : '#ff6a00';
|
|
|
|
ctx.strokeStyle = accent;
|
|
ctx.lineWidth = 1;
|
|
ctx.globalAlpha = 0.7;
|
|
ctx.setLineDash([3, 3]);
|
|
ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, h); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(0, py); ctx.lineTo(w, py); ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
ctx.globalAlpha = 1;
|
|
|
|
// Cursor dot
|
|
ctx.fillStyle = accent;
|
|
ctx.beginPath();
|
|
ctx.arc(px, py, 6, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.strokeStyle = '#fff';
|
|
ctx.lineWidth = 2;
|
|
ctx.stroke();
|
|
|
|
// Outer ring
|
|
ctx.strokeStyle = accent;
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
ctx.arc(px, py, 10, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
|
|
// ============================
|
|
// Constellation
|
|
// ============================
|
|
function drawConstellation(canvas, values, nParams) {
|
|
const ctx = canvas.getContext('2d');
|
|
const w = canvas.width;
|
|
const h = canvas.height;
|
|
const cx = w / 2;
|
|
const cy = h / 2;
|
|
const radius = Math.min(cx, cy) - 10;
|
|
|
|
ctx.clearRect(0, 0, w, h);
|
|
|
|
// Faint circle
|
|
ctx.strokeStyle = '#222';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
|
|
const n = nParams;
|
|
for (let i = 0; i < n; i++) {
|
|
const angle = (i / n) * Math.PI * 2 - Math.PI / 2;
|
|
const val = values[i] || 0;
|
|
const dotRadius = 2 + val * 4;
|
|
const x = cx + Math.cos(angle) * radius;
|
|
const y = cy + Math.sin(angle) * radius;
|
|
|
|
const alpha = 0.2 + val * 0.8;
|
|
const color = currentColors[i % currentColors.length];
|
|
|
|
ctx.globalAlpha = alpha;
|
|
ctx.fillStyle = color;
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, dotRadius, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
ctx.globalAlpha = 1;
|
|
}
|
|
|
|
// ============================
|
|
// Loss Plot
|
|
// ============================
|
|
function drawLossPlot(canvas, history) {
|
|
const ctx = canvas.getContext('2d');
|
|
const w = canvas.width;
|
|
const h = canvas.height;
|
|
ctx.clearRect(0, 0, w, h);
|
|
|
|
if (!history || history.length < 2) {
|
|
ctx.fillStyle = '#1a1a1a';
|
|
ctx.fillRect(0, 0, w, h);
|
|
ctx.fillStyle = '#333';
|
|
ctx.font = '10px monospace';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText('No training data', w / 2, h / 2 + 3);
|
|
return;
|
|
}
|
|
|
|
ctx.fillStyle = '#111';
|
|
ctx.fillRect(0, 0, w, h);
|
|
|
|
// Find range
|
|
let maxLoss = 0;
|
|
for (const v of history) if (v > maxLoss) maxLoss = v;
|
|
if (maxLoss === 0) maxLoss = 1;
|
|
|
|
const accent = outputMode === 'synth' ? '#ff8c00' : '#ff6a00';
|
|
|
|
// Draw line
|
|
ctx.strokeStyle = accent;
|
|
ctx.lineWidth = 1.5;
|
|
ctx.beginPath();
|
|
const step = w / (history.length - 1);
|
|
for (let i = 0; i < history.length; i++) {
|
|
const x = i * step;
|
|
const y = h - (history[i] / maxLoss) * (h - 4) - 2;
|
|
if (i === 0) ctx.moveTo(x, y);
|
|
else ctx.lineTo(x, y);
|
|
}
|
|
ctx.stroke();
|
|
|
|
// Fill under
|
|
ctx.lineTo(w, h);
|
|
ctx.lineTo(0, h);
|
|
ctx.closePath();
|
|
ctx.fillStyle = accent.replace(')', ', 0.1)').replace('rgb', 'rgba').replace('#', '');
|
|
// Use hex to rgba
|
|
ctx.globalAlpha = 0.15;
|
|
ctx.fillStyle = accent;
|
|
ctx.fill();
|
|
ctx.globalAlpha = 1;
|
|
}
|
|
|
|
// ============================
|
|
// Param Groups UI
|
|
// ============================
|
|
function buildParamGroups() {
|
|
paramGroupsEl.innerHTML = '';
|
|
groupElements = [];
|
|
|
|
for (const [name, colorVar, indices] of currentGroups) {
|
|
const group = document.createElement('div');
|
|
group.className = 'wb-group';
|
|
|
|
// Resolve CSS variable for accent color
|
|
const accentColor = getComputedStyle(document.documentElement).getPropertyValue(colorVar).trim();
|
|
|
|
// Header
|
|
const header = document.createElement('div');
|
|
header.className = 'wb-group-header';
|
|
header.innerHTML = `
|
|
<div class="wb-group-accent" style="background:${accentColor}"></div>
|
|
<span class="wb-group-name">${name}</span>
|
|
<canvas class="wb-group-sparkline" width="40" height="12"></canvas>
|
|
<span class="wb-group-chevron">▶</span>
|
|
`;
|
|
|
|
// Body
|
|
const body = document.createElement('div');
|
|
body.className = 'wb-group-body';
|
|
const paramsDiv = document.createElement('div');
|
|
paramsDiv.className = 'wb-group-params';
|
|
|
|
for (const idx of indices) {
|
|
const paramName = currentNames[idx] || `P${idx}`;
|
|
const color = currentColors[idx % currentColors.length];
|
|
|
|
const row = document.createElement('div');
|
|
row.className = 'wb-param';
|
|
row.innerHTML = `
|
|
<span class="wb-param-name">${paramName}</span>
|
|
<div class="wb-param-track" data-idx="${idx}">
|
|
<div class="wb-param-fill" style="width:50%;background:${color}"></div>
|
|
</div>
|
|
<span class="wb-param-value" data-idx="${idx}">0.50</span>
|
|
`;
|
|
paramsDiv.appendChild(row);
|
|
}
|
|
|
|
body.appendChild(paramsDiv);
|
|
group.appendChild(header);
|
|
group.appendChild(body);
|
|
paramGroupsEl.appendChild(group);
|
|
|
|
// Toggle collapse
|
|
let isOpen = false;
|
|
header.addEventListener('click', () => {
|
|
isOpen = !isOpen;
|
|
group.classList.toggle('open', isOpen);
|
|
});
|
|
|
|
// Dragging on param tracks
|
|
paramsDiv.addEventListener('pointerdown', (e) => {
|
|
const track = e.target.closest('.wb-param-track');
|
|
if (!track) return;
|
|
e.preventDefault();
|
|
track.setPointerCapture(e.pointerId);
|
|
const idx = parseInt(track.dataset.idx);
|
|
const setVal = (ev) => {
|
|
const rect = track.getBoundingClientRect();
|
|
const v = Math.max(0, Math.min(1, (ev.clientX - rect.left) / rect.width));
|
|
paramValues[idx] = v;
|
|
updateParamBar(track, v, idx);
|
|
// Push manually-set values to visualizer and synth in real time
|
|
routeOutputs(paramValues);
|
|
};
|
|
setVal(e);
|
|
const onMove = (ev) => { ev.preventDefault(); setVal(ev); };
|
|
const onUp = () => {
|
|
window.removeEventListener('pointermove', onMove);
|
|
window.removeEventListener('pointerup', onUp);
|
|
};
|
|
window.addEventListener('pointermove', onMove);
|
|
window.addEventListener('pointerup', onUp);
|
|
});
|
|
|
|
groupElements.push({ el: group, bodyEl: paramsDiv, indices, sparklineCanvas: header.querySelector('.wb-group-sparkline') });
|
|
}
|
|
}
|
|
|
|
function updateParamBar(trackEl, value, idx) {
|
|
const fill = trackEl.querySelector('.wb-param-fill');
|
|
if (fill) fill.style.width = (value * 100) + '%';
|
|
// Update value text
|
|
const valEl = trackEl.parentElement.querySelector(`.wb-param-value[data-idx="${idx}"]`);
|
|
if (valEl) valEl.textContent = value.toFixed(2);
|
|
}
|
|
|
|
function updateAllParamBars(values) {
|
|
paramValues = [...values];
|
|
for (const ge of groupElements) {
|
|
let sum = 0;
|
|
for (const idx of ge.indices) {
|
|
const val = values[idx] || 0;
|
|
sum += val;
|
|
const track = ge.bodyEl.querySelector(`.wb-param-track[data-idx="${idx}"]`);
|
|
if (track) updateParamBar(track, val, idx);
|
|
}
|
|
|
|
// Update sparkline
|
|
drawGroupSparkline(ge.sparklineCanvas, ge.indices, values);
|
|
}
|
|
}
|
|
|
|
function drawGroupSparkline(canvas, indices, values) {
|
|
const ctx = canvas.getContext('2d');
|
|
const w = canvas.width;
|
|
const h = canvas.height;
|
|
ctx.clearRect(0, 0, w, h);
|
|
|
|
// Background
|
|
ctx.fillStyle = '#1a1a1a';
|
|
ctx.fillRect(0, 0, w, h);
|
|
|
|
const n = indices.length;
|
|
const barW = w / n;
|
|
for (let i = 0; i < n; i++) {
|
|
const val = values[indices[i]] || 0;
|
|
const barH = val * h;
|
|
const color = currentColors[indices[i] % currentColors.length];
|
|
ctx.fillStyle = color;
|
|
ctx.globalAlpha = 0.7;
|
|
ctx.fillRect(i * barW, h - barH, barW - 0.5, barH);
|
|
}
|
|
ctx.globalAlpha = 1;
|
|
}
|
|
|
|
// ============================
|
|
// Routing
|
|
// ============================
|
|
function routeOutputs(outputs) {
|
|
if (outputMode === 'visual') {
|
|
visualizer.setParams(outputs.slice(0, N_VISUAL_OUTPUTS));
|
|
} else {
|
|
if (synthVisualizer) synthVisualizer.setParams(outputs);
|
|
if (c15 && c15.running) {
|
|
for (let i = 0; i < outputs.length && i < SYNTH_PARAM_MAP.length; i++) {
|
|
const tamed = applyTame(outputs[i], SYNTH_PARAM_MAP[i], tameLevel);
|
|
c15.setParameter(SYNTH_PARAM_MAP[i].id, tamed);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================
|
|
// Application logic
|
|
// ============================
|
|
let mappingView;
|
|
let lossCanvas;
|
|
let constellationCanvas;
|
|
|
|
function onJoystickMove() {
|
|
iml.setInput(0, joyX);
|
|
iml.setInput(1, joyY);
|
|
iml.process();
|
|
|
|
const outputs = iml.getOutputs();
|
|
routeOutputs(outputs);
|
|
|
|
updateAllParamBars(outputs);
|
|
|
|
const constellationN = outputMode === 'synth' ? N_SYNTH_OUTPUTS : N_VISUAL_OUTPUTS;
|
|
drawConstellation(constellationCanvas, outputs, constellationN);
|
|
updateMetrics();
|
|
|
|
// Redraw mapping overlay (cursor position) - cheap
|
|
mappingView.drawOverlay();
|
|
}
|
|
|
|
function onAddExample() {
|
|
const inputs = [joyX, joyY];
|
|
const outputs = [...paramValues];
|
|
iml.addExample(inputs, outputs);
|
|
updateMetrics();
|
|
mappingView.update();
|
|
flash('btn-add');
|
|
}
|
|
|
|
function onTrain() {
|
|
const loss = trainModel();
|
|
if (loss !== null) {
|
|
const outputs = iml.getOutputs();
|
|
routeOutputs(outputs);
|
|
updateAllParamBars(outputs);
|
|
const constellationN = outputMode === 'synth' ? N_SYNTH_OUTPUTS : N_VISUAL_OUTPUTS;
|
|
drawConstellation(constellationCanvas, outputs, constellationN);
|
|
drawLossPlot(lossCanvas, iml.lossHistory);
|
|
mappingView.update();
|
|
updateMetrics();
|
|
flash('btn-train');
|
|
}
|
|
}
|
|
|
|
function onRandomize() {
|
|
iml.randomiseWeights(spreadLevel);
|
|
iml.setInput(0, joyX);
|
|
iml.setInput(1, joyY);
|
|
iml.process();
|
|
const outputs = iml.getOutputs();
|
|
routeOutputs(outputs);
|
|
updateAllParamBars(outputs);
|
|
const constellationN = outputMode === 'synth' ? N_SYNTH_OUTPUTS : N_VISUAL_OUTPUTS;
|
|
drawConstellation(constellationCanvas, outputs, constellationN);
|
|
noiseLevel = 0.05;
|
|
mappingView.update();
|
|
updateMetrics();
|
|
}
|
|
|
|
function onClearExamples() {
|
|
iml.clearDataset();
|
|
mappingView.update();
|
|
updateMetrics();
|
|
}
|
|
|
|
function onClear() {
|
|
iml.clearDataset();
|
|
iml.lossHistory = [];
|
|
iml.bestLoss = null;
|
|
iml.totalTrainingIterations = 0;
|
|
noiseLevel = 0.05;
|
|
clearState();
|
|
drawLossPlot(lossCanvas, []);
|
|
mappingView.update();
|
|
updateMetrics();
|
|
}
|
|
|
|
function onThumbsUp() {
|
|
const inputs = [joyX, joyY];
|
|
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);
|
|
mappingView.update();
|
|
updateMetrics();
|
|
flash('btn-thumbsup');
|
|
}
|
|
|
|
function onThumbsDown() {
|
|
const noiseCap = 0.3 * (1 - spreadLevel) + 0.05 * spreadLevel;
|
|
noiseLevel = Math.min(noiseLevel * 1.5, noiseCap);
|
|
iml.moveWeights(noiseLevel, spreadLevel);
|
|
const outputs = iml.getOutputs();
|
|
routeOutputs(outputs);
|
|
updateAllParamBars(outputs);
|
|
const constellationN = outputMode === 'synth' ? N_SYNTH_OUTPUTS : N_VISUAL_OUTPUTS;
|
|
drawConstellation(constellationCanvas, outputs, constellationN);
|
|
mappingView.update();
|
|
updateMetrics();
|
|
flash('btn-thumbsdown');
|
|
}
|
|
|
|
function trainModel() {
|
|
let lastPlotUpdate = 0;
|
|
const loss = iml.train({
|
|
onIteration: (iter, iterLoss) => {
|
|
if (iter - lastPlotUpdate < 8) return;
|
|
lastPlotUpdate = iter;
|
|
drawLossPlot(lossCanvas, [...iml.lossHistory, iterLoss]);
|
|
},
|
|
});
|
|
return loss;
|
|
}
|
|
|
|
function updateMetrics() {
|
|
const el = (id) => document.getElementById(id);
|
|
el('metric-examples').textContent = iml.exampleCount;
|
|
el('metric-loss').textContent = iml.lastLoss !== null ? iml.lastLoss.toFixed(6) : '\u2014';
|
|
el('metric-noise').textContent = noiseLevel.toFixed(3);
|
|
el('metric-joy').textContent = `${joyX.toFixed(2)}, ${joyY.toFixed(2)}`;
|
|
}
|
|
|
|
function flash(id) {
|
|
const el = document.getElementById(id);
|
|
if (!el) return;
|
|
el.classList.add('flash');
|
|
setTimeout(() => el.classList.remove('flash'), 200);
|
|
}
|
|
|
|
// ============================
|
|
// Toast notifications
|
|
// ============================
|
|
function showToast(message) {
|
|
const toast = document.getElementById('toast');
|
|
if (!toast) return;
|
|
toast.textContent = message;
|
|
toast.classList.remove('hidden', 'fade-out');
|
|
// Clear any previous timeout
|
|
if (toast._timeout) clearTimeout(toast._timeout);
|
|
if (toast._fadeTimeout) clearTimeout(toast._fadeTimeout);
|
|
toast._timeout = setTimeout(() => {
|
|
toast.classList.add('fade-out');
|
|
toast._fadeTimeout = setTimeout(() => {
|
|
toast.classList.add('hidden');
|
|
toast.classList.remove('fade-out');
|
|
}, 500);
|
|
}, 1500);
|
|
}
|
|
|
|
// ============================
|
|
// Follow mode
|
|
// ============================
|
|
function toggleFollowMode() {
|
|
followMode = !followMode;
|
|
const badge = document.getElementById('follow-badge');
|
|
const toggleBtn = document.getElementById('follow-toggle-btn');
|
|
const panel = document.querySelector('.wb-mapping-panel');
|
|
|
|
if (followMode) {
|
|
badge.classList.remove('hidden');
|
|
toggleBtn.classList.add('active');
|
|
panel.classList.add('follow-active');
|
|
showToast('Follow mode ON');
|
|
} else {
|
|
badge.classList.add('hidden');
|
|
toggleBtn.classList.remove('active');
|
|
panel.classList.remove('follow-active');
|
|
showToast('Follow mode OFF');
|
|
}
|
|
}
|
|
|
|
function onFollowPointerMove(e) {
|
|
if (!followMode) return;
|
|
// Map screen coordinates to 0-1
|
|
const x = Math.max(0, Math.min(1, e.clientX / window.innerWidth));
|
|
const y = Math.max(0, Math.min(1, e.clientY / window.innerHeight));
|
|
joyX = x;
|
|
joyY = y;
|
|
onJoystickMove();
|
|
}
|
|
|
|
// ============================
|
|
// Output mode switching
|
|
// ============================
|
|
function setOutputMode(mode) {
|
|
outputMode = mode;
|
|
const badge = document.getElementById('mode-badge');
|
|
const synthControls = document.getElementById('synth-controls');
|
|
const presetsVisual = document.getElementById('presets-visual');
|
|
const label = document.getElementById('constellation-label');
|
|
const visualCanvas = document.getElementById('visual-canvas');
|
|
const synthVisCanvas = document.getElementById('synth-vis-canvas');
|
|
|
|
if (mode === 'synth') {
|
|
document.body.classList.add('synth-mode');
|
|
badge.textContent = 'Synth';
|
|
badge.classList.add('synth');
|
|
synthControls.classList.remove('hidden');
|
|
if (presetsVisual) presetsVisual.style.display = 'none';
|
|
currentGroups = SYNTH_GROUPS;
|
|
currentNames = SYNTH_PARAM_NAMES;
|
|
currentColors = SYNTH_PARAM_COLORS;
|
|
label.textContent = `${N_SYNTH_OUTPUTS} synth parameters`;
|
|
// Show synth canvas, hide visual canvas
|
|
visualCanvas.classList.add('hidden');
|
|
synthVisCanvas.classList.remove('hidden');
|
|
} else {
|
|
document.body.classList.remove('synth-mode');
|
|
badge.textContent = 'Visual';
|
|
badge.classList.remove('synth');
|
|
synthControls.classList.add('hidden');
|
|
if (presetsVisual) presetsVisual.style.display = '';
|
|
currentGroups = VISUAL_GROUPS;
|
|
currentNames = VISUAL_PARAM_NAMES;
|
|
currentColors = VISUAL_PARAM_COLORS;
|
|
label.textContent = `${N_VISUAL_OUTPUTS} parameters`;
|
|
// Show visual canvas, hide synth canvas
|
|
visualCanvas.classList.remove('hidden');
|
|
synthVisCanvas.classList.add('hidden');
|
|
}
|
|
|
|
// Toggle button state
|
|
document.getElementById('toggle-visual').classList.toggle('active', mode === 'visual');
|
|
document.getElementById('toggle-synth').classList.toggle('active', mode === 'synth');
|
|
|
|
// Resize the now-visible canvas so it picks up correct dimensions
|
|
if (mode === 'visual') {
|
|
requestAnimationFrame(() => {
|
|
visualizer.resize();
|
|
visualizer.initParticles();
|
|
});
|
|
} else if (synthVisualizer) {
|
|
requestAnimationFrame(() => synthVisualizer._resize());
|
|
}
|
|
|
|
buildParamGroups();
|
|
const outputs = iml.getOutputs();
|
|
updateAllParamBars(outputs);
|
|
routeOutputs(outputs);
|
|
const constellationN = mode === 'synth' ? N_SYNTH_OUTPUTS : N_VISUAL_OUTPUTS;
|
|
drawConstellation(constellationCanvas, outputs, constellationN);
|
|
mappingView.update();
|
|
}
|
|
|
|
// ============================
|
|
// Presets
|
|
// ============================
|
|
function loadPreset(name) {
|
|
iml.clearDataset();
|
|
|
|
if (name === 'calm-to-chaotic') {
|
|
iml.addExample([0.1, 0.9], padPresetOutputs([0.25, 0.3, 0.1, 0.55, 0.2, 0.3, 0.02, 0.05, 0.9, 0.45, 0.25, 0.2, 0.9, 0.0, 0.0, 0.2, 0.05, 0.0, 0.0, 0.2]));
|
|
iml.addExample([0.9, 0.1], padPresetOutputs([0.75, 0.7, 0.9, 0.05, 0.8, 0.7, 0.9, 0.95, 0.3, 0.2, 0.85, 0.7, 0.25, 0.55, 1.0, 0.92, 0.02, 0.95, 0.8, 0.85]));
|
|
iml.addExample([0.5, 0.5], padPresetOutputs([0.5, 0.5, 0.5, 0.3, 0.5, 0.5, 0.4, 0.5, 0.7, 0.6, 0.5, 0.45, 0.55, 0.9, 0.5, 0.65, 0.08, 0.45, 0.4, 0.5]));
|
|
} else if (name === 'rainbow-sweep') {
|
|
iml.addExample([0.0, 0.5], padPresetOutputs([0.5, 0.5, 0.4, 0.0, 0.3, 0.4, 0.05, 0.3, 0.8, 0.55, 0.4, 0.3, 0.8, 0.0, 0.0, 0.45, 0.08, 0.2, 0.25, 0.45]));
|
|
iml.addExample([0.5, 0.5], padPresetOutputs([0.5, 0.5, 0.4, 0.5, 0.3, 0.4, 0.05, 0.3, 0.8, 0.55, 0.55, 0.35, 0.7, 0.0, 0.4, 0.45, 0.08, 0.45, 0.4, 0.6]));
|
|
iml.addExample([1.0, 0.5], padPresetOutputs([0.5, 0.5, 0.4, 1.0, 0.3, 0.4, 0.05, 0.3, 0.8, 0.55, 0.75, 0.45, 0.6, 0.0, 0.8, 0.45, 0.08, 0.7, 0.55, 0.75]));
|
|
} else if (name === 'vortex') {
|
|
iml.addExample([0.5, 0.5], padPresetOutputs([0.0, 0.8, 0.8, 0.6, 0.1, 0.15, 0.02, 1.0, 1.0, 0.3, 0.95, 0.85, 0.25, 1.0, 0.5, 0.95, 0.01, 1.0, 1.0, 1.0]));
|
|
iml.addExample([0.0, 0.0], padPresetOutputs([0.5, 0.2, 0.3, 0.8, 0.9, 0.6, 0.08, 0.1, 0.35, 0.8, 0.25, 0.15, 0.8, 0.5, 0.2, 0.35, 0.2, 0.15, 0.2, 0.25]));
|
|
iml.addExample([1.0, 1.0], padPresetOutputs([0.5, 0.2, 0.3, 0.2, 0.9, 0.6, 0.08, 0.1, 0.35, 0.8, 0.25, 0.15, 0.8, 0.5, 0.8, 0.35, 0.2, 0.15, 0.2, 0.25]));
|
|
iml.addExample([0.0, 1.0], padPresetOutputs([0.3, 0.4, 0.5, 0.4, 0.5, 0.4, 0.05, 0.5, 0.65, 0.5, 0.55, 0.45, 0.45, 0.2, 0.4, 0.7, 0.1, 0.5, 0.4, 0.55]));
|
|
iml.addExample([1.0, 0.0], padPresetOutputs([0.7, 0.4, 0.5, 0.0, 0.5, 0.4, 0.05, 0.5, 0.65, 0.5, 0.55, 0.45, 0.45, 0.2, 0.9, 0.7, 0.1, 0.5, 0.4, 0.55]));
|
|
}
|
|
|
|
const loss = trainModel();
|
|
const outputs = iml.getOutputs();
|
|
routeOutputs(outputs);
|
|
updateAllParamBars(outputs);
|
|
const constellationN = outputMode === 'synth' ? N_SYNTH_OUTPUTS : N_VISUAL_OUTPUTS;
|
|
drawConstellation(constellationCanvas, outputs, constellationN);
|
|
drawLossPlot(lossCanvas, iml.lossHistory);
|
|
mappingView.update();
|
|
updateMetrics();
|
|
}
|
|
|
|
// ============================
|
|
// Synth controls
|
|
// ============================
|
|
function initSynthControls() {
|
|
const startBtn = document.getElementById('synth-start');
|
|
const volumeSlider = document.getElementById('synth-volume');
|
|
const arpToggleBtn = document.getElementById('arp-toggle');
|
|
const arpProgression = document.getElementById('arp-progression');
|
|
const arpTempo = document.getElementById('arp-tempo');
|
|
const arpOctaves = document.getElementById('arp-octaves');
|
|
const arpOffset = document.getElementById('arp-offset');
|
|
|
|
startBtn.addEventListener('click', async () => {
|
|
if (c15.running) {
|
|
arpeggiator.stop();
|
|
arpToggleBtn.textContent = 'Arp: Play';
|
|
await c15.stop();
|
|
startBtn.textContent = 'Start Audio';
|
|
} else {
|
|
await c15.start();
|
|
startBtn.textContent = 'Stop Audio';
|
|
routeOutputs(iml.getOutputs());
|
|
}
|
|
});
|
|
|
|
volumeSlider.addEventListener('input', (e) => {
|
|
c15.setMasterVolume(parseFloat(e.target.value));
|
|
});
|
|
|
|
arpToggleBtn.addEventListener('click', () => {
|
|
if (!c15.running) return;
|
|
if (arpeggiator.playing) {
|
|
arpeggiator.stop();
|
|
arpToggleBtn.textContent = 'Arp: Play';
|
|
} else {
|
|
arpeggiator.start();
|
|
arpToggleBtn.textContent = 'Arp: Stop';
|
|
}
|
|
});
|
|
|
|
arpProgression.addEventListener('change', (e) => {
|
|
arpeggiator.progression = e.target.value;
|
|
});
|
|
|
|
arpTempo.addEventListener('input', (e) => {
|
|
const val = parseInt(e.target.value);
|
|
arpeggiator.bpm = val;
|
|
document.getElementById('tempo-val').textContent = val;
|
|
});
|
|
|
|
arpOctaves.addEventListener('input', (e) => {
|
|
const val = parseInt(e.target.value);
|
|
arpeggiator.octaves = val;
|
|
document.getElementById('octaves-val').textContent = val;
|
|
});
|
|
|
|
arpOffset.addEventListener('input', (e) => {
|
|
const val = parseInt(e.target.value);
|
|
arpeggiator.octaveOffset = val;
|
|
document.getElementById('offset-val').textContent = val;
|
|
});
|
|
}
|
|
|
|
// ============================
|
|
// localStorage persistence
|
|
// ============================
|
|
function saveState() {
|
|
try {
|
|
const features = iml.dataset.features;
|
|
const labels = iml.dataset.labels;
|
|
if (!features || features.length === 0) return;
|
|
const data = { features, labels };
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
|
} catch (e) {
|
|
console.warn('[NISPS] saveState failed:', e);
|
|
}
|
|
}
|
|
|
|
// ---- 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);
|
|
if (!raw) return false;
|
|
const data = JSON.parse(raw);
|
|
if (!data.features || !data.labels || data.features.length === 0) return false;
|
|
for (let i = 0; i < data.features.length; i++) {
|
|
// Pad old saves (20 outputs) to current N_OUTPUTS with 0.5 defaults
|
|
let labels = data.labels[i];
|
|
if (labels.length < N_OUTPUTS) {
|
|
labels = padPresetOutputs(labels);
|
|
}
|
|
iml.addExample(data.features[i], labels);
|
|
}
|
|
trainModel();
|
|
routeOutputs(iml.getOutputs());
|
|
return true;
|
|
} catch (e) {
|
|
console.warn('[NISPS] loadState failed:', e);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function clearState() {
|
|
try {
|
|
localStorage.removeItem(STORAGE_KEY);
|
|
} catch (e) {
|
|
console.warn('[NISPS] clearState failed:', e);
|
|
}
|
|
}
|
|
|
|
// ============================
|
|
// Keyboard shortcuts
|
|
// ============================
|
|
window.addEventListener('keydown', (e) => {
|
|
if (e.repeat) return;
|
|
// RL shortcuts: 1=negative, 2=positive
|
|
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
|
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return;
|
|
e.preventDefault();
|
|
toggleFollowMode();
|
|
}
|
|
});
|
|
|
|
// ============================
|
|
// Animation
|
|
// ============================
|
|
function animate() {
|
|
if (!animating) return;
|
|
if (gamepad) gamepad.poll();
|
|
if (outputMode === 'visual') {
|
|
visualizer.draw();
|
|
} else if (synthVisualizer) {
|
|
synthVisualizer.draw();
|
|
}
|
|
requestAnimationFrame(animate);
|
|
}
|
|
|
|
// ============================
|
|
// Init
|
|
// ============================
|
|
function init() {
|
|
// IML — random weights, then restore saved data if available
|
|
iml = new IML(N_INPUTS, N_OUTPUTS, [32, 48, 64], 1000, 1.0, 0.00001);
|
|
iml.setLogger(msg => console.log('[NISPS]', msg));
|
|
|
|
// Flow field visualizer
|
|
const visCanvas = document.getElementById('visual-canvas');
|
|
visualizer = new FlowFieldVisualizer(visCanvas);
|
|
|
|
// Synth parameter landscape visualizer
|
|
const synthVisCanvas = document.getElementById('synth-vis-canvas');
|
|
synthVisualizer = new SynthVisualizer(synthVisCanvas);
|
|
|
|
// Mapping view (merged joystick + heatmap)
|
|
mappingView = new MappingView(document.getElementById('mapping-canvas'));
|
|
|
|
// Canvases
|
|
lossCanvas = document.getElementById('loss-canvas');
|
|
constellationCanvas = document.getElementById('constellation-canvas');
|
|
|
|
// Param groups
|
|
paramGroupsEl = document.getElementById('param-groups');
|
|
buildParamGroups();
|
|
|
|
// Controls wiring
|
|
document.getElementById('btn-add').addEventListener('click', onAddExample);
|
|
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);
|
|
|
|
// Output mode
|
|
document.getElementById('toggle-visual').addEventListener('click', () => setOutputMode('visual'));
|
|
document.getElementById('toggle-synth').addEventListener('click', () => setOutputMode('synth'));
|
|
|
|
// Follow mode toggle button
|
|
document.getElementById('follow-toggle-btn').addEventListener('click', () => toggleFollowMode());
|
|
|
|
// Follow mode: global pointer move
|
|
window.addEventListener('pointermove', onFollowPointerMove);
|
|
|
|
// Presets
|
|
document.querySelectorAll('.preset-pill[data-preset]').forEach(btn => {
|
|
btn.addEventListener('click', () => loadPreset(btn.dataset.preset));
|
|
});
|
|
|
|
// Help
|
|
const helpBtn = document.getElementById('help-btn');
|
|
const helpOverlay = document.getElementById('help-overlay');
|
|
if (helpBtn && helpOverlay) {
|
|
helpBtn.addEventListener('click', () => helpOverlay.classList.toggle('hidden'));
|
|
helpOverlay.addEventListener('click', () => helpOverlay.classList.add('hidden'));
|
|
}
|
|
|
|
// Synth
|
|
c15 = new C15Bridge();
|
|
c15.onStatusChange = (msg) => {
|
|
const el = document.getElementById('synth-status');
|
|
if (el) el.textContent = msg;
|
|
};
|
|
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', () => {
|
|
visualizer.resize();
|
|
visualizer.initParticles();
|
|
if (synthVisualizer) synthVisualizer._resize();
|
|
});
|
|
|
|
// Restore saved data if available
|
|
const restored = loadState();
|
|
|
|
// Initial inference
|
|
iml.setInput(0, 0.5);
|
|
iml.setInput(1, 0.5);
|
|
iml.process();
|
|
const outputs = iml.getOutputs();
|
|
visualizer.setParams(outputs.slice(0, N_VISUAL_OUTPUTS));
|
|
updateAllParamBars(outputs);
|
|
drawConstellation(constellationCanvas, outputs, N_VISUAL_OUTPUTS);
|
|
drawLossPlot(lossCanvas, restored ? iml.lossHistory : []);
|
|
mappingView.update();
|
|
updateMetrics();
|
|
|
|
// Auto-save every 10 seconds
|
|
setInterval(saveState, 10000);
|
|
|
|
// Start animation
|
|
animate();
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', init);
|