memlnaut-nisps/playground/js/a-app.js
w1n5t0n 73eeaac0cc feat(playground): implement Phase 1 control surface — compound axes, input pipeline, enhanced joy-map
Three new standalone ES modules + integration into a-app.js:

- input-pipeline.js: 5-stage processing (deadzone → zoom → curve → smoothing →
  momentum-as-zoom), 3 anchor modes, zoom-at-zero freeze, per-axis overrides
- control-surface.js: compound axes (Boldness/Memory/Precision) with interpolation
  tables, offset-based override resolution (trim-pot model), 6 built-in presets
- control-surface-ui.js: floating bar axis sliders, gear icon settings drawer with
  Input/Training/Exploration/Output sections, log-scale sliders, override dots
- joy-map-enhanced.js: zoom minimap with adaptive grid (4×4→32×32), vanishing trail
  with Catmull-Rom spline + tap-to-return, dual concentric noise rings, frozen overlay

Integration fixes from fresh-eyes review:
- getCurrentInputs()/setCurrentInputs() use cached pipeline coords (not raw)
- CSS noise ring hidden when canvas version active (no doubling)
- Input mode switch re-runs through pipeline
- Control surface state persisted to localStorage

Implements full Phase 1 of SPEC-controls.md plus bonus items from later phases
(zoom-aware feedback, control presets with override resolution, input curve/deadzone/
smoothing/momentum all wired).
2026-03-26 10:24:24 +02:00

2608 lines
84 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 { C15Bridge } from './synth/c15-bridge.js';
import { Arpeggiator } from './synth/arpeggiator.js';
import { MIDIInput } from './synth/midi-input.js';
import { InputPipeline } from './ui/input-pipeline.js';
import { initControlSurfaceUI } from './ui/control-surface-ui.js';
import { JoyMapEnhanced } from './ui/joy-map-enhanced.js';
// ---- ShapeSeq (feature-flagged, enable with ?shapeseq=1) ----
const ENABLE_SHAPESEQ = new URLSearchParams(window.location.search).get('shapeseq') === '1';
let _shapeSeqImports = null;
async function getShapeSeqModules() {
if (_shapeSeqImports) return _shapeSeqImports;
const [seqMod, vizMod, uiMod, busMod] = await Promise.all([
import('./shapeseq/sequencer.js'),
import('./shapeseq/step-viz.js'),
import('./shapeseq/chain-ui.js'),
import('./shapeseq/event-bus.js'),
]);
_shapeSeqImports = {
ShapeSeqEngine: seqMod.ShapeSeqEngine,
StepVisualizer: vizMod.StepVisualizer,
ChainBuilderUI: uiMod.ChainBuilderUI,
getDefaultBus: busMod.getDefaultBus,
};
return _shapeSeqImports;
}
let shapeSeq = null;
let stepViz = null;
let chainUI = null;
import { SYNTH_PARAM_MAP, SYNTH_PARAM_NAMES, SYNTH_PARAM_COLORS, applyCurve, applyGroupOverride } from './synth/param-map.js';
import { OSCOutput } from './synth/osc-output.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
const N_OUTPUTS = N_SYNTH_OUTPUTS; // MLP always produces full output; visual uses first 20
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 c15 = null;
let oscOutput = null;
let arpeggiator = null;
let midiInput = null;
let outputMode = 'visual';
// tame level is now baked into groupOverrides defaults at init time (see ?tame URL param)
let spreadLevel = 0.6;
let noiseLevel = 0.05;
const rlExplorationDecay = 0.97;
// Control surface (Phase 1)
let inputPipeline = null;
let controlSurface = null;
let joyMapEnhanced = null;
let _lastFrameTime = 0;
let _lastPipeX = 0.5, _lastPipeY = 0.5; // cached pipeline output for getCurrentInputs()
// Joystick state
let joyX = 0.5;
let joyY = 0.5;
let joyDragging = false;
let joyFollowMode = false;
let joyTrail = [];
// Sheet state
let sheetExpanded = false;
// 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 $bottomSheet, $statusText;
let $sheetContent;
let $synthPanel, $lossCanvas, $lossCtx;
let $rawParams;
let $floatingBar, $chevronBtn, $followPill;
let $synthVisCanvas;
// ---- 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;
}
// ---- 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
c15 = new C15Bridge();
c15.onStatusChange = msg => {
const el = document.getElementById('synth-status');
if (el) el.textContent = msg;
};
c15.loadParams();
arpeggiator = new Arpeggiator(c15);
midiInput = new MIDIInput(c15);
initMIDIControls();
// OSC output
oscOutput = new OSCOutput();
oscOutput.onStatusChange = (msg, connected) => {
const el = document.getElementById('osc-status');
const btn = document.getElementById('osc-toggle');
const pill = document.getElementById('osc-pill');
if (el) el.textContent = msg;
if (btn) btn.textContent = connected ? 'Disconnect' : 'Connect';
if (pill) {
pill.classList.toggle('connected', connected);
pill.classList.toggle('connecting', oscOutput.enabled && !connected);
pill.title = connected ? `OSC connected: ${msg}` : 'Connect OSC output';
}
};
wireOSCControls();
// 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');
$bottomSheet = document.getElementById('bottom-sheet');
$statusText = document.getElementById('status-text');
$sheetContent = document.getElementById('sheet-content');
$synthPanel = document.getElementById('synth-panel');
$lossCanvas = document.getElementById('loss-canvas');
$lossCtx = $lossCanvas.getContext('2d');
$rawParams = document.getElementById('raw-params');
$floatingBar = document.getElementById('floating-bar');
$chevronBtn = document.getElementById('chevron-btn');
$followPill = document.getElementById('follow-pill');
// Build heatmap cells
buildHeatmap();
// Build raw param sliders
buildRawParams();
// Wire events
wireJoystick();
wireBottomSheet();
wireControls();
wireSynthControls();
wireGamepad();
wireKeyboard();
wireInputToggle();
createDevPanel(() => handTracker);
wireQuickPlayControls();
wireGroupDrawer();
wireHelp();
// Resize
window.addEventListener('resize', onResize);
onResize();
// ---- Control Surface (Phase 1) — init before loadState so restore works ----
inputPipeline = new InputPipeline();
const csUI = initControlSurfaceUI();
controlSurface = csUI.surface;
// Restore saved state (if any)
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;
}
// Enhanced joy-map (replaces drawJoyMap)
joyMapEnhanced = new JoyMapEnhanced($joyMap, {
onTrailTap: (pos) => {
joyX = pos.x;
joyY = pos.y;
onJoystickMove();
},
getTrainingData: () => ({
features: iml.dataset.features,
labels: iml.dataset.labels,
}),
});
// Wire control surface changes to pipeline + RL params
document.addEventListener('controlsurface:change', (e) => {
const p = e.detail;
// Input pipeline
inputPipeline.setConfig({
zoom: p.zoom,
deadzone: p.deadzone,
inputCurve: p.inputCurve,
smoothing: p.smoothing,
momentumZoom: p.momentumZoom,
invertX: p.invertX,
invertY: p.invertY,
});
// Sync spread (used by moveWeights and randomise)
spreadLevel = p.spread;
});
// Initial inference — run through pipeline for consistency
if (inputMode === 'joystick') onJoystickMove();
updateStatus();
drawJoyMap();
drawLossPlot();
// Auto-save every 10 seconds
setInterval(saveState, 10000);
// Start animation
requestAnimationFrame(animate);
}
// ---- Heatmap (bar chart style) ----
function buildHeatmap() {
$heatmapCells.innerHTML = '';
const isSynth = outputMode === 'synth';
const count = isSynth ? N_SYNTH_OUTPUTS : N_VISUAL_OUTPUTS;
const names = isSynth ? SYNTH_PARAM_NAMES : VISUAL_PARAM_NAMES;
const colors = isSynth ? SYNTH_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);
cell.addEventListener('pointerenter', (e) => {
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', () => {
$heatmapTooltip.classList.remove('visible');
});
$heatmapCells.appendChild(cell);
}
}
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 + '%';
}
}
}
// ---- Joy Map (merged joystick + minimap) ----
function drawJoyMap() {
// Use enhanced joy-map if available
if (joyMapEnhanced) {
joyMapEnhanced.draw({
joyX,
joyY,
// effectiveX/Y are set in onJoystickMove; for draw we show zoom window + cursor
zoomWindow: inputPipeline ? inputPipeline.getZoomWindow() : null,
zoomLevel: inputPipeline ? inputPipeline.getZoomLevel() : 1.0,
noiseLevel,
outputMode,
frozen: inputPipeline ? inputPipeline.isFrozen() : false,
});
return;
}
// Legacy fallback
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;
// Process through input pipeline (zoom, deadzone, curve, smoothing, momentum)
const now = performance.now();
const dt = _lastFrameTime > 0 ? (now - _lastFrameTime) / 1000 : 1 / 60;
_lastFrameTime = now;
const pipeResult = inputPipeline
? inputPipeline.process(joyX, joyY, dt)
: { x: joyX, y: joyY, frozen: false };
// Cache for getCurrentInputs() (avoids re-processing and mutating state)
_lastPipeX = pipeResult.x;
_lastPipeY = pipeResult.y;
if (pipeResult.frozen) return; // Input frozen (zoom at zero)
iml.setInput(0, pipeResult.x);
iml.setInput(1, pipeResult.y);
iml.process();
const outputs = iml.getOutputs();
routeOutputs(outputs);
updateHeatmap(outputs);
syncRawParamsFromOutputs(outputs);
// Trail (enhanced joy-map handles this now, legacy trail kept for compat)
joyTrail.push({ x: joyX, y: joyY, t: Date.now() });
if (joyTrail.length > 30) joyTrail.shift();
// Enhanced trail records in input-space coords
if (joyMapEnhanced) {
joyMapEnhanced.addTrailPoint(joyX, joyY, inputPipeline ? inputPipeline.getZoomLevel() : 1.0);
}
}
// ---- 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).
const _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) {
let overridden = null;
if (outputMode === 'synth') {
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);
// C15 ring buffer: throttle + dead-zone filter
if (c15 && c15.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) {
c15.setParameter(SYNTH_PARAM_MAP[i].id, v);
_lastSentParams[i] = v;
}
}
}
}
} else {
visualizer.setParams(outputs.slice(0, N_VISUAL_OUTPUTS));
}
// OSC output — sends in both modes (has its own throttle + dead-zone).
// In synth mode sends post-override values; in visual mode sends raw outputs.
if (oscOutput) {
oscOutput.sendParams(overridden || outputs);
}
}
// ---- Bottom sheet ----
function wireBottomSheet() {
// Chevron toggle
$chevronBtn.addEventListener('click', () => {
sheetExpanded = !sheetExpanded;
if (sheetExpanded) {
$bottomSheet.classList.add('expanded');
$chevronBtn.classList.add('open');
} else {
$bottomSheet.classList.remove('expanded');
$chevronBtn.classList.remove('open');
}
});
// Follow pill toggle
$followPill.addEventListener('click', () => {
toggleFollowMode();
});
}
// ---- Controls wiring ----
function wireControls() {
// Output mode toggle (floating bar)
document.querySelectorAll('#output-toggle-float .pill-opt').forEach(btn => {
btn.addEventListener('click', () => {
syncOutputToggles(btn.dataset.mode);
setOutputMode(btn.dataset.mode);
});
});
// Action buttons
document.getElementById('btn-add-example').addEventListener('click', onAddExample);
document.getElementById('btn-train').addEventListener('click', onTrain);
document.getElementById('btn-train-bar').addEventListener('click', onTrain);
document.getElementById('btn-clear').addEventListener('click', onClear);
document.getElementById('btn-clear-examples').addEventListener('click', onClearExamples);
document.getElementById('btn-clear-examples-bar').addEventListener('click', onClearExamples);
document.getElementById('btn-randomize').addEventListener('click', onRandomize);
document.getElementById('btn-randomize-bar').addEventListener('click', onRandomize);
// RL buttons
$btnThumbsUp.addEventListener('click', onThumbsUp);
$btnThumbsDown.addEventListener('click', onThumbsDown);
// 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');
}
// Re-run through pipeline so MLP sees processed coords
onJoystickMove();
}
updateStatus();
drawJoyMap();
}
function getCurrentInputs() {
if (inputMode === 'hands' && handTracker) {
return [...handTracker.features];
}
// Return pipeline-processed coords (what the MLP actually sees),
// not raw joyX/joyY, so examples are recorded in the correct input space
return [_lastPipeX, _lastPipeY];
}
function setCurrentInputs() {
if (inputMode === 'hands' && handTracker) {
const f = handTracker.features;
for (let i = 0; i < f.length; i++) iml.setInput(i, f[i]);
} else {
// Use cached pipeline output (matches what MLP sees during inference)
iml.setInput(0, _lastPipeX);
iml.setInput(1, _lastPipeY);
}
}
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));
}
function setOutputMode(mode) {
outputMode = mode;
hideGroupDrawer();
buildHeatmap();
updateHeatmap(iml.getOutputs());
const heatmapStrip = document.getElementById('heatmap-strip');
const synthQuickControls = document.getElementById('synth-quick-controls');
const shapeseqContainer = document.getElementById('shapeseq-container');
if (mode === 'synth') {
$synthPanel.classList.remove('hidden');
$canvas.classList.add('hidden-canvas');
$synthVisCanvas.classList.add('active');
heatmapStrip.classList.add('hidden');
synthQuickControls.classList.remove('hidden');
synthVisualizer.enableInteraction(true);
// Pulse play button if audio not yet started
const qp = document.getElementById('quick-play');
if (qp) qp.classList.toggle('audio-needs-init', !(c15 && c15.running));
// Show ShapeSeq UI if feature-flagged
if (ENABLE_SHAPESEQ && shapeseqContainer) {
shapeseqContainer.classList.remove('hidden');
if (stepViz) {
const vizCanvas = document.getElementById('shapeseq-viz');
if (vizCanvas) {
const rect = vizCanvas.getBoundingClientRect();
stepViz.resize(rect.width, rect.height);
}
}
}
} else {
$synthPanel.classList.add('hidden');
$canvas.classList.remove('hidden-canvas');
$synthVisCanvas.classList.remove('active');
heatmapStrip.classList.remove('hidden');
synthQuickControls.classList.add('hidden');
synthVisualizer.enableInteraction(false);
// Hide ShapeSeq UI
if (shapeseqContainer) shapeseqContainer.classList.add('hidden');
}
routeOutputs(iml.getOutputs());
buildRawParams();
syncRawParamsFromOutputs(iml.getOutputs());
}
function updateNoiseRing() {
// When enhanced joy-map is active, noise ring is drawn on canvas — hide CSS version
if (joyMapEnhanced) {
$noiseRing.className = 'noise-ring';
return;
}
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();
}
// ---- RL mode ----
function onThumbsUp() {
if (iml.isTraining) return;
const inputs = getCurrentInputs();
const outputs = [...iml.getOutputs()];
iml.addExample(inputs, outputs);
// Use control surface params if available, else legacy
const csParams = controlSurface ? controlSurface.getParams() : null;
const decay = csParams ? csParams.noiseDecay : rlExplorationDecay;
const floor = csParams ? csParams.noiseFloor : 0.005;
noiseLevel *= decay;
noiseLevel = Math.max(noiseLevel, floor);
flash('btn-thumbsup');
updateNoiseRing();
trainModelAsync();
}
function onThumbsDown() {
// Use control surface params if available, else legacy
const csParams = controlSurface ? controlSurface.getParams() : null;
const noiseCap = csParams ? csParams.noiseCap : (0.3 * (1 - spreadLevel) + 0.05 * spreadLevel);
const growth = csParams ? csParams.noiseGrowth : 1.5;
// Zoom-aware feedback: scale noise by zoom level
let effectiveGrowth = growth;
if (csParams && csParams.zoomAwareFeedback && inputPipeline) {
effectiveGrowth *= inputPipeline.getZoomLevel();
}
noiseLevel = Math.min(noiseLevel * effectiveGrowth, 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;
}
// ---- 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 ----
function buildRawParams() {
$rawParams.innerHTML = '';
const isSynth = outputMode === 'synth';
const count = isSynth ? N_SYNTH_OUTPUTS : N_VISUAL_OUTPUTS;
const names = isSynth ? SYNTH_PARAM_NAMES : VISUAL_PARAM_NAMES;
for (let i = 0; i < count; i++) {
const row = document.createElement('div');
row.className = 'raw-param';
row.dataset.tooltip = `${names[i]}: ${rawParamValues[i].toFixed(2)}`;
const label = document.createElement('span');
label.className = 'raw-param-label';
label.textContent = names[i];
const slider = document.createElement('input');
slider.type = 'range';
slider.min = '0';
slider.max = '1';
slider.step = '0.01';
slider.value = rawParamValues[i];
slider.dataset.index = i;
const val = document.createElement('span');
val.className = 'raw-param-val';
val.textContent = rawParamValues[i].toFixed(2);
slider.addEventListener('input', () => {
const idx = parseInt(slider.dataset.index);
rawParamValues[idx] = parseFloat(slider.value);
val.textContent = rawParamValues[idx].toFixed(2);
row.dataset.tooltip = `${names[idx]}: ${rawParamValues[idx].toFixed(2)}`;
routeOutputs(rawParamValues);
updateHeatmap(rawParamValues);
});
row.appendChild(label);
row.appendChild(slider);
row.appendChild(val);
$rawParams.appendChild(row);
}
}
function syncRawParamsFromOutputs(outputs) {
rawParamValues = [...outputs];
const rows = $rawParams.querySelectorAll('.raw-param');
rows.forEach((row, i) => {
if (i < outputs.length) {
const s = row.querySelector('input[type="range"]');
if (s) {
s.value = outputs[i];
s.nextElementSibling.textContent = outputs[i].toFixed(2);
}
const name = row.querySelector('.raw-param-label')?.textContent || `p${i}`;
row.dataset.tooltip = `${name}: ${outputs[i].toFixed(2)}`;
}
});
}
// ---- ShapeSeq lazy init (feature-flagged) ----
async function ensureShapeSeqInit() {
if (!ENABLE_SHAPESEQ) return;
if (shapeSeq) return;
if (!c15 || !c15.audioContext) return;
try {
const mods = await getShapeSeqModules();
const eventBus = mods.getDefaultBus(c15.audioContext);
shapeSeq = new mods.ShapeSeqEngine({ audioContext: c15.audioContext, eventBus, c15Bridge: c15 });
await shapeSeq.init();
const seqVizCanvas = document.getElementById('shapeseq-viz');
const seqChainContainer = document.getElementById('shapeseq-chain');
if (seqVizCanvas && !stepViz) {
stepViz = new mods.StepVisualizer({ canvas: seqVizCanvas, eventBus });
const rect = seqVizCanvas.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) stepViz.resize(rect.width, rect.height);
}
if (seqChainContainer && !chainUI) {
chainUI = new mods.ChainBuilderUI({ container: seqChainContainer, chain: shapeSeq.getChain(), eventBus });
}
console.log('[NISPS] ShapeSeq initialized');
} catch (err) {
console.error('[NISPS] ShapeSeq init failed:', err);
}
}
// ---- 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');
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();
await ensureShapeSeqInit();
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;
});
}
// ---- OSC Output ----
function toggleOSC() {
if (oscOutput.connected || oscOutput.enabled) {
oscOutput.disconnect();
} else {
oscOutput.connect();
}
}
function wireOSCControls() {
const toggle = document.getElementById('osc-toggle');
const pill = document.getElementById('osc-pill');
if (toggle) toggle.addEventListener('click', toggleOSC);
if (pill) pill.addEventListener('click', toggleOSC);
}
// ---- MIDI Input ----
async function initMIDIControls() {
const row = document.getElementById('midi-row');
const statusRow = document.getElementById('midi-status-row');
const toggle = document.getElementById('midi-toggle');
const select = document.getElementById('midi-select');
const statusEl = document.getElementById('midi-status');
if (!row || !toggle || !select) return;
const available = await midiInput.init();
if (!available) return;
row.style.display = '';
function populateInputs() {
const inputs = midiInput.getInputs();
select.innerHTML = '<option value="">All Inputs</option>' +
inputs.map(i => `<option value="${i.id}">${i.name}</option>`).join('');
}
populateInputs();
midiInput.onInputsChange = () => populateInputs();
midiInput.onStatusChange = (msg) => {
if (statusEl) {
statusEl.textContent = msg;
statusRow.style.display = '';
}
};
midiInput.onCC = (cc, value) => {
if (cc === 1) { joyX = value; onJoystickMove(); drawJoyMap(); }
if (cc === 2) { joyY = value; onJoystickMove(); drawJoyMap(); }
};
toggle.addEventListener('click', () => {
if (!c15.running) return;
midiInput.toggle();
toggle.textContent = midiInput.enabled ? 'Disable' : 'Enable';
toggle.classList.toggle('playing', midiInput.enabled);
});
select.addEventListener('change', (e) => {
midiInput.selectInput(e.target.value || null);
});
}
// ---- 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;
if (e.key === 'Escape') {
if (sheetExpanded) {
sheetExpanded = false;
$bottomSheet.classList.remove('expanded');
$chevronBtn.classList.remove('open');
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();
}
});
}
// ---- 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');
const playIconSVG = '<path d="M4 2l10 6-10 6z"/>';
const pauseIconSVG = '<rect x="3" y="2" width="4" height="12"/><rect x="9" y="2" width="4" height="12"/>';
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();
await ensureShapeSeqInit();
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;
function wireGroupDrawer() {
// Create the drawer DOM element once
$groupDrawer = document.createElement('div');
$groupDrawer.className = 'group-drawer';
$groupDrawer.innerHTML = '<div class="group-drawer-header"></div><div class="group-drawer-body"></div>';
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();
visualizer.resize();
visualizer.initParticles();
synthVisualizer.resize();
// Resize ShapeSeq step viz if visible
if (ENABLE_SHAPESEQ && stepViz && outputMode === 'synth') {
const vizCanvas = document.getElementById('shapeseq-viz');
if (vizCanvas) {
const rect = vizCanvas.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) stepViz.resize(rect.width, rect.height);
}
}
// Resize joy-map canvas to match container
const size = $joystickContainer.offsetWidth;
if (size > 0) {
$joyMap.width = size;
$joyMap.height = size;
drawJoyMap();
}
}
// ---- OSC bridge download ----
// GitHub releases URL — update this when the repo is set up
const OSC_RELEASE_BASE = 'https://github.com/MusicallyEmbodiedML/MEMLNaut-NISPS/releases/latest/download';
function detectPlatform() {
const ua = navigator.userAgent.toLowerCase();
const platform = navigator.platform?.toLowerCase() || '';
if (ua.includes('win')) return { os: 'windows', arch: 'x86_64', label: 'Windows', ext: '.exe' };
if (ua.includes('mac') || platform.includes('mac')) {
// Check for Apple Silicon via WebGL renderer or default to ARM (most modern Macs)
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl');
const renderer = gl?.getParameter(gl.RENDERER)?.toLowerCase() || '';
const isIntel = renderer.includes('intel') || ua.includes('intel');
return isIntel
? { os: 'macos', arch: 'x86_64', label: 'macOS (Intel)', ext: '' }
: { os: 'macos', arch: 'arm64', label: 'macOS (Apple Silicon)', ext: '' };
}
if (ua.includes('linux')) {
const isArm = platform.includes('arm') || platform.includes('aarch');
return isArm
? { os: 'linux', arch: 'arm64', label: 'Linux (ARM)', ext: '' }
: { os: 'linux', arch: 'x86_64', label: 'Linux', ext: '' };
}
return { os: 'linux', arch: 'x86_64', label: 'Linux', ext: '' };
}
function downloadOSCBinary() {
const p = detectPlatform();
const filename = `nisps-osc-bridge-${p.os}-${p.arch}${p.ext}`;
const url = `${OSC_RELEASE_BASE}/${filename}`;
window.open(url, '_blank');
}
function downloadOSCSource() {
// Download bridge.ts directly — it's self-contained with Deno
fetch('osc-bridge/bridge.ts').then(r => r.blob()).then(blob => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'nisps-osc-bridge.ts';
a.click();
URL.revokeObjectURL(url);
}).catch(() => {
alert('Failed to download. Make sure the server is serving the osc-bridge/ directory.');
});
}
function initOSCDownloadUI() {
const p = detectPlatform();
// Set platform name in button
const platformName = document.getElementById('osc-platform-name');
if (platformName) platformName.textContent = p.label;
// Set run instructions based on OS
const instructions = document.getElementById('osc-run-instructions');
if (instructions) {
if (p.os === 'windows') {
instructions.textContent = 'nisps-osc-bridge-windows-x86_64.exe';
} else if (p.os === 'macos') {
instructions.textContent =
`# First time only: allow the binary to run\n` +
`chmod +x nisps-osc-bridge-macos-${p.arch}\n` +
`xattr -d com.apple.quarantine nisps-osc-bridge-macos-${p.arch}\n\n` +
`./nisps-osc-bridge-macos-${p.arch}`;
} else {
instructions.textContent =
`chmod +x nisps-osc-bridge-linux-${p.arch}\n` +
`./nisps-osc-bridge-linux-${p.arch}`;
}
}
}
// ---- Help modal ----
function wireHelp() {
const overlay = document.getElementById('help-overlay');
const btnOpen = document.getElementById('help-btn');
const btnClose = document.getElementById('help-close');
const btnGotIt = document.getElementById('help-got-it');
function show() { overlay.classList.remove('hidden'); }
function hide() { overlay.classList.add('hidden'); localStorage.setItem('nisps-help-seen', '1'); }
btnOpen.addEventListener('click', show);
btnClose.addEventListener('click', hide);
btnGotIt.addEventListener('click', hide);
overlay.addEventListener('click', (e) => { if (e.target === overlay) hide(); });
// OSC bridge download buttons
initOSCDownloadUI();
const oscDlBin = document.getElementById('osc-download-bin');
const oscDlSrc = document.getElementById('osc-download-src');
if (oscDlBin) oscDlBin.addEventListener('click', (e) => { e.stopPropagation(); downloadOSCBinary(); });
if (oscDlSrc) oscDlSrc.addEventListener('click', (e) => { e.stopPropagation(); downloadOSCSource(); });
// Show on first visit
if (!localStorage.getItem('nisps-help-seen')) show();
}
// ---- Animation ----
function animate() {
if (gamepad) gamepad.poll();
if (outputMode === 'synth') {
synthVisualizer.draw();
// ShapeSeq: route inputs and render step viz (feature-flagged)
if (ENABLE_SHAPESEQ && shapeSeq && shapeSeq.isPlaying) {
if (inputMode === 'hands' && handTracker && handTracker.active && handTracker.features) {
shapeSeq.setSequenceInputs([handTracker.features[0], handTracker.features[1]]);
} else {
shapeSeq.setSequenceInputs([joyX, joyY]);
}
}
if (ENABLE_SHAPESEQ && stepViz) stepViz.render();
} else {
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,
synthPresetId: activeSynthPresetId,
controlSurface: controlSurface ? controlSurface.getState() : null,
inputPipeline: inputPipeline ? inputPipeline.getConfig() : null,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (e) {
console.warn('[NISPS] Failed to save state:', e);
}
}
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 synth preset id (just track it, don't re-apply — groupOverrides already restored above)
if (typeof state.synthPresetId === 'string') {
activeSynthPresetId = state.synthPresetId;
}
// Restore output mode
if (state.outputMode && state.outputMode !== outputMode) {
setOutputMode(state.outputMode);
syncOutputToggles(outputMode);
}
// Restore control surface state
if (state.controlSurface && controlSurface) {
controlSurface.setState(state.controlSurface);
}
if (state.inputPipeline && inputPipeline) {
inputPipeline.setConfig(state.inputPipeline);
}
// 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);