memlnaut-nisps/playground/js/app.js
w1n5t0n 6f6a334529 feat(playground): add C15 synth mode with NISPS-controlled parameters and arpeggiator
Adds a secondary Synth mode (toggled via collapsible left side panel)
where the NISPS ML engine's 20 outputs control curated C15 synthesizer
parameters (oscillator PM, shapers, filters, reverb, echo, flanger,
output mixer) through a WASM AudioWorklet bridge.

Includes a chord progression arpeggiator with controls for tempo (BPM),
octave range, octave offset, and 4 selectable progressions. The C15
engine runs in an AudioWorklet with lock-free SharedArrayBuffer ring
buffer communication.

New files: c15/ (WASM assets), js/synth/ (bridge, arpeggiator, param map),
serve-coop.py (COOP/COEP headers for SharedArrayBuffer support).
2026-03-21 22:26:37 +02:00

616 lines
19 KiB
JavaScript

// NISPS Playground - Main application
// Wires IML engine to visual system OR C15 synth with joystick input and dual learning modes
import { IML } from './nisps/iml.js';
import { FlowFieldVisualizer } from './ui/visualizer.js';
import { VirtualJoystick } from './ui/joystick.js';
import { Controls } from './ui/controls.js';
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 } from './synth/param-map.js';
const N_INPUTS = 2;
const N_OUTPUTS = 20;
// Visual mode param display config
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 = ['#00ff88', '#00ccff', '#ff6600', '#ff00cc', '#ffcc00', '#88ff00', '#0088ff', '#ff3366', '#9bff5f', '#59d3ff', '#ff8f3f', '#a0b7ff', '#f4ff7a', '#ffa8db', '#7dffc8', '#ffd166', '#8ad4ff', '#ff5f5f', '#ffc15f', '#ff8a3d'];
// --- State ---
let iml;
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 gamepadConnected = false;
let gamepadLastAxes = [0.5, 0.5];
let followMode = false;
let visualExpanded = false;
let appRoot;
let expandVisualBtn;
// Synth state
let c15 = null;
let arpeggiator = null;
// --- Init ---
function init() {
iml = new IML(N_INPUTS, N_OUTPUTS, [10, 10, 14], 1000, 1.0, 0.00001);
iml.setLogger(msg => console.log('[NISPS]', msg));
// Visualizer
appRoot = document.querySelector('.app');
expandVisualBtn = document.getElementById('expand-visual-btn');
const canvas = document.getElementById('visual-canvas');
visualizer = new FlowFieldVisualizer(canvas);
// Joystick
joystick = new VirtualJoystick(document.getElementById('joystick-container'), {
size: 160,
trackpadScale: 2,
springBack: false,
onChange: onJoystickMove,
onFollowModeChange: (enabled) => {
followMode = enabled;
refreshDashboard();
},
});
// Parameter display
paramDisplay = new ParamDisplay(document.getElementById('param-display'), N_OUTPUTS);
// Controls
controls = new Controls(document.getElementById('controls-container'), {
onAddExample,
onTrain,
onRandomize,
onClear,
onThumbsUp,
onThumbsDown,
onModeChange,
});
// Resize handling
window.addEventListener('resize', () => {
resizeVisualizerSurface();
});
if (expandVisualBtn) {
expandVisualBtn.addEventListener('click', () => setVisualExpanded(!visualExpanded));
updateExpandButtonState();
}
// Help overlay
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'));
}
// Side panel
initSidePanel();
// Init synth
c15 = new C15Bridge();
c15.onStatusChange = (msg) => {
const el = document.getElementById('synth-status');
if (el) el.textContent = msg;
};
c15.loadParams();
arpeggiator = new Arpeggiator(c15);
// Wire synth controls
initSynthControls();
window.addEventListener('gamepadconnected', () => refreshDashboard());
window.addEventListener('gamepaddisconnected', () => refreshDashboard());
window.addEventListener('keydown', onKeyDown);
// Run initial inference to populate outputs
iml.setInput(0, 0.5);
iml.setInput(1, 0.5);
iml.process();
visualizer.setParams(iml.getOutputs());
paramDisplay.update(iml.getOutputs());
controls.updateStatus(iml.exampleCount, iml.lastLoss, noiseLevel);
controls.updateLossPlot(iml.lossHistory);
refreshDashboard();
// Start animation
animate();
// Load from localStorage if available
loadState();
}
// --- Side Panel ---
function initSidePanel() {
const panel = document.getElementById('side-panel');
const toggle = document.getElementById('side-panel-toggle');
const close = document.getElementById('side-panel-close');
const backdrop = document.getElementById('side-panel-backdrop');
const openPanel = () => {
panel.classList.add('open');
backdrop.classList.remove('hidden');
};
const closePanel = () => {
panel.classList.remove('open');
backdrop.classList.add('hidden');
};
toggle.addEventListener('click', openPanel);
close.addEventListener('click', closePanel);
backdrop.addEventListener('click', closePanel);
// Tab switching
panel.querySelectorAll('.sp-tab').forEach(tab => {
tab.addEventListener('click', () => {
const mode = tab.dataset.mode;
setOutputMode(mode);
panel.querySelectorAll('.sp-tab').forEach(t => t.classList.toggle('active', t === tab));
});
});
}
function setOutputMode(mode) {
outputMode = mode;
const synthControls = document.getElementById('synth-controls');
const visualInfo = document.getElementById('visual-info');
const presetsVisual = document.getElementById('presets-visual');
const modeBadge = document.getElementById('mode-badge');
const paramContainer = document.getElementById('param-display');
if (mode === 'synth') {
synthControls.classList.remove('hidden');
visualInfo.classList.add('hidden');
presetsVisual.classList.add('hidden');
modeBadge.textContent = 'Synth';
modeBadge.classList.add('synth');
paramContainer.classList.add('synth-mode');
// Rebuild param display with synth labels/colors
paramDisplay.setNamesAndColors(SYNTH_PARAM_NAMES, SYNTH_PARAM_COLORS);
} else {
synthControls.classList.add('hidden');
visualInfo.classList.remove('hidden');
presetsVisual.classList.remove('hidden');
modeBadge.textContent = 'Visual';
modeBadge.classList.remove('synth');
paramContainer.classList.remove('synth-mode');
// Restore visual param labels/colors
paramDisplay.setNamesAndColors(VISUAL_PARAM_NAMES, VISUAL_PARAM_COLORS);
}
// Re-run inference and route outputs
routeOutputs(iml.getOutputs());
}
function routeOutputs(outputs) {
// Always update visualizer (it's always visible)
visualizer.setParams(outputs);
// If in synth mode, also send to C15
if (outputMode === 'synth' && c15 && c15.running) {
for (let i = 0; i < outputs.length && i < SYNTH_PARAM_MAP.length; i++) {
c15.setParameter(SYNTH_PARAM_MAP[i].id, outputs[i]);
}
}
}
// --- Synth Controls ---
function initSynthControls() {
const startBtn = document.getElementById('synth-start');
const volumeSlider = document.getElementById('synth-volume');
const arpToggle = 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();
arpToggle.textContent = 'Play';
arpToggle.classList.remove('playing');
await c15.stop();
startBtn.textContent = 'Start Audio';
} else {
await c15.start();
startBtn.textContent = 'Stop Audio';
// Send current NISPS outputs to synth
routeOutputs(iml.getOutputs());
}
});
volumeSlider.addEventListener('input', (e) => {
c15.setMasterVolume(parseFloat(e.target.value));
});
arpToggle.addEventListener('click', () => {
if (!c15.running) return;
if (arpeggiator.playing) {
arpeggiator.stop();
arpToggle.textContent = 'Play';
arpToggle.classList.remove('playing');
} else {
arpeggiator.start();
arpToggle.textContent = 'Stop';
arpToggle.classList.add('playing');
}
});
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;
});
}
// --- Animation loop ---
function animate() {
if (!animating) return;
pollGamepad();
visualizer.draw();
requestAnimationFrame(animate);
}
// --- Joystick handler ---
function onJoystickMove(x, y) {
iml.setInput(0, x);
iml.setInput(1, y);
iml.process();
const outputs = iml.getOutputs();
routeOutputs(outputs);
// Only update param display from network in inference (not when user is dragging)
if (learningMode !== 'examples' || paramDisplay.activeBar < 0) {
paramDisplay.update(outputs);
}
refreshDashboard();
}
// --- Examples mode callbacks ---
function onAddExample() {
// Use current joystick position as input, param bar values as desired output
const inputs = [joystick.x, joystick.y];
const outputs = [...paramDisplay.values];
iml.addExample(inputs, outputs);
controls.updateStatus(iml.exampleCount, iml.lastLoss, noiseLevel);
refreshDashboard();
flash('btn-add');
}
function onTrain() {
const loss = trainModel();
if (loss !== null) {
// After training, switch back to inference and update display
const outputs = iml.getOutputs();
routeOutputs(outputs);
paramDisplay.update(outputs);
controls.updateStatus(iml.exampleCount, loss, noiseLevel);
controls.updateLossPlot(iml.lossHistory);
refreshDashboard();
flash('btn-train');
}
}
function onRandomize() {
iml.randomiseWeights();
const outputs = iml.getOutputs();
routeOutputs(outputs);
paramDisplay.update(outputs);
noiseLevel = 0.05; // reset noise
controls.updateStatus(iml.exampleCount, iml.lastLoss, noiseLevel);
refreshDashboard();
}
function onClear() {
iml.clearDataset();
iml.lossHistory = [];
iml.bestLoss = null;
iml.totalTrainingIterations = 0;
noiseLevel = 0.05;
controls.updateStatus(0, null, noiseLevel);
controls.updateLossPlot(iml.lossHistory);
refreshDashboard();
clearState();
}
// --- RL mode callbacks ---
function onThumbsUp() {
// Save current input->output mapping as a positive example
const inputs = [joystick.x, joystick.y];
const outputs = [...iml.getOutputs()];
iml.addExample(inputs, outputs);
// Retrain incrementally
trainModel();
// Decay noise - more positive examples = less exploration
noiseLevel *= rlExplorationDecay;
noiseLevel = Math.max(noiseLevel, 0.005);
controls.updateStatus(iml.exampleCount, iml.lastLoss, noiseLevel);
controls.updateLossPlot(iml.lossHistory);
refreshDashboard();
flash('btn-thumbsup');
}
function onThumbsDown() {
// Increase noise for more exploration
noiseLevel = Math.min(noiseLevel * 1.5, 0.3);
// Perturb weights
iml.moveWeights(noiseLevel);
const outputs = iml.getOutputs();
routeOutputs(outputs);
paramDisplay.update(outputs);
controls.updateStatus(iml.exampleCount, iml.lastLoss, noiseLevel);
refreshDashboard();
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 (e.key === '1' || e.code === 'Numpad1') {
e.preventDefault();
onThumbsDown();
} else if (e.key === '2' || e.code === 'Numpad2') {
e.preventDefault();
onThumbsUp();
}
}
function resizeVisualizerSurface() {
visualizer.resize();
visualizer.initParticles();
}
function updateExpandButtonState() {
if (!expandVisualBtn) return;
expandVisualBtn.textContent = visualExpanded ? 'Collapse' : 'Expand';
expandVisualBtn.title = visualExpanded ? 'Collapse visual area' : 'Expand visual area';
expandVisualBtn.setAttribute('aria-pressed', visualExpanded ? 'true' : 'false');
}
function setVisualExpanded(expanded) {
visualExpanded = expanded;
if (appRoot) {
appRoot.classList.toggle('expanded', visualExpanded);
}
updateExpandButtonState();
// Let CSS layout settle before resizing the drawing surface.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
resizeVisualizerSurface();
});
});
}
// --- Presets ---
window.loadPreset = function(name) {
iml.clearDataset();
if (name === 'calm-to-chaotic') {
// Bottom-left: slow, smooth, cool; top-right: fast, turbulent, warm
iml.addExample([0.1, 0.9], [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], [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], [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') {
// Left to right sweeps through hues
iml.addExample([0.0, 0.5], [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], [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], [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') {
// Center: tight spiral, edges: wide flow
iml.addExample([0.5, 0.5], [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], [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], [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], [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], [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);
paramDisplay.update(outputs);
controls.updateStatus(iml.exampleCount, loss, noiseLevel);
controls.updateLossPlot(iml.lossHistory);
refreshDashboard();
};
// --- Persistence ---
function saveState() {
try {
const state = {
features: iml.dataset.features,
labels: iml.dataset.labels,
};
localStorage.setItem('nisps-playground', JSON.stringify(state));
} catch (e) { /* ignore */ }
}
function loadState() {
try {
const data = JSON.parse(localStorage.getItem('nisps-playground'));
if (data && data.features && data.features.length > 0) {
for (let i = 0; i < data.features.length; i++) {
iml.addExample(data.features[i], data.labels[i]);
}
trainModel();
const outputs = iml.getOutputs();
routeOutputs(outputs);
paramDisplay.update(outputs);
controls.updateStatus(iml.exampleCount, iml.lastLoss, noiseLevel);
controls.updateLossPlot(iml.lossHistory);
refreshDashboard();
}
} catch (e) { /* ignore */ }
}
function clearState() {
try { localStorage.removeItem('nisps-playground'); } catch (e) { /* ignore */ }
}
// Auto-save periodically
setInterval(saveState, 10000);
// Visual feedback flash
function flash(id) {
const el = document.getElementById(id);
if (!el) return;
el.classList.add('flash');
setTimeout(() => el.classList.remove('flash'), 200);
}
function trainModel() {
let lastPlotUpdate = 0;
const loss = iml.train({
onIteration: (iter, iterLoss) => {
if (iter - lastPlotUpdate < 8) return;
lastPlotUpdate = iter;
controls.updateLossPlot([...iml.lossHistory, iterLoss]);
},
});
return loss;
}
function refreshDashboard() {
const outputs = iml.getOutputs();
let mean = 0;
for (let i = 0; i < outputs.length; i++) mean += outputs[i];
mean /= Math.max(outputs.length, 1);
let variance = 0;
for (let i = 0; i < outputs.length; i++) {
const diff = outputs[i] - mean;
variance += diff * diff;
}
variance /= Math.max(outputs.length, 1);
controls.updateMetrics({
mode: learningMode,
joystickX: joystick.x,
joystickY: joystick.y,
outputMean: mean,
outputSpread: Math.sqrt(variance),
bestLoss: iml.bestLoss,
totalTrainingIterations: iml.totalTrainingIterations,
gamepadConnected,
followMode,
});
}
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
paramDisplay.setDraggable(true);
});