// NISPS Immersive — Design A // Full-viewport flow field with floating overlays import { WasmIML } from './nisps/nisps-wasm.js'; import { FlowFieldVisualizer } from './ui/visualizer.js'; import { AudioCanvas, N_AUDIO_OUTPUTS, AUDIO_PARAM_NAMES, AUDIO_PARAM_COLORS } from './audio/audio-canvas.js'; import { C15Adapter } from './synth/c15-adapter.js'; import { Arpeggiator } from './synth/arpeggiator.js'; import { MIDIInput } from './synth/midi-input.js'; import { SYNTH_PARAM_MAP, SYNTH_PARAM_NAMES, SYNTH_PARAM_COLORS, applyCurve, applyGroupOverride } from './synth/param-map.js'; import { MIDIOutput } from './midi/midi-output.js'; import { loadCCMap, saveCCMap, createCCParam, exportCCMap, importCCMap, CC_NAMES } from './midi/midi-cc-map.js'; import { listPresets as listMidiPresets, loadPreset as loadMidiPreset, loadPresetFromFile as loadMidiPresetFromFile } from './midi/midi-cc-presets.js'; import { GamepadInput } from './ui/gamepad.js'; import { HandTracker } from './ui/hand-tracker.js'; import { createDevPanel } from './ui/dev-panel.js'; import { SYNTH_PRESETS, PRESET_TIERS } from './synth/presets.js'; // ---- Constants ---- const N_JOY_INPUTS = 2; const N_HAND_INPUTS = 14; const N_INPUTS = N_JOY_INPUTS; // default (joystick) const N_VISUAL_OUTPUTS = 20; const N_SYNTH_OUTPUTS = SYNTH_PARAM_MAP.length; // 126 // N_AUDIO_OUTPUTS = 36 — imported from audio-canvas.js let N_OUTPUTS = N_SYNTH_OUTPUTS; // Dynamic — changes with output mode let audioCanvas = null; // lazily created on first switch to audio-canvas mode const STORAGE_KEY = 'nisps-a-immersive'; 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', ]; // ---- Presets ---- const PRESETS = { 'calm-to-chaotic': [ { input: [0.1, 0.9], output: [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] }, { input: [0.9, 0.1], output: [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] }, { input: [0.5, 0.5], output: [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] }, ], 'rainbow-sweep': [ { input: [0.0, 0.5], output: [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] }, { input: [0.5, 0.5], output: [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] }, { input: [1.0, 0.5], output: [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] }, ], 'vortex': [ { input: [0.5, 0.5], output: [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] }, { input: [0.0, 0.0], output: [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] }, { input: [1.0, 1.0], output: [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] }, { input: [0.0, 1.0], output: [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] }, { input: [1.0, 0.0], output: [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] }, ], 'spiral': [ { input: [0.5, 0.5], output: [0.0, 0.6, 0.6, 0.3, 0.15, 0.2, 0.03, 0.7, 0.9, 0.25, 0.7, 0.6, 0.35, 0.8, 0.65, 0.9, 0.02, 0.3, 0.5, 0.7] }, { input: [0.0, 0.0], output: [0.8, 0.3, 0.4, 0.7, 0.8, 0.5, 0.06, 0.2, 0.5, 0.7, 0.3, 0.2, 0.7, 0.3, 0.3, 0.4, 0.15, 0.1, 0.1, 0.3] }, { input: [1.0, 1.0], output: [0.2, 0.3, 0.4, 0.1, 0.8, 0.5, 0.06, 0.2, 0.5, 0.7, 0.3, 0.2, 0.7, 0.3, 0.9, 0.4, 0.15, 0.1, 0.1, 0.3] }, ], 'embers': [ { input: [0.5, 0.5], output: [0.1, 0.4, 0.2, 0.05, 0.05, 0.6, 0.02, 0.15, 0.6, 0.3, 0.15, 0.9, 0.5, 0.7, 0.1, 0.3, 0.2, 0.0, 0.0, 0.2] }, { input: [0.2, 0.8], output: [0.5, 0.6, 0.15, 0.08, 0.08, 0.8, 0.015, 0.1, 0.8, 0.5, 0.1, 0.5, 0.8, 0.4, 0.05, 0.5, 0.1, 0.0, 0.0, 0.15] }, { input: [0.8, 0.2], output: [0.9, 0.3, 0.35, 0.02, 0.12, 0.35, 0.04, 0.3, 0.4, 0.2, 0.3, 1.0, 0.3, 0.9, 0.2, 0.15, 0.3, 0.0, 0.0, 0.3] }, ], }; // ---- App state ---- let iml; // active IML (points to imlJoy or imlHand) let imlJoy; // IML for joystick mode (2 inputs) let imlHand; // IML for hand tracking mode (14 inputs) let inputMode = 'joystick'; // 'joystick' | 'hands' let handTracker = null; let visualizer; let synthVisualizer; let activeEngine = null; // active SynthEngine (currently always a C15Adapter) let c15 = null; // alias kept for MIDIInput and volume controls that still reference it let arpeggiator = null; let midiInput = null; let outputMode = 'visual'; // ---- MIDI CC state ---- let midiOutput = null; let midiCCMap = loadCCMap(); // Per-param overrides for MIDI CC mode (same shape as visual overrides) let midiCCOverrides = midiCCMap.map(p => ({ min: p.min, max: p.max, curve: p.curve, frozen: p.muted, fixedValue: p.fixedValue, })); const MIDI_CC_PARAM_COLORS = []; // Generate distinct colors for CC params function _generateCCColors(n) { MIDI_CC_PARAM_COLORS.length = 0; for (let i = 0; i < n; i++) { const hue = (i * 137.508) % 360; // golden angle MIDI_CC_PARAM_COLORS.push(`hsl(${hue}, 70%, 60%)`); } } _generateCCColors(midiCCMap.length); // tame level is now baked into groupOverrides defaults at init time (see ?tame URL param) let spreadLevel = 0.6; let noiseLevel = 0.05; let rlExplorationDecay = 0.97; // Joystick state let joyX = 0.5; let joyY = 0.5; let joyDragging = false; let joyFollowMode = false; let joyTrail = []; // Gamepad let gamepad; // Raw param slider values (for examples mode advanced editing) let rawParamValues = new Array(N_OUTPUTS).fill(0.5); // ---- DOM refs ---- let $canvas, $heatmapCells, $heatmapTooltip; let $joystickContainer, $joyMap, $joyMapCtx; let $noiseRing, $followBadge; let $rlButtons, $btnThumbsUp, $btnThumbsDown; let $statusText; let $lossCanvas, $lossCtx; let $engineParams; let $followPill; let $synthVisCanvas; // Undo stack — stores weight snapshots before RL actions const undoStack = []; const MAX_UNDO = 20; // ---- Visual Overrides: per-param curve/min/max/freeze for visual mode ---- const visualOverrides = VISUAL_PARAM_NAMES.map(() => ({ min: 0, max: 1, curve: 0.5, frozen: false, fixedValue: 0.5, })); // ---- Audio Canvas Overrides ---- const audioCanvasOverrides = Array.from({ length: N_AUDIO_OUTPUTS }, () => ({ min: 0, max: 1, curve: 0.5, frozen: false, fixedValue: 0.5, })); // ---- Synth Sections (for SynthVisualizer) ---- const SYNTH_SECTIONS = [ { name: 'Env A', count: 7, color: '#4488ff' }, { name: 'Env B', count: 7, color: '#4488ff' }, { name: 'Env C', count: 6, color: '#6688dd' }, { name: 'Osc A', count: 5, color: '#ff8844' }, { name: 'Osc B', count: 5, color: '#ff8844' }, { name: 'Shp A', count: 6, color: '#ff4466' }, { name: 'Shp B', count: 6, color: '#ff4466' }, { name: 'Comb', count: 8, color: '#44ddaa' }, { name: 'SVF', count: 9, color: '#44ddaa' }, { name: 'Gap', count: 6, color: '#44bbaa' }, { name: 'FB Mix', count: 9, color: '#ddaa44' }, { name: 'Out Mix', count: 14, color: '#ddaa44' }, { name: 'Cabinet', count: 8, color: '#aa88dd' }, { name: 'Flanger', count: 13, color: '#dd88aa' }, { name: 'Echo', count: 7, color: '#88aadd' }, { name: 'Reverb', count: 6, color: '#88ddaa' }, { name: 'Unison', count: 3, color: '#cccccc' }, { name: 'Mono', count: 1, color: '#999999' }, ]; // ---- Group Overrides: per-group curve + per-param min/max/curve/mute ---- // groupOverrides[sectionIndex] = { curve: 0.5, params: [{ min, max, curve, muted, fixedValue }, ...] } // Defaults are seeded from safeMin/safeMax in SYNTH_PARAM_MAP, scaled by the // ?tame URL parameter (0 = unconstrained, 1 = full safe limits). // localStorage overrides on load. // Module-level tame so applyPreset() can use it const _urlTame = parseFloat(new URLSearchParams(window.location.search).get('tame') ?? '1'); const tameLevel = isNaN(_urlTame) ? 1 : Math.max(0, Math.min(1, _urlTame)); const groupOverrides = (() => { const t = tameLevel; let flatIdx = 0; return SYNTH_SECTIONS.map(sec => ({ curve: 0.5, params: new Array(sec.count).fill(null).map(() => { const p = SYNTH_PARAM_MAP[flatIdx++]; // Interpolate: at tame=0 use full [0,1]; at tame=1 use [safeMin, safeMax] const safeMin = p?.safeMin ?? 0; const safeMax = p?.safeMax ?? 1; return { min: safeMin * t, max: 1 - (1 - safeMax) * t, curve: 0.5, muted: false, fixedValue: 0.5, }; }), })); })(); // Lookup: param name -> flat index (for preset application) const paramNameToIndex = new Map(); SYNTH_PARAM_MAP.forEach((p, i) => paramNameToIndex.set(p.name, i)); // Currently active synth preset id (null = no preset / manual) let activeSynthPresetId = null; // Build a flat lookup: paramIndex -> { sectionIndex, localIndex } const paramToSection = []; { let idx = 0; for (let si = 0; si < SYNTH_SECTIONS.length; si++) { for (let li = 0; li < SYNTH_SECTIONS[si].count; li++) { paramToSection.push({ si, li }); idx++; } } // Pad for any params beyond sections while (paramToSection.length < N_SYNTH_OUTPUTS) { paramToSection.push(null); } } /** * Apply group overrides (per-param curve + min/max) to a single ML output value. * Returns the remapped value, or fixedValue if the param is muted. */ function applyGroupOverrides(rawValue, paramIndex) { const mapping = paramToSection[paramIndex]; if (!mapping) return rawValue; const ov = groupOverrides[mapping.si]; const p = ov.params[mapping.li]; if (p.muted) return p.fixedValue; // Per-param curve overrides group curve (if param curve != 0.5, use it; else use group curve) const curve = p.curve !== 0.5 ? p.curve : ov.curve; return applyGroupOverride(rawValue, curve, p.min, p.max); } /** Check if param at given index is muted */ function isParamMuted(paramIndex) { const mapping = paramToSection[paramIndex]; if (!mapping) return false; return groupOverrides[mapping.si].params[mapping.li].muted; } // ---- Synth Preset Application ---- /** * Apply a synth preset by id. * Sets groupOverrides (muted/active, min/max/curve/fixedValue) for all 126 params, * re-routes outputs, saves state, and updates the UI dropdown. */ function applyPreset(presetId) { const preset = SYNTH_PRESETS.find(p => p.id === presetId); if (!preset) { console.warn(`[NISPS] Unknown preset: ${presetId}`); return; } const t = tameLevel; const allActive = preset.active === null; // null = all params active (Tier 4) const activeSet = allActive ? null : new Set(preset.active); const overrides = preset.overrides || {}; const mutedOv = preset.mutedOverrides || {}; for (let i = 0; i < N_SYNTH_OUTPUTS; i++) { const param = SYNTH_PARAM_MAP[i]; const mapping = paramToSection[i]; if (!mapping) continue; const gp = groupOverrides[mapping.si].params[mapping.li]; const paramName = param.name; // Tame-derived defaults for this param const safeMin = param.safeMin ?? 0; const safeMax = param.safeMax ?? 1; const tameMin = safeMin * t; const tameMax = 1 - (1 - safeMax) * t; const isActive = allActive || activeSet.has(paramName); if (isActive) { gp.muted = false; const ov = overrides[paramName]; if (ov) { gp.min = ov.min !== undefined ? ov.min : tameMin; gp.max = ov.max !== undefined ? ov.max : tameMax; gp.curve = ov.curve !== undefined ? ov.curve : 0.5; gp.fixedValue = ov.fixedValue !== undefined ? ov.fixedValue : param.defaultValue; } else { gp.min = tameMin; gp.max = tameMax; gp.curve = 0.5; gp.fixedValue = param.defaultValue; } } else { gp.muted = true; const mov = mutedOv[paramName]; gp.fixedValue = (mov && mov.fixedValue !== undefined) ? mov.fixedValue : param.defaultValue; // Keep tame-derived ranges for manual unmuting gp.min = tameMin; gp.max = tameMax; gp.curve = 0.5; } } // Apply group curves for (let si = 0; si < SYNTH_SECTIONS.length; si++) { const gc = preset.groupCurves || {}; const secName = SYNTH_SECTIONS[si].name; groupOverrides[si].curve = (gc[secName] !== undefined) ? gc[secName] : 0.5; } activeSynthPresetId = presetId; // Close the group drawer if open hideGroupDrawer(); // Re-route outputs through updated overrides if (iml) { routeOutputs(iml.getOutputs()); } // Update the preset dropdown const $presetSelect = document.getElementById('synth-preset-select'); if ($presetSelect) $presetSelect.value = presetId; // Save to storage saveState(); console.log(`[NISPS] Applied synth preset: ${preset.name}`); } // ---- SynthVisualizer class ---- class SynthVisualizer { constructor(canvas) { this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.params = new Array(N_OUTPUTS).fill(0.5); this.displayParams = new Array(N_OUTPUTS).fill(0.5); this.lerpSpeed = 0.12; this.topPadding = 40; this.bottomPadding = 100; // Interaction state this._dragging = false; this._dragBarIndex = -1; this._interactionEnabled = false; // Hover tooltip state this._hoveredBar = -1; this._tooltipEl = null; // Build section map this.sectionMap = []; let idx = 0; for (const sec of SYNTH_SECTIONS) { for (let i = 0; i < sec.count && idx < N_OUTPUTS; i++, idx++) { this.sectionMap.push(sec); } } // Fill remainder with generic while (this.sectionMap.length < N_OUTPUTS) { this.sectionMap.push({ name: 'Other', count: 1, color: '#666666' }); } // Always-on hover tracking for tooltip (independent of enableInteraction) this.canvas.addEventListener('pointermove', (e) => { if (this._dragging) return; // interaction handler takes over const idx = this.hitTest(e.clientX, e.clientY); this._hoveredBar = idx; // Store mouse position in canvas pixels for tooltip const rect = this.canvas.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1; this._mouseCanvasX = (e.clientX - rect.left) * dpr; this._mouseCanvasY = (e.clientY - rect.top) * dpr; }); this.canvas.addEventListener('pointerleave', () => { this._hoveredBar = -1; }); this.resize(); } resize() { this.canvas.width = window.innerWidth * (window.devicePixelRatio || 1); this.canvas.height = window.innerHeight * (window.devicePixelRatio || 1); this.canvas.style.width = '100%'; this.canvas.style.height = '100%'; } setParams(outputs) { for (let i = 0; i < N_OUTPUTS && i < outputs.length; i++) { this.params[i] = outputs[i]; } } draw() { const ctx = this.ctx; const W = this.canvas.width; const H = this.canvas.height; const dpr = window.devicePixelRatio || 1; const n = N_OUTPUTS; // Lerp display values toward target for (let i = 0; i < n; i++) { this.displayParams[i] += (this.params[i] - this.displayParams[i]) * this.lerpSpeed; } // Background ctx.fillStyle = '#0a0a0a'; ctx.fillRect(0, 0, W, H); // Build list of visible (non-muted) param indices const visibleIndices = []; for (let i = 0; i < n; i++) { if (!isParamMuted(i)) visibleIndices.push(i); } const nVisible = visibleIndices.length || 1; // Calculate section gap positions (among visible params only) const sectionGaps = new Set(); let prevSi = -1; for (const vi of visibleIndices) { const mapping = paramToSection[vi]; const curSi = mapping ? mapping.si : -1; if (prevSi >= 0 && curSi !== prevSi) { sectionGaps.add(vi); } prevSi = curSi; } const topPad = this.topPadding * dpr; const bottomPad = this.bottomPadding * dpr; const totalGapPx = sectionGaps.size * 2 * dpr; const barAreaWidth = W - totalGapPx; const barWidth = barAreaWidth / nVisible; const usableHeight = H - topPad - bottomPad; const maxBarHeight = usableHeight; // Store layout for interaction hit-testing this._layout = { W, H, n, barWidth, totalGapPx, sectionGaps, topPad, bottomPad, usableHeight, dpr, visibleIndices }; // Draw bars let x = 0; let prevSection = null; let sectionStartX = 0; let sectionIndex = -1; // Store bar x positions for hit testing (indexed by original param index) this._barXPositions = new Array(n).fill(-1); this._barWidths = new Array(n).fill(0); // Store section label regions (in CSS pixels) for drawer hit testing this._sectionLabelRegions = []; for (let vi = 0; vi < visibleIndices.length; vi++) { const i = visibleIndices[vi]; const sec = this.sectionMap[i]; const mapping = paramToSection[i]; const curSi = mapping ? mapping.si : -1; // Section divider gap if (sectionGaps.has(i)) { // Draw section label for the previous section at top if (prevSection) { this._drawSectionLabel(ctx, prevSection.name, sectionStartX, x, topPad, prevSection.color); this._sectionLabelRegions.push({ index: sectionIndex, left: sectionStartX / dpr, right: x / dpr, top: 0, bottom: topPad / dpr, name: prevSection.name, }); } sectionIndex = curSi; x += 2 * dpr; sectionStartX = x; prevSection = sec; } if (vi === 0) { sectionStartX = x; prevSection = sec; sectionIndex = curSi; } this._barXPositions[i] = x; this._barWidths[i] = barWidth; const val = this.displayParams[i]; const barH = val * maxBarHeight; const barY = topPad + usableHeight - barH; // Bar with slight transparency ctx.fillStyle = sec.color + 'cc'; ctx.fillRect(x, barY, Math.max(barWidth - 0.5, 1), barH); // Bright top edge ctx.fillStyle = sec.color; ctx.fillRect(x, barY, Math.max(barWidth - 0.5, 1), Math.min(2 * dpr, barH)); x += barWidth; } // Draw final section label at top if (prevSection) { this._drawSectionLabel(ctx, prevSection.name, sectionStartX, x, topPad, prevSection.color); this._sectionLabelRegions.push({ index: sectionIndex, left: sectionStartX / dpr, right: x / dpr, top: 0, bottom: topPad / dpr, name: prevSection.name, }); } // Draw tooltip for hovered bar this._drawTooltip(ctx, dpr); } _drawSectionLabel(ctx, name, startX, endX, topPad, color) { const dpr = window.devicePixelRatio || 1; const fontSize = 9 * dpr; ctx.save(); ctx.font = `${fontSize}px 'JetBrains Mono', monospace`; ctx.fillStyle = color + '88'; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; const cx = (startX + endX) / 2; ctx.fillText(name, cx, topPad - 4 * dpr); ctx.restore(); } // Returns section region at client coordinates, or null hitTestSection(clientX, clientY) { if (!this._sectionLabelRegions) return null; const rect = this.canvas.getBoundingClientRect(); const px = clientX - rect.left; const py = clientY - rect.top; for (const region of this._sectionLabelRegions) { if (px >= region.left && px <= region.right && py >= region.top && py <= region.bottom) { return region; } } return null; } // Returns bar index at canvas-relative pixel x, or -1 hitTest(clientX, clientY) { if (!this._barXPositions || !this._layout) return -1; const rect = this.canvas.getBoundingClientRect(); const dpr = this._layout.dpr; const px = (clientX - rect.left) * dpr; const n = this._layout.n; for (let i = 0; i < n; i++) { const bx = this._barXPositions[i]; if (bx < 0) continue; // muted const bw = this._barWidths[i] || this._layout.barWidth; if (px >= bx && px < bx + bw) return i; } return -1; } // Returns 0-1 value from clientY (top of bar area = 1, bottom = 0) yToValue(clientY) { if (!this._layout) return 0.5; const rect = this.canvas.getBoundingClientRect(); const dpr = this._layout.dpr; const py = (clientY - rect.top) * dpr; const { topPad, usableHeight } = this._layout; const val = 1 - (py - topPad) / usableHeight; return Math.max(0, Math.min(1, val)); } enableInteraction(enabled) { if (enabled && !this._interactionEnabled) { this._interactionEnabled = true; this.canvas.style.cursor = 'crosshair'; this._onPointerDown = (e) => { const idx = this.hitTest(e.clientX, e.clientY); if (idx < 0) return; e.preventDefault(); this._dragging = true; this._dragBarIndex = idx; this.canvas.setPointerCapture(e.pointerId); const val = this.yToValue(e.clientY); rawParamValues[idx] = val; this.params[idx] = val; routeOutputs(rawParamValues); updateHeatmap(rawParamValues); }; this._onPointerMove = (e) => { if (!this._dragging) return; e.preventDefault(); // Allow sliding to adjacent bars let idx = this.hitTest(e.clientX, e.clientY); if (idx < 0) idx = this._dragBarIndex; this._dragBarIndex = idx; const val = this.yToValue(e.clientY); rawParamValues[idx] = val; this.params[idx] = val; routeOutputs(rawParamValues); updateHeatmap(rawParamValues); }; this._onPointerUp = (e) => { this._dragging = false; this._dragBarIndex = -1; }; this.canvas.addEventListener('pointerdown', this._onPointerDown); this.canvas.addEventListener('pointermove', this._onPointerMove); this.canvas.addEventListener('pointerup', this._onPointerUp); this.canvas.addEventListener('pointercancel', this._onPointerUp); } else if (!enabled && this._interactionEnabled) { this._interactionEnabled = false; this.canvas.style.cursor = ''; this._dragging = false; this._dragBarIndex = -1; this._hoveredBar = -1; if (this._onPointerDown) { this.canvas.removeEventListener('pointerdown', this._onPointerDown); this.canvas.removeEventListener('pointermove', this._onPointerMove); this.canvas.removeEventListener('pointerup', this._onPointerUp); this.canvas.removeEventListener('pointercancel', this._onPointerUp); } } } _drawTooltip(ctx, dpr) { const i = this._hoveredBar; if (i < 0 || !this._layout) return; if (this._barXPositions[i] < 0) return; // muted const name = SYNTH_PARAM_NAMES[i] || `p${i}`; const val = this.displayParams[i]; const mapping = paramToSection[i]; let rangeStr = '0.00 – 1.00'; let curveStr = '0.50'; if (mapping) { const ov = groupOverrides[mapping.si]; const p = ov.params[mapping.li]; rangeStr = `${p.min.toFixed(2)} – ${p.max.toFixed(2)}`; const curve = p.curve !== 0.5 ? p.curve : ov.curve; curveStr = curve.toFixed(2); } const lines = [name, `Val: ${val.toFixed(2)}`, `Range: ${rangeStr}`, `Curve: ${curveStr}`]; const fontSize = 10 * dpr; const lineHeight = fontSize * 1.4; const padX = 8 * dpr; const padY = 6 * dpr; ctx.save(); ctx.font = `${fontSize}px 'JetBrains Mono', monospace`; // Measure text let maxW = 0; for (const line of lines) { const m = ctx.measureText(line); if (m.width > maxW) maxW = m.width; } const boxW = maxW + padX * 2; const boxH = lines.length * lineHeight + padY * 2; // Position near the mouse cursor const mx = this._mouseCanvasX || 0; const my = this._mouseCanvasY || 0; const offset = 12 * dpr; let tx = mx + offset; let ty = my - boxH - offset; // Clamp to canvas if (tx + boxW > this._layout.W - 2 * dpr) tx = mx - boxW - offset; if (ty < 2 * dpr) ty = my + offset; if (tx < 2 * dpr) tx = 2 * dpr; // Background ctx.fillStyle = 'rgba(0, 0, 0, 0.88)'; ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)'; ctx.lineWidth = 1; const r = 4 * dpr; ctx.beginPath(); ctx.roundRect(tx, ty, boxW, boxH, r); ctx.fill(); ctx.stroke(); // Text ctx.textAlign = 'left'; ctx.textBaseline = 'top'; for (let li = 0; li < lines.length; li++) { ctx.fillStyle = li === 0 ? '#ffffff' : 'rgba(255,255,255,0.6)'; if (li === 0) ctx.font = `bold ${fontSize}px 'JetBrains Mono', monospace`; else ctx.font = `${fontSize}px 'JetBrains Mono', monospace`; ctx.fillText(lines[li], tx + padX, ty + padY + li * lineHeight); } ctx.restore(); } } // ---- Preset padding ---- function padPresetOutputs(outputs) { const padded = new Array(N_OUTPUTS).fill(0.5); for (let i = 0; i < outputs.length && i < padded.length; i++) padded[i] = outputs[i]; return padded; } // ---- MLP Resize ---- // Returns the target output count for a given mode function outputCountForMode(mode) { if (mode === 'visual') return N_VISUAL_OUTPUTS; if (mode === 'synth') return N_SYNTH_OUTPUTS; if (mode === 'midi-cc') return midiCCMap.length; if (mode === 'audio-canvas') return N_AUDIO_OUTPUTS; return N_SYNTH_OUTPUTS; } /** * Recreate IML instances with a new output count. * Joystick IML uses warm-start weight transfer to preserve learned mappings. * Training examples are always cleared (dataset is JS-side and output-count-specific). */ // ---- Engine switching (stub for meml-phg UI) ---- /** * Hot-swap the active synth engine. * Reinitialises the arpeggiator and resizes the MLP to match the new engine's * param count. The new engine must already be initialised (init() called). * * @param {import('./synth/engine-interface.js').SynthEngine} engine */ async function setActiveEngine(engine) { activeEngine = engine; c15 = engine; // keep alias in sync arpeggiator.setEngine(engine); await resizeMLP(engine.paramCount); } async function resizeMLP(newOutputCount) { if (newOutputCount === N_OUTPUTS) return; N_OUTPUTS = newOutputCount; // Extract joystick weights before destroying (for warm-start transfer) const joySnapshot = imlJoy ? imlJoy.extractWeights() : null; // Destroy old IML instances (free WASM memory) if (imlJoy) imlJoy.destroy(); if (imlHand) imlHand.destroy(); // Joystick IML: warm-start from previous weights when possible if (joySnapshot) { imlJoy = await WasmIML.createWithWarmStart(joySnapshot, N_OUTPUTS, 1000, 1.0, 0.00001); } else { imlJoy = await WasmIML.create(N_JOY_INPUTS, N_OUTPUTS, [32, 48, 64], 1000, 1.0, 0.00001); imlJoy.randomiseWeights(spreadLevel); } imlJoy.setLogger(msg => console.log('[NISPS:joy]', msg)); // Hand IML: fresh init (warm-start for 14-input networks is a future concern) imlHand = await WasmIML.create(N_HAND_INPUTS, N_OUTPUTS, [48, 48, 64], 1000, 1.0, 0.00001); imlHand.setLogger(msg => console.log('[NISPS:hand]', msg)); imlHand.randomiseWeights(spreadLevel); iml = (inputMode === 'joystick') ? imlJoy : imlHand; // Reset dependent state rawParamValues = new Array(N_OUTPUTS).fill(0.5); _lastSentParams = new Float32Array(N_OUTPUTS); // Re-run inference iml.setInput(0, joyX); iml.setInput(1, joyY); iml.process(); console.log(`[NISPS] MLP resized to ${N_OUTPUTS} outputs (joystick IML warm-started)`); } // ---- Init ---- async function init() { // Parse ?tame URL param const urlParams = new URLSearchParams(window.location.search); // tame is handled at groupOverrides init time, no longer needed here spreadLevel = parseFloat(urlParams.get('spread') ?? '0.6'); if (isNaN(spreadLevel)) spreadLevel = 0.6; spreadLevel = Math.max(0, Math.min(1, spreadLevel)); // Dual IML instances — joystick (2 inputs) and hand tracking (14 inputs) imlJoy = await WasmIML.create(N_JOY_INPUTS, N_OUTPUTS, [32, 48, 64], 1000, 1.0, 0.00001); imlJoy.setLogger(msg => console.log('[NISPS:joy]', msg)); imlHand = await WasmIML.create(N_HAND_INPUTS, N_OUTPUTS, [48, 48, 64], 1000, 1.0, 0.00001); imlHand.setLogger(msg => console.log('[NISPS:hand]', msg)); iml = imlJoy; // default to joystick // Canvas + Visualizer $canvas = document.getElementById('vis-canvas'); visualizer = new FlowFieldVisualizer($canvas); // Synth visualizer $synthVisCanvas = document.getElementById('synth-vis-canvas'); synthVisualizer = new SynthVisualizer($synthVisCanvas); // Synth — use C15Adapter to satisfy the SynthEngine interface activeEngine = new C15Adapter(); c15 = activeEngine; // alias: existing code that references c15 continues to work activeEngine.onStatusChange = msg => { const el = document.getElementById('synth-status'); if (el) el.textContent = msg; }; arpeggiator = new Arpeggiator(activeEngine); midiInput = new MIDIInput(activeEngine.bridge); // MIDIInput still talks to the C15 bridge directly initMIDIControls(); // MIDI Output midiOutput = new MIDIOutput(); initMIDICCControls(); // DOM refs $heatmapCells = document.getElementById('heatmap-cells'); $heatmapTooltip = document.getElementById('heatmap-tooltip'); $joystickContainer = document.getElementById('joystick-container'); $joyMap = document.getElementById('joy-map'); $joyMapCtx = $joyMap.getContext('2d'); $noiseRing = document.getElementById('noise-ring'); $followBadge = document.getElementById('follow-badge'); $rlButtons = document.getElementById('rl-buttons'); $btnThumbsUp = document.getElementById('btn-thumbsup'); $btnThumbsDown = document.getElementById('btn-thumbsdown'); $statusText = document.getElementById('status-text'); $lossCanvas = document.getElementById('loss-canvas'); $lossCtx = $lossCanvas.getContext('2d'); $engineParams = document.getElementById('engine-params'); $followPill = document.getElementById('follow-pill'); // Build heatmap cells buildHeatmap(); // Build raw param sliders buildEngineParams(); // Wire events wireJoystick(); wireDock(); wireControls(); wireSynthControls(); wireGamepad(); wireKeyboard(); wireInputToggle(); createDevPanel(() => handTracker); wireQuickPlayControls(); wireGroupDrawer(); wireParamPopup(); wireHelp(); // Resize window.addEventListener('resize', onResize); onResize(); // Restore saved state (if any) await loadState(); // Wire synth preset selector wireSynthPresets(); // Check URL ?preset param (overrides localStorage) const urlPreset = urlParams.get('preset'); if (urlPreset && SYNTH_PRESETS.some(p => p.id === urlPreset)) { applyPreset(urlPreset); } else if (activeSynthPresetId) { // Restored from localStorage — sync the dropdown const $ps = document.getElementById('synth-preset-select'); if ($ps) $ps.value = activeSynthPresetId; } // Initial inference iml.setInput(0, joyX); iml.setInput(1, joyY); iml.process(); routeOutputs(iml.getOutputs()); updateHeatmap(iml.getOutputs()); updateStatus(); drawJoyMap(); drawLossPlot(); // Auto-save every 10 seconds setInterval(saveState, 10000); // Debug probe — exposed on window when ?debug=1 is in the URL. // Used by Playwright e2e tests. Zero footprint in production. if (new URLSearchParams(window.location.search).has('debug')) { window.__nisps = { get iml() { return iml; }, get imlJoy() { return imlJoy; }, get imlHand(){ return imlHand; }, getOutputs: () => [...iml.getOutputs()], getLoss: () => iml.lastLoss, getWeights: () => iml._getFlatWeights(), getExampleCount: () => iml.exampleCount, setInputs: (x, y) => { iml.setInput(0, x); iml.setInput(1, y); iml.process(); const outputs = iml.getOutputs(); routeOutputs(outputs); updateHeatmap(outputs); }, thumbsUp: () => onThumbsUp(), thumbsDown: () => onThumbsDown(), train: () => { const loss = trainModel(); const outputs = iml.getOutputs(); routeOutputs(outputs); updateHeatmap(outputs); syncRawParamsFromOutputs(outputs); updateStatus(); drawLossPlot(); return loss; }, trainAsync: () => new Promise(resolve => trainModelAsync(resolve)), randomise: () => { iml.randomiseWeights(spreadLevel); const outputs = iml.getOutputs(); routeOutputs(outputs); updateHeatmap(outputs); syncRawParamsFromOutputs(outputs); updateStatus(); }, clearExamples: () => { iml.clearDataset(); updateStatus(); }, saveState: () => saveState(), evalLoss: () => iml.evalLoss(), inferBatch: (points) => iml.inferBatch(points), getLayerStats:() => iml.getLayerStats(), }; } // Start animation requestAnimationFrame(animate); } // ---- Heatmap (bar chart style) ---- function buildHeatmap() { $heatmapCells.innerHTML = ''; const isSynth = outputMode === 'synth'; const isMidiCC = outputMode === 'midi-cc'; const isAudioCanvas = outputMode === 'audio-canvas'; const count = isMidiCC ? midiCCMap.length : isSynth ? N_SYNTH_OUTPUTS : isAudioCanvas ? N_AUDIO_OUTPUTS : N_VISUAL_OUTPUTS; const names = isMidiCC ? midiCCMap.map(p => p.name) : isSynth ? SYNTH_PARAM_NAMES : isAudioCanvas ? AUDIO_PARAM_NAMES : VISUAL_PARAM_NAMES; const colors = isMidiCC ? MIDI_CC_PARAM_COLORS : isSynth ? SYNTH_PARAM_COLORS : isAudioCanvas ? AUDIO_PARAM_COLORS : VISUAL_PARAM_COLORS; for (let i = 0; i < count; i++) { const cell = document.createElement('div'); cell.className = 'heatmap-cell'; cell.dataset.index = i; const bar = document.createElement('div'); bar.className = 'heatmap-cell-bar'; bar.style.background = colors[i]; bar.style.width = '30%'; // default bar.style.height = '100%'; cell.appendChild(bar); // Tooltip on hover (quick info) + click to open popup cell.addEventListener('pointerenter', (e) => { if (cell._dragging) return; const outputs = iml.getOutputs(); $heatmapTooltip.textContent = `${names[i]}: ${outputs[i].toFixed(3)} ▾`; $heatmapTooltip.classList.add('visible'); const rect = cell.getBoundingClientRect(); $heatmapTooltip.style.left = `${rect.left}px`; }); cell.addEventListener('pointerleave', () => { if (!cell._dragging) $heatmapTooltip.classList.remove('visible'); // Delay hide popup so user can move to it if (activePopupParam === i) { popupHideTimer = setTimeout(() => hideParamPopup(), 300); } }); // Click opens popup; drag sets parameter value cell.addEventListener('pointerdown', (e) => { e.preventDefault(); cell._downX = e.clientX; cell._downY = e.clientY; cell._didDrag = false; cell._dragging = true; cell.classList.add('dragging'); cell.setPointerCapture(e.pointerId); }); cell.addEventListener('pointermove', (e) => { if (!cell._dragging) return; const dx = Math.abs(e.clientX - cell._downX); const dy = Math.abs(e.clientY - cell._downY); if (dx > 3 || dy > 3) cell._didDrag = true; if (cell._didDrag) setHeatmapValue(i, e, cell); }); cell.addEventListener('pointerup', () => { cell._dragging = false; cell.classList.remove('dragging'); if (!cell._didDrag) { // Click — toggle popup clearTimeout(popupHideTimer); if (activePopupParam === i) { hideParamPopup(); } else { showParamPopup(i, cell); } } }); cell.addEventListener('pointercancel', () => { cell._dragging = false; cell.classList.remove('dragging'); }); $heatmapCells.appendChild(cell); } } function setHeatmapValue(paramIndex, e, cell) { const rect = cell.getBoundingClientRect(); const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); // If frozen, dragging updates the fixed value directly const ov = getParamOverride(paramIndex); if (ov && ov.frozen) { ov.fixedValue = x; } rawParamValues[paramIndex] = x; routeOutputs(rawParamValues); updateHeatmap(rawParamValues); syncRawParamsFromOutputs(rawParamValues); // Update tooltip const isSynth = outputMode === 'synth'; const isMidiCC = outputMode === 'midi-cc'; const isAudioCanvas = outputMode === 'audio-canvas'; const names = isMidiCC ? midiCCMap.map(p => p.name) : isSynth ? SYNTH_PARAM_NAMES : isAudioCanvas ? AUDIO_PARAM_NAMES : VISUAL_PARAM_NAMES; $heatmapTooltip.textContent = `${names[paramIndex]}: ${x.toFixed(3)}`; $heatmapTooltip.classList.add('visible'); $heatmapTooltip.style.left = `${rect.left}px`; } function updateHeatmap(outputs) { const cells = $heatmapCells.children; for (let i = 0; i < cells.length && i < outputs.length; i++) { const bar = cells[i].querySelector('.heatmap-cell-bar'); if (bar) { const pct = Math.max(2, Math.round(outputs[i] * 100)); bar.style.width = pct + '%'; } // Dim frozen/muted cells const ov = getParamOverride(i); if (ov) { cells[i].classList.toggle('heatmap-cell-frozen', !!ov.frozen); } } } /** Get the override object for a heatmap param (works in both visual & synth mode) */ function getParamOverride(paramIndex) { if (outputMode === 'synth') { const mapping = paramToSection[paramIndex]; if (!mapping) return null; const p = groupOverrides[mapping.si].params[mapping.li]; // Expose as { min, max, curve, frozen, fixedValue } — map 'muted' to 'frozen' return { get min() { return p.min; }, set min(v) { p.min = v; }, get max() { return p.max; }, set max(v) { p.max = v; }, get curve() { return p.curve; }, set curve(v) { p.curve = v; }, get frozen() { return p.muted; }, set frozen(v) { p.muted = v; }, get fixedValue() { return p.fixedValue; }, set fixedValue(v) { p.fixedValue = v; }, }; } else if (outputMode === 'midi-cc') { return midiCCOverrides[paramIndex] || null; } else if (outputMode === 'audio-canvas') { return audioCanvasOverrides[paramIndex] || null; } else { return visualOverrides[paramIndex] || null; } } // ---- Param Popup (hover menu on heatmap cells) ---- function wireParamPopup() { $paramPopup = document.createElement('div'); $paramPopup.className = 'param-popup'; $paramPopup.innerHTML = '
'; document.body.appendChild($paramPopup); $paramPopup.addEventListener('pointerenter', () => { clearTimeout(popupHideTimer); }); $paramPopup.addEventListener('pointerleave', () => { popupHideTimer = setTimeout(() => hideParamPopup(), 300); }); } function showParamPopup(paramIndex, cell) { if (activePopupParam === paramIndex) return; activePopupParam = paramIndex; const isSynth = outputMode === 'synth'; const isMidiCC = outputMode === 'midi-cc'; const isAudioCanvas = outputMode === 'audio-canvas'; const names = isMidiCC ? midiCCMap.map(p => p.name) : isSynth ? SYNTH_PARAM_NAMES : isAudioCanvas ? AUDIO_PARAM_NAMES : VISUAL_PARAM_NAMES; const colors = isMidiCC ? MIDI_CC_PARAM_COLORS : isSynth ? SYNTH_PARAM_COLORS : isAudioCanvas ? AUDIO_PARAM_COLORS : VISUAL_PARAM_COLORS; const name = names[paramIndex]; const color = colors[paramIndex]; const ov = getParamOverride(paramIndex); if (!ov) return; const header = $paramPopup.querySelector('.pp-header'); header.textContent = name; header.style.color = color; const body = $paramPopup.querySelector('.pp-body'); body.innerHTML = ''; // -- MIDI CC specific: name, CC#, channel editors -- if (isMidiCC) { const ccParam = midiCCMap[paramIndex]; // Editable name const nameRow = document.createElement('div'); nameRow.className = 'pp-row'; const nameLabel = document.createElement('span'); nameLabel.className = 'pp-label'; nameLabel.textContent = 'Name'; const nameInput = document.createElement('input'); nameInput.type = 'text'; nameInput.value = ccParam.name; nameInput.className = 'pp-text-input'; nameInput.addEventListener('change', () => { ccParam.name = nameInput.value; header.textContent = nameInput.value; buildHeatmap(); updateHeatmap(iml.getOutputs()); saveCCMap(midiCCMap); }); nameRow.appendChild(nameLabel); nameRow.appendChild(nameInput); body.appendChild(nameRow); // CC number const ccRow = document.createElement('div'); ccRow.className = 'pp-row'; const ccLabel = document.createElement('span'); ccLabel.className = 'pp-label'; ccLabel.textContent = 'CC#'; const ccInput = document.createElement('input'); ccInput.type = 'number'; ccInput.min = '0'; ccInput.max = '127'; ccInput.value = ccParam.cc; ccInput.className = 'pp-num-input'; ccInput.addEventListener('change', () => { ccParam.cc = parseInt(ccInput.value) || 0; // Auto-name if name matches old CC name if (CC_NAMES[ccParam.cc]) { ccParam.name = CC_NAMES[ccParam.cc]; nameInput.value = ccParam.name; header.textContent = ccParam.name; buildHeatmap(); updateHeatmap(iml.getOutputs()); } saveCCMap(midiCCMap); }); ccRow.appendChild(ccLabel); ccRow.appendChild(ccInput); body.appendChild(ccRow); // Channel const chRow = document.createElement('div'); chRow.className = 'pp-row'; const chLabel = document.createElement('span'); chLabel.className = 'pp-label'; chLabel.textContent = 'Ch'; const chInput = document.createElement('input'); chInput.type = 'number'; chInput.min = '1'; chInput.max = '16'; chInput.value = ccParam.channel; chInput.className = 'pp-num-input'; chInput.addEventListener('change', () => { ccParam.channel = Math.max(1, Math.min(16, parseInt(chInput.value) || 1)); chInput.value = ccParam.channel; saveCCMap(midiCCMap); }); chRow.appendChild(chLabel); chRow.appendChild(chInput); body.appendChild(chRow); } // -- Curve row -- const curveRow = document.createElement('div'); curveRow.className = 'pp-row'; const curveLabel = document.createElement('span'); curveLabel.className = 'pp-label'; curveLabel.textContent = 'Curve'; const curveCanvas = document.createElement('canvas'); curveCanvas.className = 'pp-curve-canvas'; curveCanvas.width = 36; curveCanvas.height = 36; const curveVal = document.createElement('span'); curveVal.className = 'pp-val'; curveVal.textContent = ov.curve.toFixed(2); function redrawCurve() { _drawCurveOnCanvas(curveCanvas, ov.curve, color); } _wireCurveDrag(curveCanvas, () => ov.curve, (v) => { ov.curve = v; curveVal.textContent = v.toFixed(2); redrawCurve(); routeOutputs(iml.getOutputs()); }); redrawCurve(); curveRow.appendChild(curveLabel); curveRow.appendChild(curveCanvas); curveRow.appendChild(curveVal); body.appendChild(curveRow); // -- Min/Max row -- const rangeRow = document.createElement('div'); rangeRow.className = 'pp-row'; const rangeLabel = document.createElement('span'); rangeLabel.className = 'pp-label'; rangeLabel.textContent = 'Range'; const rangeWrap = document.createElement('div'); rangeWrap.className = 'pp-range-wrap'; const rangeFill = document.createElement('div'); rangeFill.className = 'gd-range-fill'; const minSlider = document.createElement('input'); minSlider.type = 'range'; minSlider.min = '0'; minSlider.max = '1'; minSlider.step = '0.01'; minSlider.value = ov.min; minSlider.className = 'gd-range-input gd-range-min'; const maxSlider = document.createElement('input'); maxSlider.type = 'range'; maxSlider.min = '0'; maxSlider.max = '1'; maxSlider.step = '0.01'; maxSlider.value = ov.max; maxSlider.className = 'gd-range-input gd-range-max'; const rangeValSpan = document.createElement('span'); rangeValSpan.className = 'pp-val'; function updateRangeFill() { rangeFill.style.left = `${ov.min * 100}%`; rangeFill.style.width = `${(ov.max - ov.min) * 100}%`; rangeValSpan.textContent = `${ov.min.toFixed(2)}–${ov.max.toFixed(2)}`; } updateRangeFill(); minSlider.addEventListener('input', () => { ov.min = parseFloat(minSlider.value); if (ov.min > ov.max) { ov.max = ov.min; maxSlider.value = ov.max; } updateRangeFill(); routeOutputs(iml.getOutputs()); }); maxSlider.addEventListener('input', () => { ov.max = parseFloat(maxSlider.value); if (ov.max < ov.min) { ov.min = ov.max; minSlider.value = ov.min; } updateRangeFill(); routeOutputs(iml.getOutputs()); }); rangeWrap.appendChild(rangeFill); rangeWrap.appendChild(minSlider); rangeWrap.appendChild(maxSlider); rangeRow.appendChild(rangeLabel); rangeRow.appendChild(rangeWrap); rangeRow.appendChild(rangeValSpan); body.appendChild(rangeRow); // -- Freeze row -- const freezeRow = document.createElement('div'); freezeRow.className = 'pp-row pp-freeze-row'; const freezeBtn = document.createElement('button'); freezeBtn.className = 'pp-freeze-btn' + (ov.frozen ? ' frozen' : ''); freezeBtn.textContent = ov.frozen ? 'Frozen' : 'Freeze'; freezeBtn.title = ov.frozen ? 'Unfreeze — re-enable NISPS control' : 'Freeze — lock value, disable NISPS'; const valSlider = document.createElement('input'); valSlider.type = 'range'; valSlider.min = '0'; valSlider.max = '1'; valSlider.step = '0.01'; valSlider.value = ov.fixedValue; valSlider.className = 'pp-val-slider'; if (!ov.frozen) valSlider.style.display = 'none'; const valDisplay = document.createElement('span'); valDisplay.className = 'pp-val'; valDisplay.textContent = ov.frozen ? ov.fixedValue.toFixed(2) : ''; freezeBtn.addEventListener('click', () => { ov.frozen = !ov.frozen; freezeBtn.classList.toggle('frozen', ov.frozen); freezeBtn.textContent = ov.frozen ? 'Frozen' : 'Freeze'; freezeBtn.title = ov.frozen ? 'Unfreeze — re-enable NISPS control' : 'Freeze — lock value, disable NISPS'; valSlider.style.display = ov.frozen ? '' : 'none'; valDisplay.textContent = ov.frozen ? ov.fixedValue.toFixed(2) : ''; // When freezing, capture the current output value if (ov.frozen) { const outputs = iml.getOutputs(); ov.fixedValue = outputs[paramIndex]; valSlider.value = ov.fixedValue; valDisplay.textContent = ov.fixedValue.toFixed(2); } routeOutputs(iml.getOutputs()); }); valSlider.addEventListener('input', () => { ov.fixedValue = parseFloat(valSlider.value); valDisplay.textContent = ov.fixedValue.toFixed(2); routeOutputs(iml.getOutputs()); }); freezeRow.appendChild(freezeBtn); freezeRow.appendChild(valSlider); freezeRow.appendChild(valDisplay); body.appendChild(freezeRow); // Position below the heatmap cell const cellRect = cell.getBoundingClientRect(); const popupWidth = 260; let left = cellRect.left + cellRect.width / 2 - popupWidth / 2; left = Math.max(4, Math.min(left, window.innerWidth - popupWidth - 4)); $paramPopup.style.left = `${left}px`; $paramPopup.style.top = `${cellRect.bottom + 4}px`; $paramPopup.classList.add('visible'); } function hideParamPopup() { activePopupParam = -1; if ($paramPopup) $paramPopup.classList.remove('visible'); } // ---- Joy Map (merged joystick + minimap) ---- function drawJoyMap() { const canvas = $joyMap; const ctx = $joyMapCtx; const w = canvas.width; const h = canvas.height; const cx = w / 2; const cy = h / 2; const r = w / 2 - 2; // Clear with circle clip ctx.save(); ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.clip(); // Background ctx.fillStyle = 'rgba(13, 13, 13, 0.7)'; ctx.fillRect(0, 0, w, h); // Grid lines ctx.strokeStyle = 'rgba(255, 255, 255, 0.06)'; ctx.lineWidth = 0.5; for (let frac = 0.25; frac < 1; frac += 0.25) { ctx.beginPath(); ctx.moveTo(frac * w, 0); ctx.lineTo(frac * w, h); ctx.moveTo(0, frac * h); ctx.lineTo(w, frac * h); ctx.stroke(); } // Ring border ctx.strokeStyle = 'rgba(255, 255, 255, 0.12)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.stroke(); // Training example dots const features = iml.dataset.features; const labels = iml.dataset.labels; for (let i = 0; i < features.length; i++) { const fx = features[i][0] * w; const fy = (1 - features[i][1]) * h; let hue = 0; if (labels[i]) { hue = (labels[i][3] || 0) * 360; } ctx.fillStyle = `hsla(${hue}, 80%, 60%, 0.85)`; ctx.beginPath(); ctx.arc(fx, fy, 3, 0, Math.PI * 2); ctx.fill(); } // Current position knob (accent dot with glow) const px = joyX * w; const py = (1 - joyY) * h; // Glow ctx.shadowColor = 'rgba(255, 106, 0, 0.6)'; ctx.shadowBlur = 12; ctx.fillStyle = 'rgba(255, 106, 0, 0.9)'; ctx.beginPath(); ctx.arc(px, py, 8, 0, Math.PI * 2); ctx.fill(); // Inner bright dot ctx.shadowBlur = 0; ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'; ctx.beginPath(); ctx.arc(px, py, 3, 0, Math.PI * 2); ctx.fill(); // Crosshair ctx.strokeStyle = 'rgba(255, 106, 0, 0.2)'; ctx.lineWidth = 0.5; ctx.shadowBlur = 0; ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, h); ctx.moveTo(0, py); ctx.lineTo(w, py); ctx.stroke(); ctx.restore(); } // ---- Joystick ---- function wireJoystick() { const canvas = $joyMap; let startX, startY, startJX, startJY; let lastDoubleTap = 0; function onStart(e) { e.preventDefault(); const touch = e.touches ? e.touches[0] : e; // Double-tap detection for follow mode const now = Date.now(); if (now - lastDoubleTap < 350) { toggleFollowMode(); lastDoubleTap = 0; return; } lastDoubleTap = now; // Snap to tap position within the circle const rect = canvas.getBoundingClientRect(); const size = rect.width; const dx = touch.clientX - rect.left - size / 2; const dy = touch.clientY - rect.top - size / 2; const maxR = size / 2 - 8; joyX = Math.max(0, Math.min(1, 0.5 + (dx / maxR) * 0.5)); joyY = Math.max(0, Math.min(1, 0.5 - (dy / maxR) * 0.5)); drawJoyMap(); onJoystickMove(); joyDragging = true; startX = touch.clientX; startY = touch.clientY; startJX = joyX; startJY = joyY; } function onMove(e) { if (!joyDragging && !joyFollowMode) return; e.preventDefault(); const touch = e.touches ? e.touches[0] : e; if (joyFollowMode) { joyX = Math.max(0, Math.min(1, touch.clientX / window.innerWidth)); joyY = Math.max(0, Math.min(1, 1 - touch.clientY / window.innerHeight)); } else if (joyDragging) { const dx = touch.clientX - startX; const dy = touch.clientY - startY; const size = canvas.getBoundingClientRect().width; const maxR = size / 2 - 8; const scale = 1 / maxR; joyX = Math.max(0, Math.min(1, startJX + dx * scale * 0.5)); joyY = Math.max(0, Math.min(1, startJY - dy * scale * 0.5)); } drawJoyMap(); onJoystickMove(); } function onEnd() { joyDragging = false; } canvas.addEventListener('pointerdown', onStart); window.addEventListener('pointermove', onMove); window.addEventListener('pointerup', onEnd); window.addEventListener('pointercancel', onEnd); // Follow mode: track pointer on main canvas when active $canvas.addEventListener('pointermove', (e) => { if (!joyFollowMode) return; joyX = Math.max(0, Math.min(1, e.clientX / window.innerWidth)); joyY = Math.max(0, Math.min(1, 1 - e.clientY / window.innerHeight)); drawJoyMap(); onJoystickMove(); }); } function toggleFollowMode() { joyFollowMode = !joyFollowMode; console.log('[Joystick] Follow mode:', joyFollowMode); updateFollowUI(); } function updateFollowUI() { if (joyFollowMode) { $followBadge.classList.remove('hidden'); $joystickContainer.classList.add('follow-active'); $followPill.classList.add('active'); } else { $followBadge.classList.add('hidden'); $joystickContainer.classList.remove('follow-active'); $followPill.classList.remove('active'); } } function onJoystickMove() { if (inputMode !== 'joystick') return; iml.setInput(0, joyX); iml.setInput(1, joyY); iml.process(); const outputs = iml.getOutputs(); routeOutputs(outputs); updateHeatmap(outputs); syncRawParamsFromOutputs(outputs); // Trail joyTrail.push({ x: joyX, y: joyY, t: Date.now() }); if (joyTrail.length > 30) joyTrail.shift(); } // ---- Output routing ---- // Synth param throttling: only send values that changed beyond a dead zone. // Prevents ring buffer flooding (126 params × 30fps = 3780 msg/s > 512 capacity). let _lastSentParams = new Float32Array(N_OUTPUTS); const PARAM_DEAD_ZONE = 0.002; // ~0.2% change threshold let _lastParamSendTime = 0; const PARAM_SEND_INTERVAL = 50; // max ~20fps for synth param updates function routeOutputs(outputs) { if (outputMode === 'synth') { const overridden = new Array(outputs.length); for (let i = 0; i < outputs.length; i++) { overridden[i] = applyGroupOverrides(outputs[i], i); } // Visualizer always gets every frame (it's local, no buffer) synthVisualizer.setParams(overridden); // Engine param updates: throttle + dead-zone filter if (activeEngine && activeEngine.running) { const now = performance.now(); if (now - _lastParamSendTime >= PARAM_SEND_INTERVAL) { _lastParamSendTime = now; for (let i = 0; i < overridden.length && i < SYNTH_PARAM_MAP.length; i++) { const v = overridden[i]; if (Math.abs(v - _lastSentParams[i]) > PARAM_DEAD_ZONE) { activeEngine.setParam(i, v); _lastSentParams[i] = v; } } } } } else if (outputMode === 'midi-cc') { // MIDI CC output — route through overrides then send CC messages if (midiOutput && midiOutput.enabled && midiOutput.activeOutput) { const messages = []; for (let i = 0; i < midiCCMap.length && i < outputs.length; i++) { const ccParam = midiCCMap[i]; const ov = midiCCOverrides[i]; if (!ov || ov.frozen) { if (ov) messages.push({ channel: ccParam.channel, cc: ccParam.cc, value: Math.round(ov.fixedValue * 127) }); continue; } const v = applyGroupOverride(outputs[i], ov.curve, ov.min, ov.max); messages.push({ channel: ccParam.channel, cc: ccParam.cc, value: Math.round(v * 127) }); } midiOutput.sendBatch(messages); } } else if (outputMode === 'audio-canvas') { if (audioCanvas) audioCanvas.setOutputs(outputs); } else { const vis = new Array(N_VISUAL_OUTPUTS); for (let i = 0; i < N_VISUAL_OUTPUTS; i++) { const vo = visualOverrides[i]; if (vo.frozen) { vis[i] = vo.fixedValue; } else { vis[i] = applyGroupOverride(outputs[i], vo.curve, vo.min, vo.max); } } visualizer.setParams(vis); } } // ---- Bottom sheet ---- // ---- Dock & Drawer system ---- function wireDock() { const dock = document.getElementById('dock'); // Toggle drawers on dock icon click dock.addEventListener('click', (e) => { const icon = e.target.closest('.dock-icon'); if (!icon) return; const drawerId = icon.dataset.drawer; // Special case: help opens modal instead of drawer if (drawerId === 'help') { document.getElementById('help-overlay').classList.remove('hidden'); return; } const drawer = document.getElementById(`drawer-${drawerId}`); if (!drawer) return; const isOpen = !drawer.classList.contains('hidden'); if (isOpen) { drawer.classList.add('hidden'); icon.classList.remove('active'); } else { drawer.classList.remove('hidden'); icon.classList.add('active'); } }); // Close buttons inside drawers document.querySelectorAll('.drawer-close').forEach(btn => { btn.addEventListener('click', () => { const drawerId = btn.dataset.drawer; const drawer = document.getElementById(`drawer-${drawerId}`); if (drawer) drawer.classList.add('hidden'); const icon = dock.querySelector(`.dock-icon[data-drawer="${drawerId}"]`); if (icon) icon.classList.remove('active'); }); }); // Follow pill toggle $followPill.addEventListener('click', () => { toggleFollowMode(); }); } // ---- Controls wiring ---- function wireControls() { // Output mode toggle (in mode drawer) document.querySelectorAll('#output-toggle-float .pill-opt').forEach(btn => { btn.addEventListener('click', () => { syncOutputToggles(btn.dataset.mode); setOutputMode(btn.dataset.mode); }); }); // Action buttons (in training drawer) document.getElementById('btn-add-example').addEventListener('click', onAddExample); document.getElementById('btn-train').addEventListener('click', onTrain); document.getElementById('btn-clear').addEventListener('click', onClear); document.getElementById('btn-clear-examples').addEventListener('click', onClearExamples); document.getElementById('btn-randomize').addEventListener('click', onRandomize); // RL buttons $btnThumbsUp.addEventListener('click', onThumbsUp); $btnThumbsDown.addEventListener('click', onThumbsDown); // Undo button document.getElementById('btn-undo').addEventListener('click', onUndo); // Presets document.querySelectorAll('.preset-chip').forEach(chip => { chip.addEventListener('click', () => loadPreset(chip.dataset.preset)); }); // Initial noise ring update updateNoiseRing(); } // ---- Input mode (joystick / hands) ---- function wireInputToggle() { document.querySelectorAll('#input-toggle .pill-opt').forEach(btn => { btn.addEventListener('click', () => { const mode = btn.dataset.input; if (mode === inputMode) return; setInputMode(mode); syncInputToggle(mode); }); }); } function syncInputToggle(mode) { document.querySelectorAll('#input-toggle .pill-opt').forEach(b => b.classList.toggle('active', b.dataset.input === mode) ); } let _inputModeSwitching = false; async function setInputMode(mode) { if (_inputModeSwitching) return; _inputModeSwitching = true; try { await _setInputModeInner(mode); } finally { _inputModeSwitching = false; } } async function _setInputModeInner(mode) { inputMode = mode; const $pip = document.getElementById('hand-pip'); if (mode === 'hands') { iml = imlHand; $joystickContainer.style.display = 'none'; $pip.classList.remove('hidden'); if (!handTracker) { const $status = document.getElementById('hand-status'); $status.textContent = 'Loading model...'; handTracker = new HandTracker({ videoElement: document.getElementById('hand-video'), overlayCanvas: document.getElementById('hand-overlay'), onTrackingInput: onHandInput, onGesture: onHandGesture, onConnectionChange: (active) => { console.log('[HandTracker] active:', active); }, }); try { await handTracker.start(); $status.textContent = 'Tracking'; $status.classList.add('tracking'); } catch (e) { $status.textContent = 'Camera error'; console.error('[HandTracker]', e); setInputMode('joystick'); syncInputToggle('joystick'); return; } } else { await handTracker.start(); document.getElementById('hand-status').textContent = 'Tracking'; document.getElementById('hand-status').classList.add('tracking'); } iml.process(); routeOutputs(iml.getOutputs()); updateHeatmap(iml.getOutputs()); } else { iml = imlJoy; $joystickContainer.style.display = ''; $pip.classList.add('hidden'); if (handTracker) { handTracker.stop(); document.getElementById('hand-status').classList.remove('tracking'); } iml.setInput(0, joyX); iml.setInput(1, joyY); iml.process(); routeOutputs(iml.getOutputs()); updateHeatmap(iml.getOutputs()); } updateStatus(); drawJoyMap(); } function getCurrentInputs() { if (inputMode === 'hands' && handTracker) { return [...handTracker.features]; } return [joyX, joyY]; } function setCurrentInputs() { if (inputMode === 'hands' && handTracker) { const f = handTracker.features; for (let i = 0; i < f.length; i++) iml.setInput(i, f[i]); } else { iml.setInput(0, joyX); iml.setInput(1, joyY); } } function onHandInput(features) { if (inputMode !== 'hands') return; for (let i = 0; i < features.length; i++) { iml.setInput(i, features[i]); } iml.process(); const outputs = iml.getOutputs(); routeOutputs(outputs); updateHeatmap(outputs); syncRawParamsFromOutputs(outputs); updateGestureIndicator(); } function onHandGesture(gesture) { if (gesture === 'thumbsup') { onThumbsUp(); } else if (gesture === 'thumbsdown') { onThumbsDown(); } } function updateGestureIndicator() { if (!handTracker) return; const $indicator = document.getElementById('gesture-indicator'); const $label = document.getElementById('gesture-label'); const $progress = $indicator.querySelector('.gesture-ring-progress'); if (handTracker.gestureCandidate && handTracker.gestureProgress > 0) { $indicator.classList.add('active'); const circumference = 2 * Math.PI * 16; const offset = circumference * (1 - handTracker.gestureProgress); $progress.style.strokeDashoffset = offset; $label.textContent = handTracker.gestureCandidate === 'thumbsup' ? '+' : '\u2212'; } else { $indicator.classList.remove('active'); } const $status = document.getElementById('hand-status'); if (handTracker.active) { if (handTracker.trackingRight && handTracker.trackingLeft) { $status.textContent = 'Both hands'; } else if (handTracker.trackingRight) { $status.textContent = 'Tracking'; } else { $status.textContent = 'No hand'; } $status.classList.toggle('tracking', handTracker.trackingRight); } } function syncOutputToggles(mode) { document.querySelectorAll('#output-toggle-float .pill-opt').forEach(b => b.classList.toggle('active', b.dataset.mode === mode)); } async function setOutputMode(mode, { skipConfirm = false } = {}) { const targetOutputs = outputCountForMode(mode); const needsResize = targetOutputs !== N_OUTPUTS; // Warn about weight reset if resizing (skip during state restore) if (needsResize && !skipConfirm && iml.dataset.features.length > 0) { if (!confirm(`Switching to ${mode} mode requires ${targetOutputs} outputs (currently ${N_OUTPUTS}). This will reset training examples. Network weights will be partially preserved. Continue?`)) { syncOutputToggles(outputMode); // revert pill UI return; } } outputMode = mode; hideGroupDrawer(); hideParamPopup(); // Resize MLP if needed if (needsResize) { await resizeMLP(targetOutputs); } buildHeatmap(); updateHeatmap(iml.getOutputs()); const heatmapStrip = document.getElementById('heatmap-strip'); const synthQuickControls = document.getElementById('synth-quick-controls'); const midiCCQuickControls = document.getElementById('midi-cc-quick-controls'); const audioCanvasWrap = document.getElementById('audio-canvas-wrap'); // Lazily create AudioCanvas on first switch if (mode === 'audio-canvas' && !audioCanvas) { audioCanvas = new AudioCanvas(audioCanvasWrap); } // Hide/show audio-canvas wrap if (audioCanvasWrap) audioCanvasWrap.style.display = mode === 'audio-canvas' ? 'block' : 'none'; if (mode === 'synth') { $canvas.classList.add('hidden-canvas'); $synthVisCanvas.classList.add('active'); heatmapStrip.classList.add('hidden'); synthQuickControls.classList.remove('hidden'); if (midiCCQuickControls) midiCCQuickControls.classList.add('hidden'); synthVisualizer.enableInteraction(true); const qp = document.getElementById('quick-play'); if (qp) qp.classList.toggle('audio-needs-init', !(c15 && c15.running)); } else if (mode === 'midi-cc') { $canvas.classList.add('hidden-canvas'); $synthVisCanvas.classList.remove('active'); heatmapStrip.classList.remove('hidden'); synthQuickControls.classList.add('hidden'); if (midiCCQuickControls) midiCCQuickControls.classList.remove('hidden'); synthVisualizer.enableInteraction(false); } else if (mode === 'audio-canvas') { $canvas.classList.add('hidden-canvas'); $synthVisCanvas.classList.remove('active'); heatmapStrip.classList.remove('hidden'); synthQuickControls.classList.add('hidden'); if (midiCCQuickControls) midiCCQuickControls.classList.add('hidden'); synthVisualizer.enableInteraction(false); } else { $canvas.classList.remove('hidden-canvas'); $synthVisCanvas.classList.remove('active'); heatmapStrip.classList.remove('hidden'); synthQuickControls.classList.add('hidden'); if (midiCCQuickControls) midiCCQuickControls.classList.add('hidden'); synthVisualizer.enableInteraction(false); } routeOutputs(iml.getOutputs()); buildEngineParams(); syncRawParamsFromOutputs(iml.getOutputs()); } function updateNoiseRing() { if (noiseLevel > 0.15) { $noiseRing.className = 'noise-ring active high'; } else if (noiseLevel > 0.01) { $noiseRing.className = 'noise-ring active'; } else { $noiseRing.className = 'noise-ring'; } } // ---- Examples mode ---- function onAddExample() { const inputs = getCurrentInputs(); const outputs = [...rawParamValues]; iml.addExample(inputs, outputs); updateStatus(); drawJoyMap(); flash('btn-add-example'); } function onTrain() { if (iml.isTraining) return; flash('btn-train'); trainModelAsync(); } function onRandomize() { iml.randomiseWeights(spreadLevel); setCurrentInputs(); iml.process(); const outputs = iml.getOutputs(); routeOutputs(outputs); updateHeatmap(outputs); syncRawParamsFromOutputs(outputs); noiseLevel = 0.05; updateStatus(); } function onClearExamples() { iml.clearDataset(); updateStatus(); drawJoyMap(); } function onClear() { iml.clearDataset(); iml.lossHistory = []; iml.bestLoss = null; iml.totalTrainingIterations = 0; noiseLevel = 0.05; clearState(); updateStatus(); drawLossPlot(); drawJoyMap(); } // ---- Undo ---- function pushUndoSnapshot() { const snapshot = { weights: iml._getFlatWeights ? iml._getFlatWeights() : (iml.mlp ? iml.mlp.getWeights() : null), noiseLevel, exampleCount: iml.exampleCount, }; if (snapshot.weights) { undoStack.push(snapshot); if (undoStack.length > MAX_UNDO) undoStack.shift(); updateUndoButton(); } } function onUndo() { if (undoStack.length === 0) return; const snapshot = undoStack.pop(); if (iml._setFlatWeights && Array.isArray(snapshot.weights)) { iml._setFlatWeights(snapshot.weights); } else if (iml.mlp && snapshot.weights) { iml.mlp.setWeights(snapshot.weights); } noiseLevel = snapshot.noiseLevel; iml.process(); const outputs = iml.getOutputs(); routeOutputs(outputs); updateHeatmap(outputs); syncRawParamsFromOutputs(outputs); updateStatus(); updateNoiseRing(); updateUndoButton(); flash('btn-undo'); } function updateUndoButton() { const btn = document.getElementById('btn-undo'); if (btn) btn.classList.toggle('has-undo', undoStack.length > 0); } // ---- RL mode ---- function onThumbsUp() { if (iml.isTraining) return; pushUndoSnapshot(); const inputs = getCurrentInputs(); const outputs = [...rawParamValues]; iml.addExample(inputs, outputs); noiseLevel *= rlExplorationDecay; noiseLevel = Math.max(noiseLevel, 0.005); flash('btn-thumbsup'); updateNoiseRing(); trainModelAsync(); } function onThumbsDown() { pushUndoSnapshot(); 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); updateHeatmap(outputs); syncRawParamsFromOutputs(outputs); updateStatus(); updateNoiseRing(); flash('btn-thumbsdown'); } // ---- Training ---- // Sync — used for preset loading and state restore function trainModel() { return iml.train(); } // Async — used for interactive training (thumbs-up, train button) function trainModelAsync(onDone) { iml.trainAsync(({ loss, outputs }) => { routeOutputs(outputs); updateHeatmap(outputs); syncRawParamsFromOutputs(outputs); updateStatus(); drawLossPlot(); drawJoyMap(); if (onDone) onDone(); }); } // ---- Presets ---- function loadPreset(name) { const preset = PRESETS[name]; if (!preset) return; // Visual presets have 2-element inputs — always apply to joystick IML imlJoy.clearDataset(); for (const ex of preset) { imlJoy.addExample(ex.input, padPresetOutputs(ex.output)); } // Temporarily point iml to imlJoy for training, then restore const prevIml = iml; iml = imlJoy; const loss = trainModel(); iml = prevIml; // Show results from joystick IML imlJoy.setInput(0, joyX); imlJoy.setInput(1, joyY); imlJoy.process(); const outputs = imlJoy.getOutputs(); routeOutputs(outputs); updateHeatmap(outputs); syncRawParamsFromOutputs(outputs); updateStatus(); drawLossPlot(); drawJoyMap(); } // ---- Status ---- function updateStatus() { const count = iml.exampleCount; const loss = iml.lastLoss; let text = `${count} example${count !== 1 ? 's' : ''}`; if (loss !== null) { text += ` \u00b7 loss ${loss.toFixed(5)}`; } else { text += ' \u00b7 untrained'; } text += ` \u00b7 noise ${noiseLevel.toFixed(3)}`; $statusText.textContent = text; syncEngineParams(); } // ---- Loss plot ---- function drawLossPlot() { const ctx = $lossCtx; const w = $lossCanvas.width; const h = $lossCanvas.height; const history = iml.lossHistory; ctx.fillStyle = 'rgba(0, 0, 0, 0.3)'; ctx.fillRect(0, 0, w, h); if (history.length < 2) return; const maxLoss = Math.max(...history.slice(-200)) || 1; const points = history.slice(-200); ctx.strokeStyle = 'rgba(255, 106, 0, 0.6)'; ctx.lineWidth = 1.5; ctx.beginPath(); for (let i = 0; i < points.length; i++) { const x = (i / (points.length - 1)) * w; const y = h - (points[i] / maxLoss) * h * 0.9; if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.stroke(); } // ---- Raw param sliders ---- // ---- Engine Parameters (NISPS tuning) ---- function buildEngineParams() { const container = $engineParams; if (!container) return; container.innerHTML = ''; const params = [ { label: 'Spread', key: 'spread', get: () => spreadLevel, set: (v) => { spreadLevel = v; }, min: 0, max: 1, step: 0.01, desc: 'Weight init scale & RL noise regime (0=polarised, 1=centered)', }, { label: 'Noise', key: 'noise', get: () => noiseLevel, set: (v) => { noiseLevel = v; updateNoiseRing(); }, min: 0, max: 0.5, step: 0.001, desc: 'Current RL exploration noise level', }, { label: 'RL Decay', key: 'rlDecay', get: () => rlExplorationDecay, set: (v) => { rlExplorationDecay = v; }, min: 0.8, max: 1.0, step: 0.005, desc: 'Noise decay per thumbs-up (lower = faster convergence)', }, { label: 'Learn Rate', key: 'lr', get: () => iml.learningRate, set: (v) => { iml.learningRate = v; }, min: 0.01, max: 5.0, step: 0.01, desc: 'MLP training learning rate', }, { label: 'Max Iters', key: 'maxIter', get: () => iml.maxIterations, set: (v) => { iml.maxIterations = Math.round(v); }, min: 50, max: 5000, step: 50, desc: 'Max training iterations per train() call', }, { label: 'Convergence', key: 'conv', get: () => iml.convergenceThreshold, set: (v) => { iml.convergenceThreshold = v; }, min: 0.000001, max: 0.01, step: 0.000001, desc: 'Stop training when loss drops below this', }, ]; for (const p of params) { const row = document.createElement('div'); row.className = 'engine-param'; const label = document.createElement('span'); label.className = 'engine-param-label'; label.textContent = p.label; label.title = p.desc; const slider = document.createElement('input'); slider.type = 'range'; slider.min = String(p.min); slider.max = String(p.max); slider.step = String(p.step); slider.value = p.get(); const val = document.createElement('span'); val.className = 'engine-param-val'; val.textContent = formatEngineVal(p.get(), p.step); slider.addEventListener('input', () => { const v = parseFloat(slider.value); p.set(v); val.textContent = formatEngineVal(v, p.step); }); row.appendChild(label); row.appendChild(slider); row.appendChild(val); container.appendChild(row); // Store ref for live updates row._paramDef = p; row._slider = slider; row._val = val; } } function formatEngineVal(v, step) { if (step < 0.0001) return v.toExponential(1); if (step < 0.01) return v.toFixed(3); if (step < 1) return v.toFixed(2); return String(Math.round(v)); } function syncEngineParams() { if (!$engineParams) return; const rows = $engineParams.querySelectorAll('.engine-param'); rows.forEach(row => { const p = row._paramDef; if (!p) return; const v = p.get(); row._slider.value = v; row._val.textContent = formatEngineVal(v, parseFloat(row._slider.step)); }); } function syncRawParamsFromOutputs(outputs) { rawParamValues = [...outputs]; } // ---- Synth controls ---- function wireSynthControls() { 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'); if (!startBtn || !volumeSlider || !arpToggle) return; startBtn.addEventListener('click', async () => { const quickPlay = document.getElementById('quick-play'); if (c15.running) { arpeggiator.stop(); arpToggle.textContent = 'Play'; await c15.stop(); startBtn.textContent = 'Start Audio'; if (quickPlay) quickPlay.classList.add('audio-needs-init'); } else { await c15.start(); startBtn.textContent = 'Stop Audio'; if (quickPlay) quickPlay.classList.remove('audio-needs-init'); routeOutputs(iml.getOutputs()); } }); volumeSlider.addEventListener('input', (e) => { c15.setMasterVolume(parseFloat(e.target.value)); const quickVol = document.getElementById('quick-vol'); if (quickVol) quickVol.value = e.target.value; }); arpToggle.addEventListener('click', () => { if (!c15.running) return; if (arpeggiator.playing) { arpeggiator.stop(); arpToggle.textContent = 'Play'; } else { arpeggiator.start(); arpToggle.textContent = '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; const quickBpm = document.getElementById('quick-bpm'); const quickBpmVal = document.getElementById('quick-bpm-val'); if (quickBpm) quickBpm.value = val; if (quickBpmVal) quickBpmVal.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; }); } // ---- 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 = '' + inputs.map(i => ``).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); }); } // ---- MIDI CC Preset Application ---- async function applyMidiCCPreset(preset) { // Replace the CC map with preset params midiCCMap.length = 0; midiCCMap.push(...preset.params.map(p => ({ ...p }))); // Rebuild overrides midiCCOverrides.length = 0; midiCCOverrides.push(...preset.params.map(p => ({ min: p.min ?? 0, max: p.max ?? 1, curve: p.curve ?? 0.5, frozen: p.muted ?? false, fixedValue: p.fixedValue ?? 0.5, }))); _generateCCColors(midiCCMap.length); saveCCMap(midiCCMap); // Resize MLP if in midi-cc mode if (outputMode === 'midi-cc') { await resizeMLP(midiCCMap.length); buildHeatmap(); updateHeatmap(iml.getOutputs()); } // Update UI const countEl = document.getElementById('midi-cc-count'); if (countEl) countEl.textContent = midiCCMap.length; buildMidiCCParamList(); console.log(`[NISPS] Applied MIDI CC preset: ${preset.name} (${preset.params.length} params)`); } // ---- MIDI CC Controls ---- async function initMIDICCControls() { const enableBtn = document.getElementById('midi-cc-enable-btn'); const outputSelect = document.getElementById('midi-cc-output-select'); const statusEl = document.getElementById('midi-cc-status'); const countEl = document.getElementById('midi-cc-count'); const addBtn = document.getElementById('midi-cc-add'); const removeBtn = document.getElementById('midi-cc-remove'); const importBtn = document.getElementById('midi-cc-import'); const exportBtn = document.getElementById('midi-cc-export'); const presetSelect = document.getElementById('midi-cc-preset-select'); const fileImportBtn = document.getElementById('midi-cc-file-import'); if (!enableBtn || !outputSelect) return; const available = await midiOutput.init(); function populateOutputs() { const outputs = midiOutput.getOutputs(); outputSelect.innerHTML = '' + outputs.map(o => ``).join(''); } if (available) { populateOutputs(); midiOutput.onOutputsChange = () => populateOutputs(); } midiOutput.onStatusChange = (msg) => { if (statusEl) statusEl.textContent = msg; }; enableBtn.addEventListener('click', () => { midiOutput.toggle(); enableBtn.classList.toggle('playing', midiOutput.enabled); }); outputSelect.addEventListener('change', (e) => { midiOutput.selectOutput(e.target.value || null); if (e.target.value) midiOutput.enable(); enableBtn.classList.toggle('playing', midiOutput.enabled); }); // Update count display function updateCountDisplay() { if (countEl) countEl.textContent = midiCCMap.length; } updateCountDisplay(); // Add CC param if (addBtn) { addBtn.addEventListener('click', async () => { midiCCMap.push(createCCParam(74, 1)); midiCCOverrides.push({ min: 0, max: 1, curve: 0.5, frozen: false, fixedValue: 0.5 }); _generateCCColors(midiCCMap.length); saveCCMap(midiCCMap); updateCountDisplay(); // Resize MLP if we're in midi-cc mode if (outputMode === 'midi-cc') { await resizeMLP(midiCCMap.length); buildHeatmap(); updateHeatmap(iml.getOutputs()); } }); } // Remove last CC param if (removeBtn) { removeBtn.addEventListener('click', async () => { if (midiCCMap.length <= 1) return; midiCCMap.pop(); midiCCOverrides.pop(); _generateCCColors(midiCCMap.length); saveCCMap(midiCCMap); updateCountDisplay(); if (outputMode === 'midi-cc') { await resizeMLP(midiCCMap.length); buildHeatmap(); updateHeatmap(iml.getOutputs()); } }); } // Export if (exportBtn) { exportBtn.addEventListener('click', () => { const json = exportCCMap(midiCCMap); navigator.clipboard.writeText(json).then( () => { if (statusEl) statusEl.textContent = 'Copied to clipboard'; }, () => { // Fallback: prompt prompt('Copy this JSON:', json); } ); }); } // Import if (importBtn) { importBtn.addEventListener('click', async () => { const json = prompt('Paste MIDI CC map JSON:'); if (!json) return; const parsed = importCCMap(json); if (!parsed) { alert('Invalid MIDI CC map JSON'); return; } midiCCMap.length = 0; midiCCMap.push(...parsed); midiCCOverrides.length = 0; midiCCOverrides.push(...parsed.map(p => ({ min: p.min ?? 0, max: p.max ?? 1, curve: p.curve ?? 0.5, frozen: p.muted ?? false, fixedValue: p.fixedValue ?? 0.5, }))); _generateCCColors(midiCCMap.length); saveCCMap(midiCCMap); updateCountDisplay(); if (outputMode === 'midi-cc') { await resizeMLP(midiCCMap.length); buildHeatmap(); updateHeatmap(iml.getOutputs()); } }); } // Quick preset selector (in quick controls bar) const quickPresetSelect = document.getElementById('midi-cc-quick-preset'); if (quickPresetSelect) { quickPresetSelect.innerHTML = ''; for (const p of listMidiPresets()) { const opt = document.createElement('option'); opt.value = p.id; opt.textContent = p.name; quickPresetSelect.appendChild(opt); } quickPresetSelect.addEventListener('change', async () => { const id = quickPresetSelect.value; if (!id) return; try { const preset = await loadMidiPreset(id); await applyMidiCCPreset(preset); updateCountDisplay(); if (statusEl) statusEl.textContent = `Loaded: ${preset.name}`; // Sync drawer preset selector if (presetSelect) presetSelect.value = id; } catch (err) { console.error('[MIDI CC] Failed to load preset:', err); } }); } // Preset selector (in drawer) if (presetSelect) { // Populate with built-in presets presetSelect.innerHTML = ''; for (const p of listMidiPresets()) { const opt = document.createElement('option'); opt.value = p.id; opt.textContent = p.name; presetSelect.appendChild(opt); } presetSelect.addEventListener('change', async () => { const id = presetSelect.value; if (!id) return; // "Manual" selected — keep current config try { const preset = await loadMidiPreset(id); await applyMidiCCPreset(preset); updateCountDisplay(); if (statusEl) statusEl.textContent = `Loaded: ${preset.name}`; // Sync quick preset selector if (quickPresetSelect) quickPresetSelect.value = id; } catch (err) { console.error('[MIDI CC] Failed to load preset:', err); if (statusEl) statusEl.textContent = `Error: ${err.message}`; } }); } // File import (JSON file from disk) if (fileImportBtn) { // Create hidden file input const fileInput = document.createElement('input'); fileInput.type = 'file'; fileInput.accept = '.json'; fileInput.style.display = 'none'; document.body.appendChild(fileInput); fileImportBtn.addEventListener('click', () => fileInput.click()); fileInput.addEventListener('change', async () => { const file = fileInput.files?.[0]; if (!file) return; const preset = await loadMidiPresetFromFile(file); if (!preset) { alert('Invalid MIDI CC preset file'); return; } await applyMidiCCPreset(preset); updateCountDisplay(); if (statusEl) statusEl.textContent = `Loaded: ${preset.name || file.name}`; // Reset file input so the same file can be re-selected fileInput.value = ''; }); } // Build the per-param editor in the drawer buildMidiCCParamList(); } function buildMidiCCParamList() { const container = document.getElementById('midi-cc-param-list'); if (!container) return; container.innerHTML = ''; for (let i = 0; i < midiCCMap.length; i++) { const p = midiCCMap[i]; const row = document.createElement('div'); row.className = 'synth-row midi-cc-param-row'; row.style.borderLeft = `3px solid ${MIDI_CC_PARAM_COLORS[i] || '#888'}`; const nameSpan = document.createElement('span'); nameSpan.className = 'midi-cc-param-name'; nameSpan.textContent = `${p.name}`; nameSpan.style.flex = '1'; nameSpan.style.fontSize = '0.75rem'; const ccSpan = document.createElement('span'); ccSpan.style.opacity = '0.6'; ccSpan.style.fontSize = '0.65rem'; ccSpan.textContent = `CC${p.cc} Ch${p.channel}`; row.appendChild(nameSpan); row.appendChild(ccSpan); container.appendChild(row); } } // ---- Gamepad ---- function wireGamepad() { gamepad = new GamepadInput({ invertY: true, onMove: (x, y) => { joyX = x; joyY = y; drawJoyMap(); onJoystickMove(); }, onButton: (btn) => { if (btn === 'lb') onThumbsDown(); if (btn === 'rb') onThumbsUp(); if (btn === 'a') onTrain(); if (btn === 'x') onRandomize(); if (btn === 'b') onClearExamples(); }, onConnectionChange: (connected) => { const el = document.getElementById('gamepad-status'); if (el) el.textContent = connected ? 'Gamepad connected' : 'Press any button to activate gamepad'; }, }); // Show hint immediately if no gamepad detected yet if (!gamepad.connected) { const el = document.getElementById('gamepad-status'); if (el) el.textContent = 'Press any gamepad button to connect'; } } // ---- Keyboard ---- function wireKeyboard() { window.addEventListener('keydown', (e) => { if (e.repeat) return; // Don't intercept keys when an input/select has focus const tag = document.activeElement?.tagName; if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA') return; if (e.key === '1' || e.code === 'Numpad1') { e.preventDefault(); onThumbsDown(); } else if (e.key === '2' || e.code === 'Numpad2') { e.preventDefault(); onThumbsUp(); } else if (e.key === 'z' || e.key === 'Z') { e.preventDefault(); onUndo(); } }); } // ---- Quick play controls ---- // ---- Synth Preset Selector ---- function wireSynthPresets() { const $select = document.getElementById('synth-preset-select'); if (!$select) return; $select.addEventListener('change', () => { const val = $select.value; if (val === '') { // "Manual" selected — clear preset tracking but don't change overrides activeSynthPresetId = null; saveState(); } else { applyPreset(val); } }); } function wireQuickPlayControls() { const quickPlay = document.getElementById('quick-play'); const quickPlayIcon = document.getElementById('quick-play-icon'); const quickVol = document.getElementById('quick-vol'); const quickBpm = document.getElementById('quick-bpm'); const quickBpmVal = document.getElementById('quick-bpm-val'); if (!quickPlay || !quickPlayIcon || !quickVol || !quickBpm) return; const playIconSVG = ''; const pauseIconSVG = ''; function updatePlayIcon() { const isPlaying = c15 && c15.running; quickPlayIcon.innerHTML = isPlaying ? pauseIconSVG : playIconSVG; quickPlay.classList.toggle('playing', isPlaying); quickPlay.classList.toggle('audio-needs-init', !isPlaying); } quickPlay.addEventListener('click', async () => { if (c15.running) { arpeggiator.stop(); await c15.stop(); // Also update the bottom sheet controls const startBtn = document.getElementById('synth-start'); const arpToggle = document.getElementById('arp-toggle'); if (startBtn) startBtn.textContent = 'Start Audio'; if (arpToggle) arpToggle.textContent = 'Play'; } else { await c15.start(); arpeggiator.start(); routeOutputs(iml.getOutputs()); // Also update the bottom sheet controls const startBtn = document.getElementById('synth-start'); const arpToggle = document.getElementById('arp-toggle'); if (startBtn) startBtn.textContent = 'Stop Audio'; if (arpToggle) arpToggle.textContent = 'Stop'; } updatePlayIcon(); }); quickVol.addEventListener('input', (e) => { c15.setMasterVolume(parseFloat(e.target.value)); // Sync with bottom sheet volume slider const sheetVol = document.getElementById('synth-volume'); if (sheetVol) sheetVol.value = e.target.value; }); quickBpm.addEventListener('input', (e) => { const val = parseInt(e.target.value); arpeggiator.bpm = val; quickBpmVal.textContent = val; // Sync with bottom sheet tempo slider const sheetTempo = document.getElementById('arp-tempo'); const sheetTempoVal = document.getElementById('tempo-val'); if (sheetTempo) sheetTempo.value = val; if (sheetTempoVal) sheetTempoVal.textContent = val; }); } // ---- Group Override Drawer ---- let $groupDrawer = null; let activeDrawerSection = -1; let drawerHideTimer = null; // ---- Heatmap param popup ---- let $paramPopup = null; let activePopupParam = -1; let popupHideTimer = null; function wireGroupDrawer() { // Create the drawer DOM element once $groupDrawer = document.createElement('div'); $groupDrawer.className = 'group-drawer'; $groupDrawer.innerHTML = '
'; document.body.appendChild($groupDrawer); // Keep drawer open while hovering over it $groupDrawer.addEventListener('pointerenter', () => { clearTimeout(drawerHideTimer); }); $groupDrawer.addEventListener('pointerleave', () => { drawerHideTimer = setTimeout(() => hideGroupDrawer(), 300); }); // Detect hover over section labels on the synth vis canvas $synthVisCanvas.addEventListener('pointermove', (e) => { if (outputMode !== 'synth') return; const region = synthVisualizer.hitTestSection(e.clientX, e.clientY); if (region) { clearTimeout(drawerHideTimer); if (activeDrawerSection !== region.index) { showGroupDrawer(region); } } else { // Leaving section label area, delay hide if (activeDrawerSection >= 0) { drawerHideTimer = setTimeout(() => hideGroupDrawer(), 300); } } }); // Also handle click for mobile $synthVisCanvas.addEventListener('pointerdown', (e) => { if (outputMode !== 'synth') return; const region = synthVisualizer.hitTestSection(e.clientX, e.clientY); if (region) { e.preventDefault(); e.stopPropagation(); clearTimeout(drawerHideTimer); if (activeDrawerSection === region.index) { hideGroupDrawer(); } else { showGroupDrawer(region); } } }, true); // capture phase so it fires before bar interaction } function showGroupDrawer(region) { activeDrawerSection = region.index; const sec = SYNTH_SECTIONS[region.index]; const ov = groupOverrides[region.index]; // Find global param start index for this section let paramStart = 0; for (let i = 0; i < region.index; i++) paramStart += SYNTH_SECTIONS[i].count; // Header with section name const header = $groupDrawer.querySelector('.group-drawer-header'); header.textContent = sec.name; header.style.color = sec.color; // Body: group curve + per-param rows const body = $groupDrawer.querySelector('.group-drawer-body'); body.innerHTML = ''; // -- Group master curve (draggable canvas) -- const curveRow = document.createElement('div'); curveRow.className = 'gd-curve-row'; const curveLabel = document.createElement('span'); curveLabel.className = 'gd-label'; curveLabel.textContent = 'Group'; const curveCanvas = document.createElement('canvas'); curveCanvas.className = 'gd-curve-canvas'; curveCanvas.width = 48; curveCanvas.height = 48; const curveVal = document.createElement('span'); curveVal.className = 'gd-val'; curveVal.textContent = ov.curve.toFixed(2); function drawGroupCurvePreview() { _drawCurveOnCanvas(curveCanvas, ov.curve, sec.color); } // Vertical drag on group curve — applies relative delta to all param curves { let dragging = false, startY = 0, startGroupCurve = 0, startParamCurves = []; curveCanvas.style.cursor = 'ns-resize'; curveCanvas.style.touchAction = 'none'; curveCanvas.addEventListener('pointerdown', (e) => { e.preventDefault(); e.stopPropagation(); dragging = true; startY = e.clientY; startGroupCurve = ov.curve; startParamCurves = ov.params.map(p => p.curve); curveCanvas.setPointerCapture(e.pointerId); }); curveCanvas.addEventListener('pointermove', (e) => { if (!dragging) return; e.preventDefault(); const dy = e.clientY - startY; const delta = dy / 80; const newGroup = Math.max(0, Math.min(1, startGroupCurve + delta)); ov.curve = newGroup; curveVal.textContent = newGroup.toFixed(2); // Apply same delta to each param, preserving relative offsets for (let i = 0; i < ov.params.length; i++) { ov.params[i].curve = Math.max(0, Math.min(1, startParamCurves[i] + delta)); } drawGroupCurvePreview(); body.querySelectorAll('.gd-param-curve-canvas').forEach(c => { if (c._redraw) c._redraw(); }); routeOutputs(iml.getOutputs()); }); curveCanvas.addEventListener('pointerup', () => { dragging = false; }); curveCanvas.addEventListener('pointercancel', () => { dragging = false; }); } curveRow.appendChild(curveLabel); curveRow.appendChild(curveCanvas); curveRow.appendChild(curveVal); body.appendChild(curveRow); drawGroupCurvePreview(); // -- Per-param rows -- for (let li = 0; li < sec.count; li++) { const pi = paramStart + li; if (pi >= SYNTH_PARAM_MAP.length) break; const param = SYNTH_PARAM_MAP[pi]; const pov = ov.params[li]; const row = document.createElement('div'); row.className = 'gd-param-row'; if (pov.muted) row.classList.add('gd-muted'); // Name const nameSpan = document.createElement('span'); nameSpan.className = 'gd-param-name'; nameSpan.textContent = param.label; // Per-param curve canvas (vertically draggable, no slider) const pCurveCanvas = document.createElement('canvas'); pCurveCanvas.className = 'gd-param-curve-canvas'; pCurveCanvas.width = 28; pCurveCanvas.height = 28; pCurveCanvas._redraw = () => _drawCurveOnCanvas(pCurveCanvas, pov.curve, sec.color); _wireCurveDrag(pCurveCanvas, () => pov.curve, (v) => { pov.curve = v; pCurveCanvas._redraw(); routeOutputs(iml.getOutputs()); }); pCurveCanvas._redraw(); // Dual-range slider (min/max as two overlapping range inputs) const rangeWrap = document.createElement('div'); rangeWrap.className = 'gd-range-wrap'; const rangeFill = document.createElement('div'); rangeFill.className = 'gd-range-fill'; const minSlider = document.createElement('input'); minSlider.type = 'range'; minSlider.min = '0'; minSlider.max = '1'; minSlider.step = '0.01'; minSlider.value = pov.min; minSlider.className = 'gd-range-input gd-range-min'; const maxSlider = document.createElement('input'); maxSlider.type = 'range'; maxSlider.min = '0'; maxSlider.max = '1'; maxSlider.step = '0.01'; maxSlider.value = pov.max; maxSlider.className = 'gd-range-input gd-range-max'; // Value slider (shown when muted) const valSlider = document.createElement('input'); valSlider.type = 'range'; valSlider.min = '0'; valSlider.max = '1'; valSlider.step = '0.01'; valSlider.value = pov.fixedValue; valSlider.className = 'gd-val-slider'; function updateRangeFill() { rangeFill.style.left = `${pov.min * 100}%`; rangeFill.style.width = `${(pov.max - pov.min) * 100}%`; } updateRangeFill(); minSlider.addEventListener('input', () => { pov.min = parseFloat(minSlider.value); if (pov.min > pov.max) { pov.max = pov.min; maxSlider.value = pov.max; } updateRangeFill(); routeOutputs(iml.getOutputs()); }); maxSlider.addEventListener('input', () => { pov.max = parseFloat(maxSlider.value); if (pov.max < pov.min) { pov.min = pov.max; minSlider.value = pov.min; } updateRangeFill(); routeOutputs(iml.getOutputs()); }); valSlider.addEventListener('input', () => { pov.fixedValue = parseFloat(valSlider.value); routeOutputs(iml.getOutputs()); }); rangeWrap.appendChild(rangeFill); rangeWrap.appendChild(minSlider); rangeWrap.appendChild(maxSlider); // Mute toggle const muteBtn = document.createElement('button'); muteBtn.className = 'gd-mute-btn' + (pov.muted ? ' muted' : ''); muteBtn.textContent = pov.muted ? 'M' : 'M'; muteBtn.title = pov.muted ? 'Unmute (re-enable NISPS control)' : 'Mute (remove from NISPS)'; muteBtn.addEventListener('click', () => { pov.muted = !pov.muted; muteBtn.classList.toggle('muted', pov.muted); muteBtn.title = pov.muted ? 'Unmute (re-enable NISPS control)' : 'Mute (remove from NISPS)'; row.classList.toggle('gd-muted', pov.muted); routeOutputs(iml.getOutputs()); }); row.appendChild(nameSpan); row.appendChild(pCurveCanvas); row.appendChild(rangeWrap); row.appendChild(valSlider); row.appendChild(muteBtn); body.appendChild(row); } // Position the drawer below the section label const canvasRect = $synthVisCanvas.getBoundingClientRect(); const centerX = (region.left + region.right) / 2 + canvasRect.left; const topY = region.bottom + canvasRect.top + 4; const drawerWidth = 320; let left = centerX - drawerWidth / 2; left = Math.max(4, Math.min(left, window.innerWidth - drawerWidth - 4)); $groupDrawer.style.left = `${left}px`; $groupDrawer.style.top = `${topY}px`; $groupDrawer.classList.add('visible'); } /** Draw a curve preview on a canvas element */ function _drawCurveOnCanvas(canvas, curveFactor, color) { const ctx = canvas.getContext('2d'); const w = canvas.width; const h = canvas.height; ctx.clearRect(0, 0, w, h); ctx.fillStyle = 'rgba(255,255,255,0.03)'; ctx.fillRect(0, 0, w, h); // Linear reference ctx.strokeStyle = 'rgba(255,255,255,0.1)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(0, h); ctx.lineTo(w, 0); ctx.stroke(); // Curve ctx.strokeStyle = color; ctx.lineWidth = Math.min(2, w / 16); ctx.beginPath(); const steps = 30; for (let i = 0; i <= steps; i++) { const t = i / steps; const v = applyCurve(t, curveFactor); const px = t * w; const py = (1 - v) * h; if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py); } ctx.stroke(); } /** Wire vertical drag on a canvas to control a curve factor */ function _wireCurveDrag(canvas, getter, setter) { let dragging = false; let startY = 0; let startVal = 0; canvas.style.cursor = 'ns-resize'; canvas.style.touchAction = 'none'; canvas.addEventListener('pointerdown', (e) => { e.preventDefault(); e.stopPropagation(); dragging = true; startY = e.clientY; startVal = getter(); canvas.setPointerCapture(e.pointerId); }); canvas.addEventListener('pointermove', (e) => { if (!dragging) return; e.preventDefault(); // Drag down = more exponential (higher curve), drag up = more logarithmic (lower curve) const dy = e.clientY - startY; const newVal = Math.max(0, Math.min(1, startVal + dy / 80)); setter(newVal); }); canvas.addEventListener('pointerup', () => { dragging = false; }); canvas.addEventListener('pointercancel', () => { dragging = false; }); } function hideGroupDrawer() { activeDrawerSection = -1; if ($groupDrawer) $groupDrawer.classList.remove('visible'); } // ---- Resize ---- function onResize() { hideGroupDrawer(); hideParamPopup(); visualizer.resize(); visualizer.initParticles(); synthVisualizer.resize(); // Resize joy-map canvas to match container const size = $joystickContainer.offsetWidth; if (size > 0) { $joyMap.width = size; $joyMap.height = size; drawJoyMap(); } } // ---- Help modal ---- function wireHelp() { const overlay = document.getElementById('help-overlay'); const btnClose = document.getElementById('help-close'); const btnGotIt = document.getElementById('help-got-it'); function hide() { overlay.classList.add('hidden'); localStorage.setItem('nisps-help-seen', '1'); } // Help is opened via dock icon (handled in wireDock) btnClose.addEventListener('click', hide); btnGotIt.addEventListener('click', hide); overlay.addEventListener('click', (e) => { if (e.target === overlay) hide(); }); // Show on first visit if (!localStorage.getItem('nisps-help-seen')) overlay.classList.remove('hidden'); } // ---- Animation ---- function animate() { if (gamepad) gamepad.poll(); if (outputMode === 'synth') { synthVisualizer.draw(); } else if (outputMode === 'visual') { visualizer.draw(); } requestAnimationFrame(animate); } // ---- Utility ---- function flash(id) { const el = document.getElementById(id); if (!el) return; el.classList.add('flash'); setTimeout(() => el.classList.remove('flash'), 250); } // ---- Persistence ---- function saveState() { try { const state = { features: imlJoy.dataset.features, labels: imlJoy.dataset.labels, handFeatures: imlHand.dataset.features, handLabels: imlHand.dataset.labels, noiseLevel, outputMode, inputMode, joyX, joyY, groupOverrides, visualOverrides, midiCCOverrides, audioCanvasState: audioCanvas ? audioCanvas.getState() : null, synthPresetId: activeSynthPresetId, }; localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch (e) { console.warn('[NISPS] Failed to save state:', e); } } async function loadState() { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return; const state = JSON.parse(raw); // Restore joystick IML training data (pad old 20-element labels to N_OUTPUTS) if (state.features && state.labels && state.features.length > 0) { for (let i = 0; i < state.features.length; i++) { imlJoy.addExample(state.features[i], padPresetOutputs(state.labels[i])); } const prevIml = iml; iml = imlJoy; trainModel(); iml = prevIml; } // Restore hand IML training data if (state.handFeatures && state.handLabels && state.handFeatures.length > 0) { for (let i = 0; i < state.handFeatures.length; i++) { imlHand.addExample(state.handFeatures[i], padPresetOutputs(state.handLabels[i])); } const prevIml = iml; iml = imlHand; trainModel(); iml = prevIml; } 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 group overrides if (state.groupOverrides && Array.isArray(state.groupOverrides)) { for (let si = 0; si < state.groupOverrides.length && si < groupOverrides.length; si++) { const saved = state.groupOverrides[si]; if (typeof saved.curve === 'number') groupOverrides[si].curve = saved.curve; if (Array.isArray(saved.params)) { for (let li = 0; li < saved.params.length && li < groupOverrides[si].params.length; li++) { const sp = saved.params[li], gp = groupOverrides[si].params[li]; if (typeof sp.min === 'number') gp.min = sp.min; if (typeof sp.max === 'number') gp.max = sp.max; if (typeof sp.curve === 'number') gp.curve = sp.curve; if (typeof sp.muted === 'boolean') gp.muted = sp.muted; if (typeof sp.fixedValue === 'number') gp.fixedValue = sp.fixedValue; } } } } // Restore visual overrides if (Array.isArray(state.visualOverrides)) { for (let i = 0; i < state.visualOverrides.length && i < visualOverrides.length; i++) { const sv = state.visualOverrides[i], vo = visualOverrides[i]; if (typeof sv.min === 'number') vo.min = sv.min; if (typeof sv.max === 'number') vo.max = sv.max; if (typeof sv.curve === 'number') vo.curve = sv.curve; if (typeof sv.frozen === 'boolean') vo.frozen = sv.frozen; if (typeof sv.fixedValue === 'number') vo.fixedValue = sv.fixedValue; } } // Restore MIDI CC overrides if (Array.isArray(state.midiCCOverrides)) { for (let i = 0; i < state.midiCCOverrides.length && i < midiCCOverrides.length; i++) { const sv = state.midiCCOverrides[i], ov = midiCCOverrides[i]; if (typeof sv.min === 'number') ov.min = sv.min; if (typeof sv.max === 'number') ov.max = sv.max; if (typeof sv.curve === 'number') ov.curve = sv.curve; if (typeof sv.frozen === 'boolean') ov.frozen = sv.frozen; if (typeof sv.fixedValue === 'number') ov.fixedValue = sv.fixedValue; } } // Restore synth preset id (just track it, don't re-apply — groupOverrides already restored above) if (typeof state.synthPresetId === 'string') { activeSynthPresetId = state.synthPresetId; } // Restore audio canvas state (pan/zoom/submode) if (state.audioCanvasState && audioCanvas) { audioCanvas.setState(state.audioCanvasState); } // Restore output mode if (state.outputMode && state.outputMode !== outputMode) { await setOutputMode(state.outputMode, { skipConfirm: true }); syncOutputToggles(outputMode); } // Note: don't auto-restore inputMode='hands' — requires camera permission console.log(`[NISPS] Restored ${state.features?.length || 0} joy examples, ${state.handFeatures?.length || 0} hand examples from storage`); } catch (e) { console.warn('[NISPS] Failed to load state:', e); } } function clearState() { try { localStorage.removeItem(STORAGE_KEY); } catch (e) { console.warn('[NISPS] Failed to clear state:', e); } } // ---- Start ---- document.addEventListener('DOMContentLoaded', init);