From a669a54543e452ba6a473a663044befff6e45c35 Mon Sep 17 00:00:00 2001 From: w1n5t0n Date: Sun, 22 Mar 2026 00:57:58 +0200 Subject: [PATCH] feat(playground): add three alternative UI design explorations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three redesigns of the playground UI, each exploring a different interaction philosophy for the NISPS interactive ML engine: A) Immersive — fullscreen canvas with floating glassmorphism overlays B) Workbench — dashboard with 2D mapping heatmap as centerpiece C) Journey — phased UI that dissolves (Explore → Teach → Perform) All share the same 126-output MLP ([32,48,64] hidden layers), C15 synth bridge, and flow field visualizer. Each adds: - SynthVisualizer (126 grouped vertical bars) replacing particles in synth mode - RL as default learning mode with keyboard shortcuts (1/2) - Follow mode for trackpad exploration - ?tame= URL param for safe-range constraining - localStorage persistence (random on first boot) - Live iframe previews on designs.html homepage See devlog/2026-03-21-ui-redesign-explorations.md and meml-2c3 for remaining refinements identified during browser testing. --- playground/a-immersive.html | 155 ++ playground/b-workbench.html | 191 +++ playground/c-journey.html | 189 +++ playground/css/a-immersive.css | 809 ++++++++++ playground/css/b-workbench.css | 827 ++++++++++ playground/css/c-journey.css | 999 ++++++++++++ playground/designs.html | 192 +++ .../2026-03-21-ui-redesign-explorations.md | 103 ++ playground/js/a-app.js | 1359 +++++++++++++++++ playground/js/b-app.js | 1204 +++++++++++++++ playground/js/c-app.js | 1158 ++++++++++++++ 11 files changed, 7186 insertions(+) create mode 100644 playground/a-immersive.html create mode 100644 playground/b-workbench.html create mode 100644 playground/c-journey.html create mode 100644 playground/css/a-immersive.css create mode 100644 playground/css/b-workbench.css create mode 100644 playground/css/c-journey.css create mode 100644 playground/designs.html create mode 100644 playground/devlog/2026-03-21-ui-redesign-explorations.md create mode 100644 playground/js/a-app.js create mode 100644 playground/js/b-app.js create mode 100644 playground/js/c-app.js diff --git a/playground/a-immersive.html b/playground/a-immersive.html new file mode 100644 index 0000000..1877987 --- /dev/null +++ b/playground/a-immersive.html @@ -0,0 +1,155 @@ + + + + + + + + NISPS Immersive + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + +
+
+ +
+ +
+ + +
+ + +
+ + + + + + + diff --git a/playground/b-workbench.html b/playground/b-workbench.html new file mode 100644 index 0000000..f599850 --- /dev/null +++ b/playground/b-workbench.html @@ -0,0 +1,191 @@ + + + + + + + + NISPS Workbench + + + +
+ + +
+
+ + + +

NISPS Workbench

+
+
+ Visual + +
+
+ + +
+ + +
+
+ Flow Field +
+ + + +
+
+
+ + +
+
+ + +
+
+ Input Space + +
+
+
+ + +
Drag to explore · double-click or f to toggle follow
+
+
+ +
+ + 20 parameters +
+
+ + +
+
+ Parameters +
+ + +
+
+
+
+ + +
+
+ Controls +
+ + +
+ + +
+ + + + + +
+ + 1 + + 2 + +
+ + + + + +
+
Loss
+ +
+ + +
+
Examples0
+
Loss
+
Noise0.050
+
Joy X,Y0.50, 0.50
+
+
+ +
+ + + + + + + +
+ + + + diff --git a/playground/c-journey.html b/playground/c-journey.html new file mode 100644 index 0000000..6f71fa2 --- /dev/null +++ b/playground/c-journey.html @@ -0,0 +1,189 @@ + + + + + + + + NISPS Journey + + + +
+ + + + + + + + + + + + +
+ + + +
+ + +
+
+ 20 +
+
+ + +
+ + +
+ + +
+

Move the joystick to explore

+
+ + + + +
+ + +
+ + + + +
+ + +
+ +
+ + +
+ + + + + +
+

Like what you see? 👍 Want something different? 👎

+
+ + +
+
+ 1 + 2 +
+
+ + + + + +
+ 0 examples + + +
+ + + +
+ + +
+ +
+ + + + +
+ + + + diff --git a/playground/css/a-immersive.css b/playground/css/a-immersive.css new file mode 100644 index 0000000..697d68b --- /dev/null +++ b/playground/css/a-immersive.css @@ -0,0 +1,809 @@ +/* NISPS Immersive — Design A */ +/* Full-viewport canvas with floating translucent overlays */ + +* { + margin: 0; + padding: 0; + box-sizing: border-box; + -webkit-tap-highlight-color: transparent; +} + +:root { + --glass-bg: rgba(13, 13, 13, 0.65); + --glass-border: rgba(255, 255, 255, 0.08); + --glass-blur: 16px; + --accent: #00ff88; + --accent-dim: rgba(0, 255, 136, 0.25); + --danger: #ff4466; + --text: #e0e0e0; + --text-dim: #888; + --radius: 12px; + --radius-sm: 8px; + --safe-bottom: env(safe-area-inset-bottom, 0px); +} + +html, body { + width: 100%; + height: 100%; + overflow: hidden; + background: #0d0d0d; + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, 'SF Pro', 'Segoe UI', system-ui, sans-serif; + font-size: 14px; + touch-action: none; + user-select: none; + -webkit-user-select: none; +} + +/* ---- Back button ---- */ +.back-btn { + position: fixed; + top: 30px; + left: 8px; + z-index: 30; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + border-radius: 18px; + background: rgba(13, 13, 13, 0.65); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid rgba(255, 255, 255, 0.08); + color: var(--text); + text-decoration: none; + line-height: 1; + transition: background 0.15s; +} + +.back-btn:active { + background: rgba(255, 255, 255, 0.12); +} + +/* ---- Fullscreen canvas ---- */ +#vis-canvas, +#synth-vis-canvas { + position: fixed; + inset: 0; + width: 100%; + height: 100%; + display: block; + z-index: 0; +} + +#synth-vis-canvas { + display: none; +} + +#synth-vis-canvas.active { + display: block; +} + +#vis-canvas.hidden-canvas { + display: none; +} + +/* ---- Glass mixin ---- */ +.glass { + background: var(--glass-bg); + backdrop-filter: blur(var(--glass-blur)); + -webkit-backdrop-filter: blur(var(--glass-blur)); + border: 1px solid var(--glass-border); +} + +/* ---- Heatmap strip (top) ---- */ +.heatmap-strip { + position: fixed; + top: 0; + left: 0; + right: 0; + height: 22px; + z-index: 20; + background: rgba(13, 13, 13, 0.5); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + display: flex; + align-items: flex-end; + padding: 2px 4px; +} + +.heatmap-cells { + display: flex; + flex-direction: row; + flex: 1; + height: 16px; + gap: 1px; + border-radius: 3px; + overflow: hidden; + align-items: stretch; +} + +.heatmap-cell { + flex: 1; + min-width: 0; + position: relative; + cursor: pointer; + height: 16px; + background: rgba(255, 255, 255, 0.04); + overflow: hidden; +} + +.heatmap-cell-bar { + height: 100%; + border-radius: 0 1px 1px 0; + transition: width 0.15s; +} + +.heatmap-cell:hover .heatmap-cell-bar { + filter: brightness(1.3); +} + +.heatmap-tooltip { + position: fixed; + top: 32px; + padding: 4px 10px; + background: rgba(0, 0, 0, 0.85); + border: 1px solid var(--glass-border); + border-radius: 6px; + font-size: 12px; + color: var(--text); + pointer-events: none; + opacity: 0; + transition: opacity 0.15s; + z-index: 25; + white-space: nowrap; +} + +.heatmap-tooltip.visible { + opacity: 1; +} + +/* ---- Floating joystick with integrated map ---- */ +.joystick-container { + position: fixed; + bottom: 136px; + left: 24px; + width: 160px; + height: 160px; + z-index: 30; + touch-action: none; + border-radius: 50%; +} + +#joy-map { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + border-radius: 50%; + cursor: grab; + touch-action: none; +} + +#joy-map:active { + cursor: grabbing; +} + +.joystick-glow { + position: absolute; + inset: -8px; + border-radius: 50%; + pointer-events: none; + background: radial-gradient(circle, rgba(0,255,136,0.06) 0%, transparent 70%); + z-index: -1; +} + +/* Noise ring (RL exploration indicator) */ +.noise-ring { + position: absolute; + inset: -4px; + border-radius: 50%; + border: 0px solid rgba(255, 100, 50, 0.0); + pointer-events: none; + transition: border-width 0.3s, border-color 0.3s; +} + +.noise-ring.active { + border-width: 3px; + border-color: rgba(255, 100, 50, 0.5); +} + +.noise-ring.high { + border-width: 6px; + border-color: rgba(255, 60, 40, 0.6); +} + +/* Follow badge near joystick */ +.follow-badge { + position: absolute; + top: -24px; + left: 50%; + transform: translateX(-50%); + padding: 2px 10px; + border-radius: 10px; + background: rgba(0, 255, 136, 0.15); + border: 1px solid rgba(0, 255, 136, 0.4); + color: var(--accent); + font-size: 10px; + font-weight: 700; + letter-spacing: 1px; + pointer-events: none; + white-space: nowrap; +} + +.follow-badge.hidden { + display: none; +} + +/* Joystick follow-mode pulsing border on canvas */ +.joystick-container.follow-active #joy-map { + box-shadow: 0 0 0 2px rgba(0, 255, 136, 0.5); + animation: follow-pulse 1.5s ease-in-out infinite; +} + +@keyframes follow-pulse { + 0%, 100% { box-shadow: 0 0 0 2px rgba(0, 255, 136, 0.3); } + 50% { box-shadow: 0 0 12px 3px rgba(0, 255, 136, 0.5); } +} + +/* ---- RL floating buttons ---- */ +.rl-buttons { + position: fixed; + bottom: 170px; + left: 50%; + transform: translateX(-50%); + display: flex; + gap: 24px; + z-index: 25; + transition: opacity 0.3s; +} + +.key-num { + font-size: 10px; + opacity: 0.5; + margin-left: 4px; + font-family: monospace; +} + +.rl-buttons.hidden { + opacity: 0; + pointer-events: none; +} + +.rl-btn { + width: 64px; + height: 64px; + border-radius: 50%; + border: 2px solid var(--glass-border); + background: var(--glass-bg); + backdrop-filter: blur(var(--glass-blur)); + -webkit-backdrop-filter: blur(var(--glass-blur)); + color: var(--text); + font-size: 28px; + font-weight: 700; + cursor: pointer; + transition: all 0.15s; + display: flex; + align-items: center; + justify-content: center; +} + +.rl-btn:active { + transform: scale(0.9); +} + +.rl-up { + border-color: rgba(0, 255, 136, 0.3); + color: var(--accent); +} + +.rl-up:active { + background: rgba(0, 255, 136, 0.15); + box-shadow: 0 0 20px rgba(0, 255, 136, 0.3); +} + +.rl-down { + border-color: rgba(255, 68, 102, 0.3); + color: var(--danger); +} + +.rl-down:active { + background: rgba(255, 68, 102, 0.15); + box-shadow: 0 0 20px rgba(255, 68, 102, 0.3); +} + +.rl-btn.flash { + animation: rl-flash 0.25s ease-out; +} + +@keyframes rl-flash { + 0% { transform: scale(1.15); } + 100% { transform: scale(1); } +} + +/* ---- Floating mode bar (sticky inside sheet) ---- */ +.floating-bar { + position: sticky; + top: 0; + z-index: 45; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 6px 12px; + background: rgba(13, 13, 13, 0.85); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-bottom: 1px solid var(--glass-border); +} + +/* Float button (randomize in floating bar) */ +.float-btn { + padding: 4px 12px; + border-radius: 14px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.04); + color: var(--text-dim); + font-size: 12px; + cursor: pointer; + transition: all 0.15s; +} + +.float-btn:active { + background: rgba(0, 255, 136, 0.15); + color: var(--accent); +} + +.follow-pill { + padding: 4px 12px; + border-radius: 14px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.04); + color: var(--text-dim); + font-size: 12px; + cursor: pointer; + transition: all 0.15s; +} + +.follow-pill.active { + background: rgba(0, 255, 136, 0.15); + border-color: rgba(0, 255, 136, 0.4); + color: var(--accent); +} + +.chevron-btn { + width: 32px; + height: 32px; + border-radius: 50%; + border: 1px solid var(--glass-border); + background: rgba(255, 255, 255, 0.06); + color: var(--text-dim); + font-size: 14px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; +} + +.chevron-btn:active { + background: rgba(255, 255, 255, 0.12); +} + +.chevron-btn.open { + transform: rotate(180deg); +} + +/* ---- Bottom sheet ---- */ +.bottom-sheet { + position: fixed; + bottom: 0; + left: 0; + right: 0; + z-index: 40; + background: rgba(13, 13, 13, 0.75); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-top: 1px solid var(--glass-border); + border-radius: 0; + transition: height 0.35s cubic-bezier(0.22, 1, 0.36, 1); + height: 88px; + overflow: hidden; + padding-bottom: var(--safe-bottom); + will-change: height; +} + +.bottom-sheet.expanded { + height: 55vh; + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} + +/* Sheet status line */ +.sheet-status { + padding: 8px 16px; + font-size: 12px; + color: var(--text-dim); + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + height: 44px; + display: flex; + align-items: center; + justify-content: center; +} + +/* Sheet content */ +.sheet-content { + padding: 0 16px 16px; +} + +/* Mode row */ +.mode-row { + display: flex; + justify-content: center; + margin-bottom: 12px; +} + +/* Pill toggle */ +.pill-toggle { + display: inline-flex; + border-radius: 20px; + overflow: hidden; + border: 1px solid var(--glass-border); + background: rgba(255, 255, 255, 0.04); +} + +.pill-opt { + padding: 6px 18px; + background: none; + border: none; + color: var(--text-dim); + font-size: 13px; + cursor: pointer; + transition: all 0.2s; +} + +.pill-opt.active { + background: var(--accent-dim); + color: var(--accent); + font-weight: 600; +} + +/* Action buttons row */ +.action-row { + display: flex; + gap: 8px; + flex-wrap: wrap; + justify-content: center; + margin-bottom: 12px; +} + +.action-btn { + padding: 8px 16px; + border-radius: 20px; + border: 1px solid var(--glass-border); + background: rgba(255, 255, 255, 0.06); + color: var(--text); + font-size: 13px; + cursor: pointer; + transition: all 0.15s; + touch-action: manipulation; +} + +.action-btn:active { + transform: scale(0.95); +} + +.action-btn.accent { + border-color: rgba(0, 255, 136, 0.3); + color: var(--accent); + background: rgba(0, 255, 136, 0.08); +} + +.action-btn.accent:active { + background: rgba(0, 255, 136, 0.2); +} + +.action-btn.dim { + color: var(--text-dim); + border-color: rgba(255, 255, 255, 0.05); +} + +.action-btn.flash { + animation: btn-flash 0.2s ease-out; +} + +@keyframes btn-flash { + 0% { background: rgba(0, 255, 136, 0.3); } + 100% { background: rgba(255, 255, 255, 0.06); } +} + +/* Preset row */ +.preset-row { + display: flex; + gap: 6px; + align-items: center; + flex-wrap: wrap; + justify-content: center; + margin-bottom: 12px; +} + +.preset-label { + font-size: 11px; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-right: 4px; +} + +.preset-chip { + padding: 4px 12px; + border-radius: 14px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.04); + color: var(--text-dim); + font-size: 12px; + cursor: pointer; + transition: all 0.15s; + touch-action: manipulation; +} + +.preset-chip:active { + background: rgba(0, 255, 136, 0.15); + color: var(--accent); +} + +/* Synth panel */ +.synth-panel { + margin-bottom: 16px; +} + +.synth-panel.hidden { + display: none; +} + +.synth-row { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 10px; +} + +.synth-row label { + font-size: 12px; + color: var(--text-dim); + min-width: 80px; +} + +.synth-row input[type="range"] { + flex: 1; + accent-color: var(--accent); + height: 4px; +} + +.synth-row select { + flex: 1; + background: rgba(255, 255, 255, 0.06); + border: 1px solid var(--glass-border); + border-radius: 6px; + color: var(--text); + padding: 4px 8px; + font-size: 12px; +} + +.synth-status { + font-size: 11px; + color: var(--text-dim); +} + +/* Loss section */ +.loss-section { + margin-bottom: 12px; +} + +.loss-section label { + font-size: 11px; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.5px; + display: block; + margin-bottom: 4px; +} + +#loss-canvas { + width: 100%; + height: 80px; + border-radius: var(--radius-sm); + background: rgba(0, 0, 0, 0.3); + border: 1px solid var(--glass-border); +} + +/* Advanced section */ +.advanced-section { + margin-top: 8px; +} + +.advanced-section summary { + font-size: 12px; + color: var(--text-dim); + cursor: pointer; + padding: 6px 0; +} + +.raw-params { + display: grid; + grid-template-columns: 1fr; + gap: 4px; + max-height: 300px; + overflow-y: auto; + padding: 8px 0; +} + +.raw-param { + display: flex; + align-items: center; + gap: 8px; + font-size: 11px; +} + +.raw-param-label { + width: 70px; + color: var(--text-dim); + text-align: right; + flex-shrink: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.raw-param input[type="range"] { + flex: 1; + accent-color: var(--accent); + height: 3px; +} + +.raw-param-val { + width: 36px; + color: var(--text-dim); + text-align: left; + flex-shrink: 0; + font-variant-numeric: tabular-nums; +} + +/* ---- Synth quick controls (play button next to back) ---- */ +.synth-quick-controls { + position: fixed; + top: 30px; + left: 52px; + z-index: 30; + display: flex; + flex-direction: row; + align-items: center; + gap: 0; +} + +.play-btn { + width: 36px; + height: 36px; + border-radius: 18px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(13, 13, 13, 0.65); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + color: var(--text); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s, border-color 0.15s; + padding: 0; + flex-shrink: 0; +} + +.play-btn:active { + background: rgba(255, 255, 255, 0.12); +} + +.play-btn.playing { + border-color: rgba(0, 255, 136, 0.4); + color: var(--accent); + background: rgba(0, 255, 136, 0.12); +} + +.play-drawer { + display: none; + flex-direction: column; + gap: 6px; + padding: 8px 12px; + border-radius: 18px; + background: rgba(13, 13, 13, 0.65); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid rgba(255, 255, 255, 0.08); + margin-left: 6px; + height: auto; + white-space: nowrap; +} + +.play-drawer label { + font-size: 11px; + color: var(--text-dim); + display: flex; + align-items: center; + gap: 6px; +} + +.play-drawer input[type="range"] { + width: 80px; + accent-color: var(--accent); + height: 3px; +} + +.play-drawer span { + font-size: 10px; + font-variant-numeric: tabular-nums; + min-width: 24px; + color: var(--text-dim); +} + +.synth-quick-controls:hover .play-drawer, +.play-drawer.show { + display: flex; +} + +/* ---- Utility ---- */ +.hidden { + display: none !important; +} + +/* ---- Scrollbar styling ---- */ +.bottom-sheet::-webkit-scrollbar { + width: 4px; +} + +.bottom-sheet::-webkit-scrollbar-track { + background: transparent; +} + +.bottom-sheet::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.1); + border-radius: 2px; +} + +/* ---- Media queries ---- */ +@media (min-width: 768px) { + .joystick-container { + width: 180px; + height: 180px; + bottom: 156px; + left: 32px; + } + + #joy-map { + width: 180px; + height: 180px; + } + + .rl-buttons { + bottom: 196px; + gap: 32px; + } + + .rl-btn { + width: 72px; + height: 72px; + font-size: 32px; + } +} + +@media (min-width: 1024px) { + .bottom-sheet { + max-width: 600px; + left: 50%; + transform: translateX(-50%); + } +} diff --git a/playground/css/b-workbench.css b/playground/css/b-workbench.css new file mode 100644 index 0000000..3c90bae --- /dev/null +++ b/playground/css/b-workbench.css @@ -0,0 +1,827 @@ +/* NISPS Workbench — Design B CSS */ + +/* === Reset & Base === */ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + --bg: #0d0d0d; + --surface: #1a1a1a; + --elevated: #252525; + --border: #222; + --border-light: #333; + --text: #e0e0e0; + --text-dim: #888; + --text-muted: #555; + --accent-visual: #00ff88; + --accent-synth: #ff6b35; + --accent: var(--accent-visual); + --danger: #ff4444; + --good: #00cc66; + --bad: #ff6644; + --radius: 8px; + --radius-sm: 4px; + --transition: 0.2s ease; + /* Group accent colors */ + --group-motion: #00ccff; + --group-color: #ff00cc; + --group-particles: #ffcc00; + --group-forces: #ff3366; + --group-effects: #9b59b6; + /* Synth group colors */ + --group-osc: #ff6b35; + --group-shapers: #ff006e; + --group-filters: #3a86ff; + --group-comb: #06d6a0; + --group-fx: #8338ec; + --group-output: #ffd166; +} + +body { + background: var(--bg); + color: var(--text); + font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace; + font-size: 13px; + line-height: 1.4; + overflow-x: hidden; + -webkit-font-smoothing: antialiased; +} + +body.synth-mode { + --accent: var(--accent-synth); +} + +/* === Layout === */ +.workbench { + display: flex; + flex-direction: column; + min-height: 100vh; + max-width: 1400px; + margin: 0 auto; + padding: 8px; + gap: 8px; +} + +.wb-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: var(--surface); + border-radius: var(--radius); + border: 1px solid var(--border); +} + +.wb-header-left { + display: flex; + align-items: center; + gap: 10px; +} + +.wb-back-btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: var(--radius-sm); + border: 1px solid #333; + background: var(--elevated); + color: #666; + font-size: 16px; + text-decoration: none; + transition: all var(--transition); + flex-shrink: 0; +} +.wb-back-btn:hover { + border-color: var(--accent); + color: var(--accent); + background: var(--surface); +} + +.wb-title { + font-size: 16px; + font-weight: 600; + color: var(--text); + letter-spacing: 0.5px; +} + +.wb-title-accent { + color: var(--accent); + font-weight: 400; +} + +.wb-header-right { + display: flex; + align-items: center; + gap: 8px; +} + +.wb-mode-badge { + font-size: 11px; + padding: 2px 8px; + border-radius: 10px; + background: var(--accent); + color: var(--bg); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.wb-mode-badge.synth { + background: var(--accent-synth); +} + +.wb-help-btn { + width: 28px; + height: 28px; + border-radius: 50%; + border: 1px solid var(--border-light); + background: var(--elevated); + color: var(--text-dim); + font-size: 14px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} +.wb-help-btn:hover { + border-color: var(--accent); + color: var(--accent); +} + +/* === Grid === */ +.wb-grid { + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: auto auto; + gap: 8px; + flex: 1; +} + +/* === Panel === */ +.wb-panel { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + display: flex; + flex-direction: column; +} + +.wb-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + border-bottom: 1px solid var(--border); + min-height: 36px; +} + +.wb-panel-title { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--text-dim); +} + +/* === Canvas Panel === */ +.wb-canvas-panel { + grid-column: 1; + grid-row: 1; + min-height: 300px; +} + +.wb-canvas-wrap { + flex: 1; + position: relative; + min-height: 250px; +} + +.wb-canvas-wrap canvas { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: block; +} + +#synth-vis-canvas { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: block; + z-index: 1; +} +#synth-vis-canvas.hidden { + display: none !important; +} + +/* Presets */ +.presets { + display: flex; + gap: 4px; +} + +.preset-pill { + font-size: 10px; + padding: 2px 8px; + border-radius: 10px; + border: 1px solid var(--border-light); + background: var(--elevated); + color: var(--text-dim); + cursor: pointer; + transition: all var(--transition); + white-space: nowrap; +} +.preset-pill:hover { + border-color: var(--accent); + color: var(--accent); +} + +/* === Mapping Panel === */ +.wb-mapping-panel { + grid-column: 2; + grid-row: 1; +} + +.wb-mapping-panel.follow-active { + border-color: var(--accent); +} + +.wb-mapping-area { + display: flex; + gap: 12px; + padding: 12px; + align-items: flex-start; + justify-content: center; + flex-wrap: wrap; +} + +.wb-heatmap-wrap { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + position: relative; +} + +.wb-heatmap-wrap canvas { + border-radius: var(--radius-sm); + border: 1px solid var(--border-light); + cursor: crosshair; + touch-action: none; + width: 100%; + aspect-ratio: 1; + max-width: 280px; +} + +.wb-heatmap-label { + font-size: 10px; + color: var(--text-muted); + text-align: center; +} + +/* Follow toggle button */ +.wb-follow-toggle-btn { + font-size: 10px; + padding: 2px 10px; + border-radius: 10px; + border: 1px solid var(--border-light); + background: var(--bg); + color: var(--text-muted); + cursor: pointer; + transition: all var(--transition); + font-family: inherit; +} +.wb-follow-toggle-btn:hover { + border-color: var(--accent); + color: var(--accent); +} +.wb-follow-toggle-btn.active { + background: var(--accent); + color: var(--bg); + border-color: var(--accent); + font-weight: 600; +} + +/* Follow badge */ +.wb-follow-badge { + position: absolute; + top: 8px; + right: 8px; + font-size: 10px; + font-weight: 700; + padding: 2px 8px; + border-radius: 10px; + background: var(--accent); + color: var(--bg); + text-transform: uppercase; + letter-spacing: 1px; + pointer-events: none; + z-index: 10; +} + +/* Constellation */ +.wb-constellation-wrap { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 8px 12px; + border-top: 1px solid var(--border); +} + +.wb-constellation-wrap canvas { + flex-shrink: 0; +} + +.wb-constellation-label { + font-size: 11px; + color: var(--text-muted); + font-style: italic; +} + +/* === Params Panel === */ +.wb-params-panel { + grid-column: 1; + grid-row: 2; + max-height: 400px; + overflow-y: auto; +} + +.wb-output-toggle { + display: flex; + gap: 2px; +} + +.wb-toggle-btn { + font-size: 10px; + padding: 2px 10px; + border: 1px solid var(--border-light); + background: var(--bg); + color: var(--text-muted); + cursor: pointer; + transition: all var(--transition); +} +.wb-toggle-btn:first-child { border-radius: 10px 0 0 10px; } +.wb-toggle-btn:last-child { border-radius: 0 10px 10px 0; } +.wb-toggle-btn.active { + background: var(--accent); + color: var(--bg); + border-color: var(--accent); + font-weight: 600; +} + +.wb-param-groups { + padding: 4px; + display: flex; + flex-direction: column; + gap: 2px; +} + +/* Param group card */ +.wb-group { + border-radius: var(--radius-sm); + overflow: hidden; + border: 1px solid var(--border); + background: var(--bg); +} + +.wb-group-header { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + cursor: pointer; + user-select: none; + transition: background var(--transition); +} +.wb-group-header:hover { + background: var(--elevated); +} + +.wb-group-accent { + width: 3px; + height: 18px; + border-radius: 2px; + flex-shrink: 0; +} + +.wb-group-name { + font-size: 11px; + font-weight: 600; + color: var(--text); + flex: 1; +} + +.wb-group-sparkline { + width: 40px; + height: 12px; + flex-shrink: 0; +} + +.wb-group-chevron { + font-size: 10px; + color: var(--text-muted); + transition: transform var(--transition); +} +.wb-group.open .wb-group-chevron { + transform: rotate(90deg); +} + +.wb-group-body { + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease; +} +.wb-group.open .wb-group-body { + max-height: 500px; +} + +.wb-group-params { + padding: 4px 8px 8px; + display: flex; + flex-direction: column; + gap: 3px; +} + +/* Individual param bar */ +.wb-param { + display: flex; + align-items: center; + gap: 6px; + height: 20px; +} + +.wb-param-name { + font-size: 10px; + color: var(--text-dim); + width: 60px; + flex-shrink: 0; + text-align: right; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.wb-param-track { + flex: 1; + height: 8px; + background: var(--surface); + border-radius: 4px; + position: relative; + cursor: pointer; + overflow: hidden; + touch-action: none; +} + +.wb-param-fill { + height: 100%; + border-radius: 4px; + transition: width 0.1s ease; + min-width: 1px; + pointer-events: none; +} + +.wb-param-value { + font-size: 10px; + color: var(--text-muted); + width: 32px; + text-align: right; + flex-shrink: 0; +} + +/* === Controls Panel === */ +.wb-controls-panel { + grid-column: 2; + grid-row: 2; + gap: 0; +} + +.wb-mode-toggle { + display: flex; + padding: 8px 12px 4px; + gap: 4px; +} + +.wb-mode-btn { + flex: 1; + padding: 6px 12px; + border: 1px solid var(--border-light); + background: var(--bg); + color: var(--text-dim); + font-size: 12px; + font-family: inherit; + cursor: pointer; + border-radius: var(--radius-sm); + transition: all var(--transition); +} +.wb-mode-btn.active { + background: var(--accent); + color: var(--bg); + border-color: var(--accent); + font-weight: 600; +} + +.wb-action-group { + display: flex; + flex-wrap: wrap; + gap: 4px; + padding: 4px 12px 8px; +} + +/* Buttons */ +.wb-btn { + padding: 6px 14px; + border: 1px solid var(--border-light); + background: var(--elevated); + color: var(--text); + font-size: 12px; + font-family: inherit; + border-radius: var(--radius-sm); + cursor: pointer; + transition: all var(--transition); + white-space: nowrap; +} +.wb-btn:hover { border-color: var(--text-muted); } +.wb-btn:active { transform: scale(0.97); } + +.wb-btn-primary { + background: var(--accent); + color: var(--bg); + border-color: var(--accent); + font-weight: 600; +} +.wb-btn-primary:hover { filter: brightness(1.15); } + +.wb-btn-accent { + border-color: var(--accent); + color: var(--accent); +} +.wb-btn-accent:hover { background: var(--accent); color: var(--bg); } + +.wb-btn-danger { + border-color: var(--danger); + color: var(--danger); +} +.wb-btn-danger:hover { background: var(--danger); color: var(--bg); } + +.wb-btn-good { + background: var(--good); + color: var(--bg); + border-color: var(--good); + font-weight: 600; +} +.wb-btn-bad { + background: var(--bad); + color: var(--bg); + border-color: var(--bad); + font-weight: 600; +} + +.wb-btn.flash { + filter: brightness(1.8); +} + +.wb-key-hint { + font-size: 10px; + font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace; + color: #555; + text-align: center; + line-height: 1; + flex-shrink: 0; +} + +/* Synth controls */ +.wb-synth-controls { + padding: 4px 12px 8px; + display: flex; + flex-direction: column; + gap: 6px; + border-top: 1px solid var(--border); +} + +.wb-synth-row { + display: flex; + align-items: center; + gap: 8px; +} +.wb-synth-row label { + font-size: 11px; + color: var(--text-dim); + min-width: 70px; +} +.wb-synth-row input[type="range"] { + flex: 1; + accent-color: var(--accent); + height: 4px; +} +.wb-synth-row select { + flex: 1; + background: var(--elevated); + border: 1px solid var(--border-light); + color: var(--text); + font-family: inherit; + font-size: 11px; + padding: 3px 6px; + border-radius: var(--radius-sm); +} + +.wb-synth-status { + font-size: 10px; + color: var(--text-muted); +} + +/* Loss plot */ +.wb-loss-wrap { + padding: 4px 12px; + border-top: 1px solid var(--border); +} +.wb-loss-label { + font-size: 10px; + color: var(--text-muted); + margin-bottom: 2px; + text-transform: uppercase; + letter-spacing: 0.5px; +} +.wb-loss-wrap canvas { + width: 100%; + height: 60px; + display: block; + border-radius: var(--radius-sm); +} + +/* Metrics */ +.wb-metrics { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4px; + padding: 8px 12px; + border-top: 1px solid var(--border); +} + +.wb-metric { + display: flex; + justify-content: space-between; + align-items: center; +} + +.wb-metric-label { + font-size: 10px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.wb-metric-value { + font-size: 11px; + color: var(--accent); + font-weight: 600; +} + +/* === Toast === */ +.wb-toast { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + padding: 8px 20px; + border-radius: 20px; + background: var(--accent); + color: var(--bg); + font-size: 12px; + font-weight: 600; + font-family: inherit; + letter-spacing: 0.5px; + z-index: 2000; + pointer-events: none; + opacity: 1; + transition: opacity 0.5s ease; +} +.wb-toast.fade-out { + opacity: 0; +} + +/* === Help Overlay === */ +.wb-help-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.75); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 20px; +} + +.wb-help-content { + background: var(--surface); + border: 1px solid var(--border-light); + border-radius: var(--radius); + max-width: 520px; + max-height: 80vh; + overflow-y: auto; + padding: 24px; + color: var(--text); +} +.wb-help-content h2 { + font-size: 18px; + margin-bottom: 12px; + color: var(--accent); +} +.wb-help-content h3 { + font-size: 14px; + margin: 16px 0 6px; + color: var(--text); +} +.wb-help-content p, .wb-help-content ol { + font-size: 13px; + color: var(--text-dim); + margin-bottom: 8px; +} +.wb-help-content ol { + padding-left: 20px; +} +.wb-help-content li { + margin-bottom: 4px; +} + +/* === Utility === */ +.hidden { + display: none !important; +} + +/* Scrollbar */ +::-webkit-scrollbar { width: 6px; } +::-webkit-scrollbar-track { background: var(--bg); } +::-webkit-scrollbar-thumb { background: var(--border-light); border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: var(--text-muted); } + +/* === Mobile: single column === */ +@media (max-width: 767px) { + .workbench { + padding: 4px; + gap: 4px; + } + + .wb-grid { + grid-template-columns: 1fr; + grid-template-rows: auto auto auto auto; + } + + .wb-canvas-panel { grid-column: 1; grid-row: 1; min-height: 220px; } + .wb-mapping-panel { grid-column: 1; grid-row: 2; } + .wb-params-panel { grid-column: 1; grid-row: 3; max-height: 300px; } + .wb-controls-panel { grid-column: 1; grid-row: 4; } + + .wb-mapping-area { + justify-content: center; + gap: 8px; + padding: 8px; + } + + #mapping-canvas { + max-width: 220px !important; + } + + .wb-title { font-size: 14px; } + .presets { display: none; } + + /* Collapsible sections on mobile */ + .wb-panel.collapsible > .wb-panel-header { + cursor: pointer; + } + .wb-panel.collapsible.collapsed > *:not(.wb-panel-header) { + max-height: 0; + overflow: hidden; + padding: 0; + border: none; + } +} + +/* === Tablet and up === */ +@media (min-width: 768px) { + .wb-canvas-panel { + min-height: 380px; + } + + .wb-mapping-area { + gap: 16px; + padding: 16px; + } +} + +/* Large screens */ +@media (min-width: 1100px) { + .wb-canvas-panel { + min-height: 450px; + } +} diff --git a/playground/css/c-journey.css b/playground/css/c-journey.css new file mode 100644 index 0000000..3c98870 --- /dev/null +++ b/playground/css/c-journey.css @@ -0,0 +1,999 @@ +/* NISPS Journey — Design C */ +/* Phases that dissolve: Explore -> Teach -> Perform */ + +* { + margin: 0; + padding: 0; + box-sizing: border-box; + -webkit-tap-highlight-color: transparent; +} + +:root { + --bg: #0d0d0d; + --glass-bg: rgba(13, 13, 13, 0.6); + --glass-border: rgba(255, 255, 255, 0.08); + --glass-blur: 16px; + --accent: #00ff88; + --accent-dim: rgba(0, 255, 136, 0.2); + --accent-glow: rgba(0, 255, 136, 0.4); + --danger: #ff4466; + --text: #e0e0e0; + --text-dim: rgba(255, 255, 255, 0.45); + --text-muted: rgba(255, 255, 255, 0.25); + --radius: 12px; + --radius-sm: 8px; + --radius-pill: 20px; + --transition: 400ms cubic-bezier(0.22, 1, 0.36, 1); + --transition-fast: 200ms ease-out; + --safe-bottom: env(safe-area-inset-bottom, 0px); +} + +html, body { + width: 100%; + height: 100%; + overflow: hidden; + background: var(--bg); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, 'SF Pro', 'Segoe UI', system-ui, sans-serif; + font-size: 14px; + touch-action: none; + user-select: none; + -webkit-user-select: none; +} + +/* ============================================================ + Canvas + ============================================================ */ +#vis-canvas { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: block; + z-index: 0; + transition: height var(--transition); +} + +.app.phase-teach #vis-canvas, +.app.phase-teach #synth-vis-canvas { + height: 40vh; +} + +/* Synth visualizer canvas */ +#synth-vis-canvas { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: block; + z-index: 0; + transition: height var(--transition); +} + +/* ============================================================ + Phase dots (top-center) + ============================================================ */ +.phase-dots { + position: fixed; + top: 12px; + left: 50%; + transform: translateX(-50%); + z-index: 100; + display: flex; + gap: 10px; + padding: 6px 14px; + background: var(--glass-bg); + backdrop-filter: blur(var(--glass-blur)); + -webkit-backdrop-filter: blur(var(--glass-blur)); + border: 1px solid var(--glass-border); + border-radius: var(--radius-pill); + transition: opacity var(--transition); +} + +.phase-dot { + width: 10px; + height: 10px; + border-radius: 50%; + border: 1.5px solid var(--text-dim); + background: transparent; + cursor: pointer; + padding: 0; + transition: all var(--transition-fast); +} + +.phase-dot.active { + background: var(--accent); + border-color: var(--accent); + box-shadow: 0 0 8px var(--accent-glow); +} + +.phase-dot:hover { + border-color: var(--text); +} + +/* ============================================================ + Heatmap strip + ============================================================ */ +.heatmap-strip { + position: fixed; + top: 40px; + left: 50%; + transform: translateX(-50%); + z-index: 90; + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + background: var(--glass-bg); + backdrop-filter: blur(var(--glass-blur)); + -webkit-backdrop-filter: blur(var(--glass-blur)); + border: 1px solid var(--glass-border); + border-radius: var(--radius-sm); + transition: opacity var(--transition); + cursor: pointer; +} + +.heatmap-cells { + display: flex; + gap: 1px; + height: 16px; +} + +.heatmap-cell { + flex: 0 0 auto; + width: 14px; + height: 16px; + background: #1a1a1a; + border-radius: 2px; + overflow: hidden; + position: relative; +} + +.heatmap-bar { + position: absolute; + left: 0; + top: 0; + height: 100%; + width: 50%; + transition: width 0.1s ease-out; + border-radius: 2px; +} + +.heatmap-strip.wide .heatmap-cell { + width: 3px; + height: 16px; +} + +.heatmap-count { + font-size: 11px; + color: var(--text-dim); + white-space: nowrap; + min-width: 16px; + text-align: right; +} + +.heatmap-tooltip { + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + margin-top: 6px; + padding: 4px 10px; + background: rgba(0, 0, 0, 0.85); + border: 1px solid var(--glass-border); + border-radius: var(--radius-sm); + font-size: 11px; + color: var(--text); + white-space: nowrap; + pointer-events: none; + opacity: 0; + transition: opacity var(--transition-fast); +} + +.heatmap-tooltip.show { + opacity: 1; +} + +/* ============================================================ + Mode pill (top-left) + ============================================================ */ +.mode-pill { + position: fixed; + top: 12px; + left: 56px; + z-index: 100; + display: flex; + background: var(--glass-bg); + backdrop-filter: blur(var(--glass-blur)); + -webkit-backdrop-filter: blur(var(--glass-blur)); + border: 1px solid var(--glass-border); + border-radius: var(--radius-pill); + overflow: hidden; + transition: opacity var(--transition); +} + +.mode-opt { + padding: 5px 14px; + font-size: 12px; + color: var(--text-dim); + background: transparent; + border: none; + cursor: pointer; + transition: all var(--transition-fast); +} + +.mode-opt.active { + color: var(--accent); + background: var(--accent-dim); +} + +/* ============================================================ + Explore phase prompts + ============================================================ */ +.explore-prompt { + position: fixed; + top: 35%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 50; + text-align: center; + pointer-events: none; + transition: opacity var(--transition); +} + +.prompt-text { + font-size: 18px; + color: var(--text-dim); + animation: fadeSlideIn 800ms ease-out forwards; + opacity: 0; + animation-delay: 1s; +} + +@keyframes fadeSlideIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +.explore-ctas { + position: fixed; + bottom: calc(180px + var(--safe-bottom)); + left: 50%; + transform: translateX(-50%); + z-index: 60; + display: flex; + gap: 12px; + transition: opacity var(--transition); +} + +.cta-pill { + padding: 10px 22px; + font-size: 14px; + color: var(--text); + background: var(--glass-bg); + backdrop-filter: blur(var(--glass-blur)); + -webkit-backdrop-filter: blur(var(--glass-blur)); + border: 1px solid var(--glass-border); + border-radius: var(--radius-pill); + cursor: pointer; + transition: all var(--transition-fast); + white-space: nowrap; +} + +.cta-pill:hover, .cta-pill:active { + border-color: var(--text-dim); +} + +.cta-pill.primary { + background: var(--accent-dim); + border-color: var(--accent); + color: var(--accent); +} + +.cta-pill.primary:hover, .cta-pill.primary:active { + background: rgba(0, 255, 136, 0.3); +} + +/* ============================================================ + Joystick + ============================================================ */ +.joystick-area { + position: fixed; + z-index: 85; + width: 150px; + height: 150px; + transition: all var(--transition); +} + +/* Phase 1: bottom-center */ +.app.phase-explore .joystick-area { + bottom: calc(24px + var(--safe-bottom)); + left: 50%; + transform: translateX(-50%); + width: 150px; + height: 150px; + opacity: 1; +} + +/* Phase 2: left side, smaller */ +.app.phase-teach .joystick-area { + bottom: auto; + top: calc(40vh + 16px); + left: 16px; + transform: none; + width: 120px; + height: 120px; + opacity: 1; +} + +/* Phase 2 RL: left side, same as examples */ +.app.phase-teach[data-teach-mode="rl"] .joystick-area { + left: 16px; + transform: none; + top: calc(40vh + 16px); +} + +/* Phase 3: ghost, bottom-center, smaller */ +.app.phase-perform .joystick-area { + bottom: calc(60px + var(--safe-bottom)); + left: 50%; + transform: translateX(-50%); + width: 120px; + height: 120px; + opacity: 0.55; +} + +.joystick-canvas { + width: 100%; + height: 100%; + border-radius: 50%; + touch-action: none; +} + +/* Noise ring for RL mode */ +.noise-ring { + position: absolute; + inset: -8px; + border-radius: 50%; + border: 2px solid var(--accent); + opacity: 0; + pointer-events: none; + transition: all var(--transition-fast); +} + +.app[data-teach-mode="rl"] .noise-ring { + opacity: 0.5; +} + +/* ============================================================ + Teach panel + ============================================================ */ +.teach-panel { + position: fixed; + top: 40vh; + left: 0; + right: 0; + bottom: 0; + z-index: 80; + background: var(--bg); + border-top: 1px solid var(--glass-border); + overflow-y: auto; + -webkit-overflow-scrolling: touch; + padding: 0 16px 24px; + opacity: 0; + pointer-events: none; + transform: translateY(20px); + transition: opacity var(--transition), transform var(--transition); +} + +.app.phase-teach .teach-panel { + opacity: 1; + pointer-events: auto; + transform: translateY(0); +} + +/* Teach tabs */ +.teach-tabs { + display: flex; + gap: 0; + margin: 12px 0; + background: rgba(255, 255, 255, 0.04); + border-radius: var(--radius-pill); + overflow: hidden; + border: 1px solid var(--glass-border); +} + +.teach-tab { + flex: 1; + padding: 8px 0; + font-size: 13px; + color: var(--text-dim); + background: transparent; + border: none; + cursor: pointer; + transition: all var(--transition-fast); +} + +.teach-tab.active { + color: var(--accent); + background: var(--accent-dim); +} + +/* Teach content sections */ +.teach-content { + transition: opacity var(--transition-fast); +} + +.teach-layout { + display: flex; + flex-direction: column; + gap: 12px; + padding-left: 136px; /* room for joystick on left */ +} + +.app.phase-teach[data-teach-mode="rl"] .teach-layout { + padding-left: 136px; +} + +/* Preset grid */ +.preset-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; +} + +.preset-thumb { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 6px; + background: rgba(255, 255, 255, 0.03); + border: 1.5px solid var(--glass-border); + border-radius: var(--radius-sm); + cursor: pointer; + transition: all var(--transition-fast); + color: var(--text-dim); + font-size: 11px; +} + +.preset-thumb.selected { + border-color: var(--accent); + background: var(--accent-dim); + color: var(--accent); +} + +.preset-thumb:hover { + border-color: var(--text-dim); +} + +.preset-canvas { + width: 56px; + height: 56px; + border-radius: 4px; + background: var(--bg); +} + +/* Teach actions */ +.teach-actions { + display: flex; + gap: 8px; +} + +/* Minimap */ +.minimap { + width: 120px; + height: 120px; + border-radius: var(--radius-sm); + background: rgba(255, 255, 255, 0.02); + border: 1px solid var(--glass-border); + align-self: center; +} + +/* Fine-tune */ +.fine-tune { + margin-top: 4px; +} + +.fine-tune summary { + font-size: 12px; + color: var(--text-dim); + cursor: pointer; + padding: 6px 0; +} + +.param-sliders { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4px 12px; + max-height: 200px; + overflow-y: auto; +} + +.param-slider-row { + display: flex; + align-items: center; + gap: 4px; +} + +.param-slider-row label { + font-size: 10px; + color: var(--text-dim); + width: 50px; + flex-shrink: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.param-slider-row input[type="range"] { + flex: 1; + height: 4px; + -webkit-appearance: none; + appearance: none; + background: rgba(255, 255, 255, 0.08); + border-radius: 2px; + outline: none; +} + +.param-slider-row input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--accent); + cursor: pointer; +} + +/* RL sub-mode */ +#teach-rl { + padding-left: 136px; +} + +.rl-hint { + text-align: center; + color: var(--text-dim); + font-size: 13px; + margin: 12px 0; +} + +.thumb-icon { + font-size: 16px; +} + +.rl-buttons { + display: flex; + justify-content: center; + gap: 40px; + margin-top: 16px; +} + +.rl-btn { + width: 64px; + height: 64px; + border-radius: 50%; + border: 2px solid var(--glass-border); + background: var(--glass-bg); + backdrop-filter: blur(var(--glass-blur)); + -webkit-backdrop-filter: blur(var(--glass-blur)); + font-size: 28px; + cursor: pointer; + transition: all var(--transition-fast); + display: flex; + align-items: center; + justify-content: center; +} + +.rl-btn.up:hover, .rl-btn.up:active { + border-color: var(--accent); + background: var(--accent-dim); +} + +.rl-btn.down:hover, .rl-btn.down:active { + border-color: var(--danger); + background: rgba(255, 68, 102, 0.15); +} + +/* Synth section */ +.synth-section { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--glass-border); + display: flex; + flex-direction: column; + gap: 8px; +} + +.synth-row { + display: flex; + align-items: center; + gap: 10px; +} + +.synth-row label { + font-size: 12px; + color: var(--text-dim); + min-width: 80px; +} + +.synth-row input[type="range"] { + flex: 1; + height: 4px; + -webkit-appearance: none; + appearance: none; + background: rgba(255, 255, 255, 0.08); + border-radius: 2px; + outline: none; +} + +.synth-row input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--accent); + cursor: pointer; +} + +.synth-row select { + flex: 1; + padding: 4px 8px; + font-size: 12px; + color: var(--text); + background: rgba(255, 255, 255, 0.06); + border: 1px solid var(--glass-border); + border-radius: var(--radius-sm); + outline: none; +} + +.synth-status { + font-size: 12px; + color: var(--text-dim); +} + +/* Teach status bar */ +.teach-status { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 0; + font-size: 12px; + color: var(--text-dim); + margin-top: 8px; + border-top: 1px solid var(--glass-border); +} + +/* Done button */ +.done-btn { + display: block; + width: 100%; + padding: 12px; + margin-top: 8px; + font-size: 14px; + color: var(--bg); + background: var(--accent); + border: none; + border-radius: var(--radius-pill); + cursor: pointer; + transition: all var(--transition-fast); +} + +.done-btn:hover, .done-btn:active { + background: #33ffaa; +} + +/* ============================================================ + Buttons (shared) + ============================================================ */ +.action-btn { + padding: 8px 16px; + font-size: 13px; + color: var(--text); + background: rgba(255, 255, 255, 0.06); + border: 1px solid var(--glass-border); + border-radius: var(--radius-pill); + cursor: pointer; + transition: all var(--transition-fast); + white-space: nowrap; +} + +.action-btn:hover, .action-btn:active { + background: rgba(255, 255, 255, 0.1); + border-color: var(--text-dim); +} + +.action-btn.primary { + background: var(--accent-dim); + border-color: var(--accent); + color: var(--accent); +} + +.action-btn.primary:hover { + background: rgba(0, 255, 136, 0.3); +} + +.action-btn.accent { + background: rgba(0, 255, 136, 0.12); + border-color: rgba(0, 255, 136, 0.3); + color: var(--accent); +} + +.action-btn.accent:hover { + background: rgba(0, 255, 136, 0.2); +} + +.action-btn.dim { + color: var(--text-dim); + border-color: transparent; + background: transparent; +} + +.action-btn.small { + padding: 4px 10px; + font-size: 11px; +} + +/* Train button pulse animation */ +.action-btn.accent.pulse { + animation: trainPulse 1.5s ease-in-out infinite; +} + +@keyframes trainPulse { + 0%, 100% { box-shadow: 0 0 0 0 var(--accent-glow); } + 50% { box-shadow: 0 0 12px 4px var(--accent-glow); } +} + +/* ============================================================ + Edit pill (Phase 3) + ============================================================ */ +.edit-pill-wrap { + position: fixed; + bottom: calc(16px + var(--safe-bottom)); + left: 50%; + transform: translateX(-50%); + z-index: 80; + opacity: 0; + pointer-events: none; + transition: opacity var(--transition); +} + +.app.phase-perform .edit-pill-wrap { + opacity: 1; + pointer-events: auto; +} + +.edit-pill { + padding: 10px 28px; + font-size: 13px; + color: var(--text); + background: var(--glass-bg); + backdrop-filter: blur(var(--glass-blur)); + -webkit-backdrop-filter: blur(var(--glass-blur)); + border: 1px solid var(--glass-border); + border-radius: var(--radius-pill); + cursor: pointer; + transition: all var(--transition-fast); +} + +.edit-pill:hover { + border-color: var(--text-dim); +} + +/* Audio control (Phase 3, synth mode) */ +.audio-control { + position: fixed; + top: 12px; + right: 12px; + z-index: 100; + display: flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + background: var(--glass-bg); + backdrop-filter: blur(var(--glass-blur)); + -webkit-backdrop-filter: blur(var(--glass-blur)); + border: 1px solid var(--glass-border); + border-radius: var(--radius-pill); + transition: opacity var(--transition); +} + +.audio-btn { + width: 28px; + height: 28px; + border-radius: 50%; + border: 1px solid var(--glass-border); + background: transparent; + color: var(--text); + font-size: 12px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.audio-vol { + width: 60px; + height: 3px; + -webkit-appearance: none; + appearance: none; + background: rgba(255, 255, 255, 0.12); + border-radius: 2px; + outline: none; +} + +.audio-vol::-webkit-slider-thumb { + -webkit-appearance: none; + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--accent); + cursor: pointer; +} + +/* ============================================================ + Phase visibility rules + ============================================================ */ + +/* Explore: show prompts, CTAs; hide teach panel, edit pill */ +.app.phase-explore .teach-panel, +.app.phase-explore .edit-pill-wrap, +.app.phase-explore .audio-control { opacity: 0; pointer-events: none; } + +/* Teach: hide prompts, CTAs, edit pill */ +.app.phase-teach .explore-prompt, +.app.phase-teach .explore-ctas, +.app.phase-teach .edit-pill-wrap, +.app.phase-teach .audio-control { opacity: 0; pointer-events: none; } + +/* Perform: hide prompts, CTAs, teach panel */ +.app.phase-perform .explore-prompt, +.app.phase-perform .explore-ctas, +.app.phase-perform .teach-panel { opacity: 0; pointer-events: none; } + +/* Perform phase: auto-fade idle elements */ +.app.phase-perform.idle .edit-pill-wrap { + opacity: 0.1; +} +.app.phase-perform.idle .heatmap-strip { + opacity: 0.15; +} +.app.phase-perform.idle .phase-dots { + opacity: 0.15; +} +.app.phase-perform.idle .mode-pill { + opacity: 0.15; +} + +/* ============================================================ + Back button + ============================================================ */ +.back-btn { + position: fixed; + top: 12px; + left: 12px; + z-index: 110; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + background: var(--glass-bg); + backdrop-filter: blur(var(--glass-blur)); + -webkit-backdrop-filter: blur(var(--glass-blur)); + border: 1px solid var(--glass-border); + border-radius: 50%; + color: var(--text); + text-decoration: none; + font-size: 18px; + line-height: 1; + transition: all var(--transition-fast); +} + +.back-btn:hover { + border-color: var(--text-dim); + background: rgba(255, 255, 255, 0.08); +} + +/* ============================================================ + RL key hints + ============================================================ */ +.rl-key-hints { + display: flex; + justify-content: center; + gap: 40px; + margin-top: 6px; +} + +.key-hint { + font-family: 'SF Mono', 'Fira Code', monospace; + font-size: 10px; + color: #555; + width: 64px; + text-align: center; +} + +/* ============================================================ + Follow mode + ============================================================ */ +.follow-indicator { + position: absolute; + top: -20px; + left: 50%; + transform: translateX(-50%); + font-size: 10px; + font-weight: 600; + letter-spacing: 1px; + color: var(--accent); + text-shadow: 0 0 8px var(--accent-glow); + pointer-events: none; +} + +.follow-pill { + position: absolute; + bottom: -24px; + left: 50%; + transform: translateX(-50%); + padding: 2px 10px; + font-size: 10px; + color: var(--text-dim); + background: var(--glass-bg); + backdrop-filter: blur(var(--glass-blur)); + -webkit-backdrop-filter: blur(var(--glass-blur)); + border: 1px solid var(--glass-border); + border-radius: var(--radius-pill); + cursor: pointer; + transition: all var(--transition-fast); + white-space: nowrap; +} + +.follow-pill:hover { + border-color: var(--text-dim); +} + +.follow-pill.active { + color: var(--accent); + border-color: var(--accent); + background: var(--accent-dim); +} + +/* ============================================================ + Hidden utility + ============================================================ */ +.hidden { + display: none !important; +} + +/* ============================================================ + Responsive + ============================================================ */ +@media (min-width: 600px) { + .teach-layout { + padding-left: 140px; + } + + .preset-grid { + grid-template-columns: repeat(3, 1fr); + max-width: 400px; + } + + .rl-buttons { + gap: 60px; + } +} + +@media (max-width: 380px) { + .preset-canvas { + width: 44px; + height: 44px; + } + + .teach-layout { + padding-left: 110px; + } + + .cta-pill { + padding: 8px 14px; + font-size: 13px; + } +} diff --git a/playground/designs.html b/playground/designs.html new file mode 100644 index 0000000..58c0ffa --- /dev/null +++ b/playground/designs.html @@ -0,0 +1,192 @@ + + + + + + NISPS Playground + + + +
+

NISPS Playground

+

Neural Interactive Shaping of Parameter Spaces

+ + + +
+
+
+ +
+
+ A +

Immersive

+

Full-screen canvas with floating controls. The visualization IS the interface — everything else gets out of the way.

+ Try it → +
+
+ +
+
+ +
+
+ B +

Workbench

+

Dashboard layout with a 2D heatmap showing what the network has learned. Built for understanding the mapping.

+ Try it → +
+
+ +
+
+ +
+
+ C +

Journey

+

Three phases — Explore, Teach, Perform — that dissolve as you master the mapping. The UI fades away after training.

+ Try it → +
+
+
+
+ + diff --git a/playground/devlog/2026-03-21-ui-redesign-explorations.md b/playground/devlog/2026-03-21-ui-redesign-explorations.md new file mode 100644 index 0000000..7db6f53 --- /dev/null +++ b/playground/devlog/2026-03-21-ui-redesign-explorations.md @@ -0,0 +1,103 @@ +# Playground UI Redesign Explorations + +**Date**: 2026-03-21 → 2026-03-22 +**Status**: Three designs built and iterated through 4 rounds of feedback. Several refinements still open. + +## Context + +The existing playground UI (`index.html`) is a single-column mobile-first layout: header, canvas, 20 parameter bars, joystick + controls. It works but has several tensions: + +- **Individual param bars dominate the screen** and are only interactive in Examples mode +- **Two orthogonal mode axes** (learning mode: examples/RL, output mode: visual/synth) live in different places with different UI patterns +- The synth side panel feels bolted on +- The "teach then perform" arc isn't surfaced — new users don't know what to do first +- Most importantly: **manually setting sliders contradicts the core thesis** — NISPS exists precisely because you *shouldn't* need to understand individual parameters + +With the C15 synth mode now at **126 parameters**, the slider approach is impossible, making the ML mapping the only viable interface. + +## Design Thesis + +The three designs explore different answers to: **if users shouldn't set params directly, what DO they interact with?** + +## Design A: "Immersive" + +**File**: `a-immersive.html` | **Philosophy**: The canvas IS the app + +The flow field fills the entire viewport. Everything else floats on top as translucent glassmorphism overlays: + +- **Floating circular joystick** with integrated training-example minimap (merged into one control) +- **Heatmap strip** at top — horizontal fill bars (like a tiny equalizer) showing output values at a glance. Hidden in synth mode since the full-screen synth visualizer serves this purpose. +- **Bottom sheet** with chevron toggle (collapsed/expanded), mode toggles always visible in a sticky floating bar +- **RL mode is default** — floating thumbs up/down buttons with key hints (1/2) built in +- **Follow mode** for trackpad-friendly exploration without holding the mouse +- **Synth mode**: full-screen parameter landscape (126 vertical bars grouped by synthesis section), interactive/draggable in examples mode, with a quick play button + hover drawer next to the back button + +**Strengths**: Feels like an instrument. Canvas gets maximum space. The synth visualizer conveys the full 126-param scale. +**Open issues**: See bd issue for remaining refinements (synth bar layout, slider interactivity, default mode). + +## Design B: "Workbench" + +**File**: `b-workbench.html` | **Philosophy**: Show the mapping, not the params + +A CSS grid dashboard (2-column on desktop, single-column with collapsible sections on mobile): + +- **Mapping heatmap** (the key innovation): a 2D canvas sampling a 20×20 grid through the network, showing the learned function as a color field. Training examples are visible dots. Click/drag anywhere to control position — the mapping IS the joystick (merged). +- **Parameter constellation**: dots in a circle, sized by value, with a count label ("20 parameters" / "126 synth parameters") +- **Semantic param groups**: collapsible cards with sparkline headers. 5 groups for visual mode, 17 groups for synth mode (covering all 126 params by synthesis section) +- **Synth mode**: replaces the flow field canvas with a parameter landscape visualization (126 grouped bars) +- **Softened heatmap colors** with grid overlay for readability + +**Strengths**: The mapping heatmap makes the learned function tangible. Param groups reduce cognitive load. +**Open issues**: Visual pill toggle reliability, heatmap color tuning. + +## Design C: "Journey" + +**File**: `c-journey.html` | **Philosophy**: The UI teaches you, then disappears + +Three phases that transition based on user actions: + +1. **Explore** (start): fullscreen canvas + floating joystick + gentle prompts. Minimal chrome. +2. **Teach** (active training): canvas shrinks to ~40%. Teaching interface with mode tabs (Examples/RL). Preset snapshot grid. Minimap of examples. Collapsible raw sliders. +3. **Perform** (post-training): canvas expands back to fullscreen. Controls fade to near-invisible after 5s inactivity. + +- **RL mode is default** teach mode with keyboard shortcuts (1/2) working in all phases +- **Heatmap strip** with horizontal fill bars, scales from 20 to 126 params +- **Synth mode**: parameter landscape visualization replacing the flow field +- **Follow mode** with double-click toggle and pill button + +**Strengths**: Maps perfectly to the real user arc. Phase 3 auto-dissolve is genuinely novel. +**Open issues**: RL mode joystick visibility, fine-tune slider style consistency. + +## Shared Architecture + +All three designs share unchanged modules: +- `js/nisps/` — MLP, IML, Dataset, Layer, Node (the ML engine) +- `js/ui/visualizer.js` — FlowFieldVisualizer (Canvas2D particle system) +- `js/synth/` — C15Bridge (WASM synth), Arpeggiator, param-map (126 params) + +Each design has its own HTML + CSS + app.js. The `designs.html` homepage links to all three with live iframe previews. + +### Common features across all designs: +- **126-output MLP** with `[32, 48, 64]` hidden layers +- **Visual mode**: first 20 outputs → flow field particle system +- **Synth mode**: all 126 outputs → SynthVisualizer (grouped bar chart) + C15 WASM engine +- **`?tame=0.7`** URL parameter for safe-range constraining of synth params +- **RL as default** learning mode with keyboard shortcuts (1=negative, 2=positive) +- **Follow mode** for trackpad-friendly exploration +- **localStorage persistence** (random on first boot, restores on refresh) +- **Presets** padded from 20 to 126 outputs with 0.5 defaults + +## Answered Questions + +- **RL mode is now the default** across all designs — it's the purer expression of the NISPS thesis +- **Network architecture scaled** from `[10, 10, 14]` (20 outputs) to `[32, 48, 64]` (126 outputs) +- **Synth mode gets its own visualization** — parameter landscape with 126 grouped bars, not the particle system +- **Snapshot teaching + fine-grained sliders coexist** — presets for quick setup, expandable raw params for power users + +## Iteration History + +1. **Initial build** (2026-03-21): Three designs from PRD, all functional +2. **Round 1**: Added back buttons, follow mode, merged joystick+minimap (A), merged joystick+mapping (B), fixed RL functions (C) +3. **Round 2**: Restored localStorage, added keyboard hints, fixed slider responsiveness (B), fixed Visual pill toggle (B), horizontal heatmap bars (A) +4. **Round 3**: Upgraded to 126 outputs + `[32, 48, 64]` MLP, added SynthVisualizer to all designs, added randomize buttons, `?tame` URL param, back button SVGs +5. **Round 4**: Homepage with live previews, synth quick-play button (A), interactive synth bars (A), heatmap hidden in synth mode (A), tame param to B+C diff --git a/playground/js/a-app.js b/playground/js/a-app.js new file mode 100644 index 0000000..bbf687e --- /dev/null +++ b/playground/js/a-app.js @@ -0,0 +1,1359 @@ +// NISPS Immersive — Design A +// Full-viewport flow field with floating overlays + +import { IML } from './nisps/iml.js'; +import { FlowFieldVisualizer } from './ui/visualizer.js'; +import { C15Bridge } from './synth/c15-bridge.js'; +import { Arpeggiator } from './synth/arpeggiator.js'; +import { SYNTH_PARAM_MAP, SYNTH_PARAM_NAMES, SYNTH_PARAM_COLORS, applyTame } from './synth/param-map.js'; + +// ---- Constants ---- +const N_INPUTS = 2; +const N_VISUAL_OUTPUTS = 20; +const N_SYNTH_OUTPUTS = SYNTH_PARAM_MAP.length; // 126 +const N_OUTPUTS = N_SYNTH_OUTPUTS; // MLP always produces full output; visual uses first 20 +const STORAGE_KEY = 'nisps-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 = [ + '#00ff88', '#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; +let visualizer; +let synthVisualizer; +let c15 = null; +let arpeggiator = null; + +let learningMode = 'rl'; +let outputMode = 'visual'; +let tameLevel = 0; +let noiseLevel = 0.05; +const rlExplorationDecay = 0.97; + +// 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 gamepadIndex = -1; +let gamepadButtonsPrev = []; +let gamepadConnected = false; +let gamepadLastAxes = [0.5, 0.5]; + +// 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 $examplesActions, $presetRow; +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: 7, color: '#44ddaa' }, + { name: 'FB Mix', count: 10, 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' }, +]; + +// ---- 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; + + // 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' }); + } + + 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); + + // Calculate section gap positions + const sectionGaps = new Set(); + let ci = 0; + for (const sec of SYNTH_SECTIONS) { + ci += sec.count; + if (ci < n) sectionGaps.add(ci); + } + + const topPad = this.topPadding * dpr; + const bottomPad = this.bottomPadding * dpr; + const totalGapPx = sectionGaps.size * 2 * dpr; + const barAreaWidth = W - totalGapPx; + const barWidth = barAreaWidth / n; + 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 }; + + // Draw bars + let x = 0; + let prevSection = this.sectionMap[0]; + let sectionStartX = 0; + // Store bar x positions for hit testing + this._barXPositions = []; + + for (let i = 0; i < n; i++) { + const sec = this.sectionMap[i]; + + // Section divider gap + if (sectionGaps.has(i)) { + // Draw section label for the previous section at top + this._drawSectionLabel(ctx, prevSection.name, sectionStartX, x, topPad, prevSection.color); + x += 2 * dpr; + sectionStartX = x; + prevSection = sec; + } + + if (i === 0) { + sectionStartX = x; + prevSection = sec; + } + + this._barXPositions[i] = x; + + 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); + } + } + + _drawSectionLabel(ctx, name, startX, endX, topPad, color) { + const dpr = window.devicePixelRatio || 1; + const fontSize = 9 * dpr; + ctx.save(); + ctx.font = `${fontSize}px -apple-system, BlinkMacSystemFont, sans-serif`; + 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 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; + const barWidth = this._layout.barWidth; + + for (let i = 0; i < n; i++) { + const bx = this._barXPositions[i]; + if (px >= bx && px < bx + barWidth) 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) => { + if (learningMode !== 'examples') return; + 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; + 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); + } + } + } +} + +// ---- 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 ---- +function init() { + // Parse ?tame URL param + const urlParams = new URLSearchParams(window.location.search); + tameLevel = parseFloat(urlParams.get('tame') ?? '0'); + + // IML — fresh random weights each boot, no state restoration + iml = new IML(N_INPUTS, N_OUTPUTS, [32, 48, 64], 1000, 1.0, 0.00001); + iml.setLogger(msg => console.log('[NISPS]', msg)); + + // 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); + + // 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'); + $examplesActions = document.getElementById('examples-actions'); + $presetRow = document.getElementById('preset-row'); + $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(); + wireQuickPlayControls(); + + // Resize + window.addEventListener('resize', onResize); + onResize(); + + // Restore saved state (if any) + loadState(); + + // 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); + + // 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() { + 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(0, 255, 136, 0.6)'; + ctx.shadowBlur = 12; + ctx.fillStyle = 'rgba(0, 255, 136, 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(0, 255, 136, 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() { + iml.setInput(0, joyX); + iml.setInput(1, joyY); + iml.process(); + + const outputs = iml.getOutputs(); + routeOutputs(outputs); + updateHeatmap(outputs); + + // Update raw param sliders if not manually editing + if (learningMode !== 'examples') { + syncRawParamsFromOutputs(outputs); + } + + // Trail + joyTrail.push({ x: joyX, y: joyY, t: Date.now() }); + if (joyTrail.length > 30) joyTrail.shift(); +} + +// ---- Output routing ---- +function routeOutputs(outputs) { + if (outputMode === 'synth') { + // Synth mode: synth visualizer + C15 + synthVisualizer.setParams(outputs); + if (c15 && c15.running) { + for (let i = 0; i < outputs.length && i < SYNTH_PARAM_MAP.length; i++) { + const tamed = applyTame(outputs[i], SYNTH_PARAM_MAP[i], tameLevel); + c15.setParameter(SYNTH_PARAM_MAP[i].id, tamed); + } + } + } else { + // Visual mode: flow field uses first 20 + visualizer.setParams(outputs.slice(0, N_VISUAL_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() { + // Learning mode toggle + document.querySelectorAll('#learning-toggle .pill-opt').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('#learning-toggle .pill-opt').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + learningMode = btn.dataset.mode; + updateModeUI(); + }); + }); + + // Output mode toggle + document.querySelectorAll('#output-toggle .pill-opt').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('#output-toggle .pill-opt').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + setOutputMode(btn.dataset.mode); + }); + }); + + // Action buttons + 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-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)); + }); + + updateModeUI(); +} + +function updateModeUI() { + if (learningMode === 'rl') { + $rlButtons.classList.remove('hidden'); + $examplesActions.style.display = 'none'; + $presetRow.style.display = 'none'; + updateNoiseRing(); + } else { + $rlButtons.classList.add('hidden'); + $examplesActions.style.display = ''; + $presetRow.style.display = ''; + $noiseRing.className = 'noise-ring'; + } + // Enable/disable synth bar interaction based on mode + if (outputMode === 'synth') { + synthVisualizer.enableInteraction(learningMode === 'examples'); + } +} + +function setOutputMode(mode) { + outputMode = mode; + buildHeatmap(); + updateHeatmap(iml.getOutputs()); + + const heatmapStrip = document.getElementById('heatmap-strip'); + const synthQuickControls = document.getElementById('synth-quick-controls'); + + 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(learningMode === 'examples'); + } 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); + } + + routeOutputs(iml.getOutputs()); + buildRawParams(); + syncRawParamsFromOutputs(iml.getOutputs()); +} + +function updateNoiseRing() { + if (learningMode !== 'rl') { + $noiseRing.className = 'noise-ring'; + return; + } + if (noiseLevel > 0.15) { + $noiseRing.className = 'noise-ring active high'; + } else if (noiseLevel > 0.03) { + $noiseRing.className = 'noise-ring active'; + } else { + $noiseRing.className = 'noise-ring'; + } +} + +// ---- Examples mode ---- +function onAddExample() { + const inputs = [joyX, joyY]; + const outputs = [...rawParamValues]; + iml.addExample(inputs, outputs); + updateStatus(); + drawJoyMap(); + flash('btn-add-example'); +} + +function onTrain() { + const loss = trainModel(); + if (loss !== null) { + const outputs = iml.getOutputs(); + routeOutputs(outputs); + updateHeatmap(outputs); + syncRawParamsFromOutputs(outputs); + updateStatus(); + drawLossPlot(); + drawJoyMap(); + flash('btn-train'); + } +} + +function onRandomize() { + iml.randomiseWeights(); + const outputs = iml.getOutputs(); + routeOutputs(outputs); + updateHeatmap(outputs); + syncRawParamsFromOutputs(outputs); + noiseLevel = 0.05; + updateStatus(); +} + +function onClear() { + iml.clearDataset(); + iml.lossHistory = []; + iml.bestLoss = null; + iml.totalTrainingIterations = 0; + noiseLevel = 0.05; + clearState(); + updateStatus(); + drawLossPlot(); + drawJoyMap(); +} + +// ---- RL mode ---- +function onThumbsUp() { + const inputs = [joyX, joyY]; + const outputs = [...iml.getOutputs()]; + iml.addExample(inputs, outputs); + + trainModel(); + + noiseLevel *= rlExplorationDecay; + noiseLevel = Math.max(noiseLevel, 0.005); + + updateStatus(); + drawLossPlot(); + drawJoyMap(); + updateNoiseRing(); + flash('btn-thumbsup'); +} + +function onThumbsDown() { + noiseLevel = Math.min(noiseLevel * 1.5, 0.3); + + iml.moveWeights(noiseLevel); + + const outputs = iml.getOutputs(); + routeOutputs(outputs); + updateHeatmap(outputs); + syncRawParamsFromOutputs(outputs); + updateStatus(); + updateNoiseRing(); + flash('btn-thumbsdown'); +} + +// ---- Training ---- +function trainModel() { + const loss = iml.train({ + onIteration: (iter, iterLoss) => { + if (iter % 8 !== 0) return; + drawLossPlot(); + }, + }); + return loss; +} + +// ---- Presets ---- +function loadPreset(name) { + const preset = PRESETS[name]; + if (!preset) return; + + iml.clearDataset(); + for (const ex of preset) { + iml.addExample(ex.input, padPresetOutputs(ex.output)); + } + + const loss = trainModel(); + const outputs = iml.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'; + } + + if (learningMode === 'rl') { + 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(0, 255, 136, 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'; + + 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); + routeOutputs(rawParamValues); + updateHeatmap(rawParamValues); + }); + + row.appendChild(label); + row.appendChild(slider); + row.appendChild(val); + $rawParams.appendChild(row); + } +} + +function syncRawParamsFromOutputs(outputs) { + rawParamValues = [...outputs]; + const sliders = $rawParams.querySelectorAll('input[type="range"]'); + sliders.forEach((s, i) => { + if (i < outputs.length) { + s.value = outputs[i]; + s.nextElementSibling.textContent = outputs[i].toFixed(2); + } + }); +} + +// ---- 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 () => { + if (c15.running) { + arpeggiator.stop(); + arpToggle.textContent = 'Play'; + await c15.stop(); + startBtn.textContent = 'Start Audio'; + } else { + await c15.start(); + startBtn.textContent = 'Stop Audio'; + routeOutputs(iml.getOutputs()); + } + }); + + volumeSlider.addEventListener('input', (e) => { + c15.setMasterVolume(parseFloat(e.target.value)); + }); + + 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; + }); + + 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; + }); +} + +// ---- Gamepad ---- +function wireGamepad() { + window.addEventListener('gamepadconnected', () => { gamepadConnected = true; }); + window.addEventListener('gamepaddisconnected', () => { gamepadConnected = false; gamepadIndex = -1; }); +} + +function pollGamepad() { + if (!navigator.getGamepads) return; + const gamepads = navigator.getGamepads(); + let gp = null; + + if (gamepadIndex >= 0 && gamepads[gamepadIndex]?.connected) { + gp = gamepads[gamepadIndex]; + } else { + gamepadIndex = -1; + for (let i = 0; i < gamepads.length; i++) { + if (gamepads[i]?.connected) { + gp = gamepads[i]; + gamepadIndex = i; + break; + } + } + } + + if (!gp) { gamepadButtonsPrev = []; return; } + + const deadzone = 0.08; + const rawX = gp.axes[0] || 0; + const rawY = gp.axes[1] || 0; + const axisX = Math.abs(rawX) < deadzone ? 0 : rawX; + const axisY = Math.abs(rawY) < deadzone ? 0 : rawY; + const mappedX = (axisX + 1) * 0.5; + const mappedY = 1 - (axisY + 1) * 0.5; + + const moved = Math.abs(mappedX - gamepadLastAxes[0]) > 0.002 || + Math.abs(mappedY - gamepadLastAxes[1]) > 0.002; + + if (moved) { + gamepadLastAxes = [mappedX, mappedY]; + joyX = mappedX; + joyY = mappedY; + drawJoyMap(); + onJoystickMove(); + } + + // LB=4, RB=5 + const lbPressed = !!gp.buttons[4]?.pressed; + const rbPressed = !!gp.buttons[5]?.pressed; + const lbPrev = !!gamepadButtonsPrev[4]; + const rbPrev = !!gamepadButtonsPrev[5]; + + if (learningMode === 'rl') { + if (rbPressed && !rbPrev) onThumbsUp(); + if (lbPressed && !lbPrev) onThumbsDown(); + } + + gamepadButtonsPrev[4] = lbPressed; + gamepadButtonsPrev[5] = rbPressed; +} + +// ---- 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; + } + } + + if (learningMode === 'rl') { + if (e.key === '1' || e.code === 'Numpad1') { + e.preventDefault(); + onThumbsDown(); + } else if (e.key === '2' || e.code === 'Numpad2') { + e.preventDefault(); + onThumbsUp(); + } + } + }); +} + +// ---- Quick play controls ---- +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 = ''; + const pauseIconSVG = ''; + + function updatePlayIcon() { + const isPlaying = c15 && c15.running; + quickPlayIcon.innerHTML = isPlaying ? pauseIconSVG : playIconSVG; + quickPlay.classList.toggle('playing', 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; + }); +} + +// ---- Resize ---- +function onResize() { + 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(); + } +} + +// ---- Animation ---- +function animate() { + pollGamepad(); + if (outputMode === 'synth') { + synthVisualizer.draw(); + } 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: iml.dataset.features, + labels: iml.dataset.labels, + noiseLevel, + learningMode, + outputMode, + joyX, + joyY, + }; + 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 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++) { + iml.addExample(state.features[i], padPresetOutputs(state.labels[i])); + } + // Retrain with restored data + trainModel(); + } + + 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 learning mode + if (state.learningMode && state.learningMode !== learningMode) { + learningMode = state.learningMode; + document.querySelectorAll('#learning-toggle .pill-opt').forEach(b => { + b.classList.toggle('active', b.dataset.mode === learningMode); + }); + updateModeUI(); + } + + // Restore output mode + if (state.outputMode && state.outputMode !== outputMode) { + setOutputMode(state.outputMode); + document.querySelectorAll('#output-toggle .pill-opt').forEach(b => { + b.classList.toggle('active', b.dataset.mode === outputMode); + }); + } + + console.log(`[NISPS] Restored ${state.features?.length || 0} 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); diff --git a/playground/js/b-app.js b/playground/js/b-app.js new file mode 100644 index 0000000..ef937fd --- /dev/null +++ b/playground/js/b-app.js @@ -0,0 +1,1204 @@ +// NISPS Workbench (Design B) — Main application +// Dashboard layout with mapping visualization, merged input space + +import { IML } from './nisps/iml.js'; +import { FlowFieldVisualizer } from './ui/visualizer.js'; +import { C15Bridge } from './synth/c15-bridge.js'; +import { Arpeggiator } from './synth/arpeggiator.js'; +import { SYNTH_PARAM_MAP, SYNTH_PARAM_NAMES, SYNTH_PARAM_COLORS, applyTame } from './synth/param-map.js'; + +// --- Constants --- +const N_INPUTS = 2; +const N_VISUAL_OUTPUTS = 20; +const N_SYNTH_OUTPUTS = SYNTH_PARAM_MAP.length; // 126 +const N_OUTPUTS = N_SYNTH_OUTPUTS; // MLP always produces full output; visual uses first 20 +const STORAGE_KEY = 'nisps-b-workbench'; + +const VISUAL_PARAM_NAMES = [ + 'Flow', 'Scale', 'Speed', 'Hue', 'Spread', 'Size', 'Trail', 'Turb', + 'Attract', 'Radius', 'DispRate', 'DispAmt', 'Lifetime', 'Respawn', + 'Advection', 'Inertia', 'Drag', 'Repulse', 'RepCnt', 'RepRate' +]; +const VISUAL_PARAM_COLORS = [ + '#00ff88', '#00ccff', '#ff6600', '#ff00cc', '#ffcc00', '#88ff00', + '#0088ff', '#ff3366', '#9bff5f', '#59d3ff', '#ff8f3f', '#a0b7ff', + '#f4ff7a', '#ffa8db', '#7dffc8', '#ffd166', '#8ad4ff', '#ff5f5f', + '#ffc15f', '#ff8a3d' +]; + +// Visual param groups: [groupName, accentColor, [indices]] +const VISUAL_GROUPS = [ + ['Motion', '--group-motion', [0, 2, 14, 15, 16]], + ['Color', '--group-color', [3, 4, 6]], + ['Particles', '--group-particles', [1, 5, 12, 13]], + ['Forces', '--group-forces', [8, 9, 7, 17, 18]], + ['Effects', '--group-effects', [10, 11, 19]], +]; + +const SYNTH_GROUPS = [ + ['Envelope A', '--group-osc', Array.from({length: 7}, (_, i) => i)], + ['Envelope B', '--group-osc', Array.from({length: 7}, (_, i) => i + 7)], + ['Envelope C', '--group-osc', Array.from({length: 6}, (_, i) => i + 14)], + ['Oscillator A','--group-osc', Array.from({length: 5}, (_, i) => i + 20)], + ['Oscillator B','--group-osc', Array.from({length: 5}, (_, i) => i + 25)], + ['Shaper A', '--group-shapers', Array.from({length: 6}, (_, i) => i + 30)], + ['Shaper B', '--group-shapers', Array.from({length: 6}, (_, i) => i + 36)], + ['Comb Filter', '--group-filters', Array.from({length: 8}, (_, i) => i + 42)], + ['SVF Filter', '--group-filters', Array.from({length: 7}, (_, i) => i + 50)], + ['FB Mixer', '--group-effects', Array.from({length: 10}, (_, i) => i + 57)], + ['Out Mixer', '--group-output', Array.from({length: 14}, (_, i) => i + 67)], + ['Cabinet', '--group-effects', Array.from({length: 8}, (_, i) => i + 81)], + ['Flanger', '--group-fx', Array.from({length: 13}, (_, i) => i + 89)], + ['Echo', '--group-fx', Array.from({length: 7}, (_, i) => i + 102)], + ['Reverb', '--group-fx', Array.from({length: 6}, (_, i) => i + 109)], + ['Unison', '--group-output', Array.from({length: 3}, (_, i) => i + 115)], + // Remaining params (118..125) if any + ...(N_SYNTH_OUTPUTS > 118 ? [['Mono/Extra', '--group-output', Array.from({length: N_SYNTH_OUTPUTS - 118}, (_, i) => i + 118)]] : []), +]; + +// Heatmap colormap: dark navy -> muted teal -> muted yellow -> muted orange +function heatmapColor(t) { + t = Math.max(0, Math.min(1, t)); + let r, g, b; + if (t < 0.25) { + // Dark navy -> muted teal + const s = t / 0.25; + r = Math.floor(8 + s * 20); + g = Math.floor(12 + s * 60); + b = Math.floor(30 + s * 55); + } else if (t < 0.5) { + // Muted teal -> muted teal-green + const s = (t - 0.25) / 0.25; + r = Math.floor(28 + s * 30); + g = Math.floor(72 + s * 50); + b = Math.floor(85 - s * 40); + } else if (t < 0.75) { + // Muted teal-green -> muted yellow + const s = (t - 0.5) / 0.25; + r = Math.floor(58 + s * 90); + g = Math.floor(122 + s * 30); + b = Math.floor(45 - s * 20); + } else { + // Muted yellow -> muted orange + const s = (t - 0.75) / 0.25; + r = Math.floor(148 + s * 30); + g = Math.floor(152 - s * 60); + b = Math.floor(25 - s * 10); + } + return `rgb(${r},${g},${b})`; +} + +// --- Tame URL param --- +const _urlParams = new URLSearchParams(window.location.search); +const tameLevel = parseFloat(_urlParams.get('tame') ?? '0'); + +// --- State --- +let iml; +let visualizer; +let learningMode = 'rl'; +let outputMode = 'visual'; +let noiseLevel = 0.05; +const rlExplorationDecay = 0.97; +let animating = true; + +// Joystick state +let joyX = 0.5, joyY = 0.5; +let joyDragging = false; + +// Follow mode +let followMode = false; + +// Synth +let c15 = null; +let arpeggiator = null; +let synthVisualizer = null; + +// Current param values (for dragging) +let paramValues = new Array(N_OUTPUTS).fill(0.5); + +// Current groups/names/colors based on output mode +let currentGroups = VISUAL_GROUPS; +let currentNames = VISUAL_PARAM_NAMES; +let currentColors = VISUAL_PARAM_COLORS; + +// DOM caches +let paramGroupsEl; +let groupElements = []; // [{el, bodyEl, indices, open}] + +// ============================ +// Pad preset outputs to N_OUTPUTS +// ============================ +function padPresetOutputs(outputs) { + const padded = new Array(N_OUTPUTS).fill(0.5); + for (let i = 0; i < outputs.length; i++) padded[i] = outputs[i]; + return padded; +} + +// ============================ +// Synth Visualizer (parameter landscape) +// ============================ +const SYNTH_SECTION_COLORS = [ + { start: 0, count: 7, color: '#4488ff', label: 'Env A' }, + { start: 7, count: 7, color: '#4488ff', label: 'Env B' }, + { start: 14, count: 6, color: '#4488ff', label: 'Env C' }, + { start: 20, count: 5, color: '#ff8844', label: 'Osc A' }, + { start: 25, count: 5, color: '#ff8844', label: 'Osc B' }, + { start: 30, count: 6, color: '#ff4466', label: 'Shp A' }, + { start: 36, count: 6, color: '#ff4466', label: 'Shp B' }, + { start: 42, count: 8, color: '#44ddaa', label: 'Comb' }, + { start: 50, count: 7, color: '#44ddaa', label: 'SVF' }, + { start: 57, count: 10, color: '#ddaa44', label: 'FB Mix' }, + { start: 67, count: 14, color: '#ddaa44', label: 'Out Mix' }, + { start: 81, count: 8, color: '#aa88dd', label: 'Cab' }, + { start: 89, count: 13, color: '#dd88aa', label: 'Flanger' }, + { start: 102, count: 7, color: '#88aadd', label: 'Echo' }, + { start: 109, count: 6, color: '#88ddaa', label: 'Reverb' }, + { start: 115, count: 11, color: '#999999', label: 'Uni/Mono' }, +]; + +class SynthVisualizer { + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.params = new Array(N_SYNTH_OUTPUTS).fill(0.5); + this.displayParams = new Array(N_SYNTH_OUTPUTS).fill(0.5); + this._animId = null; + this._resize(); + window.addEventListener('resize', () => this._resize()); + } + + _resize() { + const rect = this.canvas.parentElement.getBoundingClientRect(); + this.canvas.width = rect.width * (window.devicePixelRatio || 1); + this.canvas.height = rect.height * (window.devicePixelRatio || 1); + } + + setParams(values) { + for (let i = 0; i < this.params.length && i < values.length; i++) { + this.params[i] = values[i]; + } + } + + draw() { + const ctx = this.ctx; + const w = this.canvas.width; + const h = this.canvas.height; + const n = N_SYNTH_OUTPUTS; + + // Lerp display toward target + for (let i = 0; i < n; i++) { + this.displayParams[i] += (this.params[i] - this.displayParams[i]) * 0.15; + } + + // Background + ctx.fillStyle = '#0a0a0a'; + ctx.fillRect(0, 0, w, h); + + const labelHeight = 28 * (window.devicePixelRatio || 1); + const topPad = 8 * (window.devicePixelRatio || 1); + const barAreaH = h - labelHeight - topPad; + const totalBarW = w / n; + const barW = Math.max(1, totalBarW * 0.75); + const gap = totalBarW - barW; + + // Draw section dividers and bars + let sectionIdx = 0; + for (const section of SYNTH_SECTION_COLORS) { + const sx = section.start * totalBarW; + const sw = section.count * totalBarW; + + // Section background + ctx.fillStyle = 'rgba(255,255,255,0.02)'; + if (sectionIdx % 2 === 0) { + ctx.fillRect(sx, topPad, sw, barAreaH); + } + + // Draw bars + for (let i = section.start; i < section.start + section.count && i < n; i++) { + const val = this.displayParams[i] || 0; + const x = i * totalBarW + gap / 2; + const barH = val * barAreaH; + const y = topPad + barAreaH - barH; + + ctx.fillStyle = section.color; + ctx.globalAlpha = 0.4 + val * 0.6; + ctx.fillRect(x, y, barW, barH); + ctx.globalAlpha = 1; + } + + // Section label + const fontSize = Math.max(7, Math.min(10, sw / (section.label.length * 0.7))); + ctx.fillStyle = section.color; + ctx.globalAlpha = 0.7; + ctx.font = `${fontSize * (window.devicePixelRatio || 1)}px monospace`; + ctx.textAlign = 'center'; + ctx.fillText(section.label, sx + sw / 2, h - 6 * (window.devicePixelRatio || 1)); + ctx.globalAlpha = 1; + + sectionIdx++; + } + + // Divider lines between sections + ctx.strokeStyle = 'rgba(255,255,255,0.08)'; + ctx.lineWidth = 1; + for (const section of SYNTH_SECTION_COLORS) { + if (section.start === 0) continue; + const x = section.start * totalBarW; + ctx.beginPath(); + ctx.moveTo(x, topPad); + ctx.lineTo(x, h - labelHeight); + ctx.stroke(); + } + } +} + +// ============================ +// Mapping Heatmap (merged joystick + mapping) +// ============================ +class MappingView { + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.gridSize = 20; + this.dragging = false; + // Offscreen buffer for the heatmap so drawOverlay can re-composite cheaply + this._offscreen = document.createElement('canvas'); + this._offscreen.width = canvas.width; + this._offscreen.height = canvas.height; + this._offCtx = this._offscreen.getContext('2d'); + + // Pointer interaction for joystick input + canvas.addEventListener('pointerdown', (e) => this._onDown(e)); + canvas.addEventListener('pointermove', (e) => this._onMove(e)); + canvas.addEventListener('pointerup', (e) => this._onUp(e)); + canvas.addEventListener('pointerleave', (e) => this._onUp(e)); + canvas.addEventListener('pointercancel', (e) => this._onUp(e)); + canvas.addEventListener('dblclick', (e) => { + e.preventDefault(); + toggleFollowMode(); + }); + } + + _getPos(e) { + const rect = this.canvas.getBoundingClientRect(); + const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height)); + return [x, y]; + } + + _onDown(e) { + e.preventDefault(); + this.canvas.setPointerCapture(e.pointerId); + this.dragging = true; + joyDragging = true; + const [x, y] = this._getPos(e); + joyX = x; joyY = y; + onJoystickMove(); + this.drawOverlay(); + } + + _onMove(e) { + if (!this.dragging) return; + e.preventDefault(); + const [x, y] = this._getPos(e); + joyX = x; joyY = y; + onJoystickMove(); + this.drawOverlay(); + } + + _onUp(e) { + this.dragging = false; + joyDragging = false; + } + + update() { + const ctx = this._offCtx; + const w = this._offscreen.width; + const h = this._offscreen.height; + const gs = this.gridSize; + const cellW = w / gs; + const cellH = h / gs; + + // Sample grid through network + const savedInputs = [...iml.inputState]; + + for (let gy = 0; gy < gs; gy++) { + for (let gx = 0; gx < gs; gx++) { + const nx = (gx + 0.5) / gs; + const ny = (gy + 0.5) / gs; + + iml.setInput(0, nx); + iml.setInput(1, ny); + iml.inputUpdated = true; + iml.process(); + const out = iml.getOutputs(); + + let avg = 0; + for (let i = 0; i < out.length; i++) avg += out[i]; + avg /= out.length; + + let maxIdx = 0, maxVal = 0; + for (let i = 0; i < out.length; i++) { + if (out[i] > maxVal) { maxVal = out[i]; maxIdx = i; } + } + const hueInfluence = maxIdx / out.length; + + ctx.fillStyle = heatmapColor(avg * 0.8 + hueInfluence * 0.2); + ctx.fillRect(gx * cellW, gy * cellH, cellW + 0.5, cellH + 0.5); + } + } + + // Draw grid overlay every 5th cell + ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)'; + ctx.lineWidth = 0.5; + for (let i = 5; i < gs; i += 5) { + ctx.beginPath(); + ctx.moveTo(i * cellW, 0); + ctx.lineTo(i * cellW, h); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(0, i * cellH); + ctx.lineTo(w, i * cellH); + ctx.stroke(); + } + + // Restore inputs + iml.setInputs(savedInputs); + iml.inputUpdated = true; + iml.process(); + + this.drawOverlay(); + } + + drawOverlay() { + const ctx = this.ctx; + const w = this.canvas.width; + const h = this.canvas.height; + + // Blit cached heatmap then draw overlay on top + ctx.clearRect(0, 0, w, h); + ctx.drawImage(this._offscreen, 0, 0); + + // Training examples as bright dots + const features = iml.dataset.features; + if (features) { + for (let i = 0; i < features.length; i++) { + const f = features[i]; + if (!f || f.length < 2) continue; + const ex = f[0] * w; + const ey = f[1] * h; + ctx.fillStyle = '#fff'; + ctx.globalAlpha = 0.9; + ctx.beginPath(); + ctx.arc(ex, ey, 4, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = '#000'; + ctx.lineWidth = 1; + ctx.stroke(); + ctx.globalAlpha = 1; + } + } + + // Current joystick position as crosshair + const px = joyX * w; + const py = joyY * h; + const accent = outputMode === 'synth' ? '#ff6b35' : '#00ff88'; + + ctx.strokeStyle = accent; + ctx.lineWidth = 1; + ctx.globalAlpha = 0.7; + ctx.setLineDash([3, 3]); + ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, h); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(0, py); ctx.lineTo(w, py); ctx.stroke(); + ctx.setLineDash([]); + ctx.globalAlpha = 1; + + // Cursor dot + ctx.fillStyle = accent; + ctx.beginPath(); + ctx.arc(px, py, 6, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = '#fff'; + ctx.lineWidth = 2; + ctx.stroke(); + + // Outer ring + ctx.strokeStyle = accent; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(px, py, 10, 0, Math.PI * 2); + ctx.stroke(); + } +} + +// ============================ +// Constellation +// ============================ +function drawConstellation(canvas, values, nParams) { + const ctx = canvas.getContext('2d'); + const w = canvas.width; + const h = canvas.height; + const cx = w / 2; + const cy = h / 2; + const radius = Math.min(cx, cy) - 10; + + ctx.clearRect(0, 0, w, h); + + // Faint circle + ctx.strokeStyle = '#222'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.arc(cx, cy, radius, 0, Math.PI * 2); + ctx.stroke(); + + const n = nParams; + for (let i = 0; i < n; i++) { + const angle = (i / n) * Math.PI * 2 - Math.PI / 2; + const val = values[i] || 0; + const dotRadius = 2 + val * 4; + const x = cx + Math.cos(angle) * radius; + const y = cy + Math.sin(angle) * radius; + + const alpha = 0.2 + val * 0.8; + const color = currentColors[i % currentColors.length]; + + ctx.globalAlpha = alpha; + ctx.fillStyle = color; + ctx.beginPath(); + ctx.arc(x, y, dotRadius, 0, Math.PI * 2); + ctx.fill(); + } + ctx.globalAlpha = 1; +} + +// ============================ +// Loss Plot +// ============================ +function drawLossPlot(canvas, history) { + const ctx = canvas.getContext('2d'); + const w = canvas.width; + const h = canvas.height; + ctx.clearRect(0, 0, w, h); + + if (!history || history.length < 2) { + ctx.fillStyle = '#1a1a1a'; + ctx.fillRect(0, 0, w, h); + ctx.fillStyle = '#333'; + ctx.font = '10px monospace'; + ctx.textAlign = 'center'; + ctx.fillText('No training data', w / 2, h / 2 + 3); + return; + } + + ctx.fillStyle = '#111'; + ctx.fillRect(0, 0, w, h); + + // Find range + let maxLoss = 0; + for (const v of history) if (v > maxLoss) maxLoss = v; + if (maxLoss === 0) maxLoss = 1; + + const accent = outputMode === 'synth' ? '#ff6b35' : '#00ff88'; + + // Draw line + ctx.strokeStyle = accent; + ctx.lineWidth = 1.5; + ctx.beginPath(); + const step = w / (history.length - 1); + for (let i = 0; i < history.length; i++) { + const x = i * step; + const y = h - (history[i] / maxLoss) * (h - 4) - 2; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + + // Fill under + ctx.lineTo(w, h); + ctx.lineTo(0, h); + ctx.closePath(); + ctx.fillStyle = accent.replace(')', ', 0.1)').replace('rgb', 'rgba').replace('#', ''); + // Use hex to rgba + ctx.globalAlpha = 0.15; + ctx.fillStyle = accent; + ctx.fill(); + ctx.globalAlpha = 1; +} + +// ============================ +// Param Groups UI +// ============================ +function buildParamGroups() { + paramGroupsEl.innerHTML = ''; + groupElements = []; + + for (const [name, colorVar, indices] of currentGroups) { + const group = document.createElement('div'); + group.className = 'wb-group'; + + // Resolve CSS variable for accent color + const accentColor = getComputedStyle(document.documentElement).getPropertyValue(colorVar).trim(); + + // Header + const header = document.createElement('div'); + header.className = 'wb-group-header'; + header.innerHTML = ` +
+ ${name} + + + `; + + // Body + const body = document.createElement('div'); + body.className = 'wb-group-body'; + const paramsDiv = document.createElement('div'); + paramsDiv.className = 'wb-group-params'; + + for (const idx of indices) { + const paramName = currentNames[idx] || `P${idx}`; + const color = currentColors[idx % currentColors.length]; + + const row = document.createElement('div'); + row.className = 'wb-param'; + row.innerHTML = ` + ${paramName} +
+
+
+ 0.50 + `; + paramsDiv.appendChild(row); + } + + body.appendChild(paramsDiv); + group.appendChild(header); + group.appendChild(body); + paramGroupsEl.appendChild(group); + + // Toggle collapse + let isOpen = false; + header.addEventListener('click', () => { + isOpen = !isOpen; + group.classList.toggle('open', isOpen); + }); + + // Dragging on param tracks + paramsDiv.addEventListener('pointerdown', (e) => { + if (learningMode !== 'examples') return; + const track = e.target.closest('.wb-param-track'); + if (!track) return; + e.preventDefault(); + track.setPointerCapture(e.pointerId); + const idx = parseInt(track.dataset.idx); + const setVal = (ev) => { + const rect = track.getBoundingClientRect(); + const v = Math.max(0, Math.min(1, (ev.clientX - rect.left) / rect.width)); + paramValues[idx] = v; + updateParamBar(track, v, idx); + // Push manually-set values to visualizer and synth in real time + routeOutputs(paramValues); + }; + setVal(e); + const onMove = (ev) => { ev.preventDefault(); setVal(ev); }; + const onUp = () => { + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + }; + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + }); + + groupElements.push({ el: group, bodyEl: paramsDiv, indices, sparklineCanvas: header.querySelector('.wb-group-sparkline') }); + } +} + +function updateParamBar(trackEl, value, idx) { + const fill = trackEl.querySelector('.wb-param-fill'); + if (fill) fill.style.width = (value * 100) + '%'; + // Update value text + const valEl = trackEl.parentElement.querySelector(`.wb-param-value[data-idx="${idx}"]`); + if (valEl) valEl.textContent = value.toFixed(2); +} + +function updateAllParamBars(values) { + paramValues = [...values]; + for (const ge of groupElements) { + let sum = 0; + for (const idx of ge.indices) { + const val = values[idx] || 0; + sum += val; + const track = ge.bodyEl.querySelector(`.wb-param-track[data-idx="${idx}"]`); + if (track) updateParamBar(track, val, idx); + } + + // Update sparkline + drawGroupSparkline(ge.sparklineCanvas, ge.indices, values); + } +} + +function drawGroupSparkline(canvas, indices, values) { + const ctx = canvas.getContext('2d'); + const w = canvas.width; + const h = canvas.height; + ctx.clearRect(0, 0, w, h); + + // Background + ctx.fillStyle = '#1a1a1a'; + ctx.fillRect(0, 0, w, h); + + const n = indices.length; + const barW = w / n; + for (let i = 0; i < n; i++) { + const val = values[indices[i]] || 0; + const barH = val * h; + const color = currentColors[indices[i] % currentColors.length]; + ctx.fillStyle = color; + ctx.globalAlpha = 0.7; + ctx.fillRect(i * barW, h - barH, barW - 0.5, barH); + } + ctx.globalAlpha = 1; +} + +// ============================ +// Routing +// ============================ +function routeOutputs(outputs) { + if (outputMode === 'visual') { + visualizer.setParams(outputs.slice(0, N_VISUAL_OUTPUTS)); + } else { + if (synthVisualizer) synthVisualizer.setParams(outputs); + if (c15 && c15.running) { + for (let i = 0; i < outputs.length && i < SYNTH_PARAM_MAP.length; i++) { + const tamed = applyTame(outputs[i], SYNTH_PARAM_MAP[i], tameLevel); + c15.setParameter(SYNTH_PARAM_MAP[i].id, tamed); + } + } + } +} + +// ============================ +// Application logic +// ============================ +let mappingView; +let lossCanvas; +let constellationCanvas; + +function onJoystickMove() { + iml.setInput(0, joyX); + iml.setInput(1, joyY); + iml.process(); + + const outputs = iml.getOutputs(); + routeOutputs(outputs); + + // In examples mode, only update bars from network if user isn't dragging + if (learningMode !== 'examples') { + updateAllParamBars(outputs); + } else { + // Still update param values for display but allow overrides + // Only update non-dragged params + updateAllParamBars(outputs); + } + + const constellationN = outputMode === 'synth' ? N_SYNTH_OUTPUTS : N_VISUAL_OUTPUTS; + drawConstellation(constellationCanvas, outputs, constellationN); + updateMetrics(); + + // Redraw mapping overlay (cursor position) - cheap + mappingView.drawOverlay(); +} + +function onAddExample() { + const inputs = [joyX, joyY]; + const outputs = [...paramValues]; + iml.addExample(inputs, outputs); + updateMetrics(); + mappingView.update(); + flash('btn-add'); +} + +function onTrain() { + const loss = trainModel(); + if (loss !== null) { + const outputs = iml.getOutputs(); + routeOutputs(outputs); + updateAllParamBars(outputs); + const constellationN = outputMode === 'synth' ? N_SYNTH_OUTPUTS : N_VISUAL_OUTPUTS; + drawConstellation(constellationCanvas, outputs, constellationN); + drawLossPlot(lossCanvas, iml.lossHistory); + mappingView.update(); + updateMetrics(); + flash('btn-train'); + } +} + +function onRandomize() { + iml.randomiseWeights(); + const outputs = iml.getOutputs(); + routeOutputs(outputs); + updateAllParamBars(outputs); + const constellationN = outputMode === 'synth' ? N_SYNTH_OUTPUTS : N_VISUAL_OUTPUTS; + drawConstellation(constellationCanvas, outputs, constellationN); + noiseLevel = 0.05; + mappingView.update(); + updateMetrics(); +} + +function onClear() { + iml.clearDataset(); + iml.lossHistory = []; + iml.bestLoss = null; + iml.totalTrainingIterations = 0; + noiseLevel = 0.05; + clearState(); + drawLossPlot(lossCanvas, []); + mappingView.update(); + updateMetrics(); +} + +function onThumbsUp() { + const inputs = [joyX, joyY]; + const outputs = [...iml.getOutputs()]; + iml.addExample(inputs, outputs); + trainModel(); + noiseLevel *= rlExplorationDecay; + noiseLevel = Math.max(noiseLevel, 0.005); + drawLossPlot(lossCanvas, iml.lossHistory); + mappingView.update(); + updateMetrics(); + flash('btn-thumbsup'); +} + +function onThumbsDown() { + noiseLevel = Math.min(noiseLevel * 1.5, 0.3); + iml.moveWeights(noiseLevel); + const outputs = iml.getOutputs(); + routeOutputs(outputs); + updateAllParamBars(outputs); + const constellationN = outputMode === 'synth' ? N_SYNTH_OUTPUTS : N_VISUAL_OUTPUTS; + drawConstellation(constellationCanvas, outputs, constellationN); + mappingView.update(); + updateMetrics(); + flash('btn-thumbsdown'); +} + +function trainModel() { + let lastPlotUpdate = 0; + const loss = iml.train({ + onIteration: (iter, iterLoss) => { + if (iter - lastPlotUpdate < 8) return; + lastPlotUpdate = iter; + drawLossPlot(lossCanvas, [...iml.lossHistory, iterLoss]); + }, + }); + return loss; +} + +function updateMetrics() { + const el = (id) => document.getElementById(id); + el('metric-examples').textContent = iml.exampleCount; + el('metric-loss').textContent = iml.lastLoss !== null ? iml.lastLoss.toFixed(6) : '\u2014'; + el('metric-noise').textContent = noiseLevel.toFixed(3); + el('metric-joy').textContent = `${joyX.toFixed(2)}, ${joyY.toFixed(2)}`; +} + +function flash(id) { + const el = document.getElementById(id); + if (!el) return; + el.classList.add('flash'); + setTimeout(() => el.classList.remove('flash'), 200); +} + +// ============================ +// Toast notifications +// ============================ +function showToast(message) { + const toast = document.getElementById('toast'); + if (!toast) return; + toast.textContent = message; + toast.classList.remove('hidden', 'fade-out'); + // Clear any previous timeout + if (toast._timeout) clearTimeout(toast._timeout); + if (toast._fadeTimeout) clearTimeout(toast._fadeTimeout); + toast._timeout = setTimeout(() => { + toast.classList.add('fade-out'); + toast._fadeTimeout = setTimeout(() => { + toast.classList.add('hidden'); + toast.classList.remove('fade-out'); + }, 500); + }, 1500); +} + +// ============================ +// Follow mode +// ============================ +function toggleFollowMode() { + followMode = !followMode; + const badge = document.getElementById('follow-badge'); + const toggleBtn = document.getElementById('follow-toggle-btn'); + const panel = document.querySelector('.wb-mapping-panel'); + + if (followMode) { + badge.classList.remove('hidden'); + toggleBtn.classList.add('active'); + panel.classList.add('follow-active'); + showToast('Follow mode ON'); + } else { + badge.classList.add('hidden'); + toggleBtn.classList.remove('active'); + panel.classList.remove('follow-active'); + showToast('Follow mode OFF'); + } +} + +function onFollowPointerMove(e) { + if (!followMode) return; + // Map screen coordinates to 0-1 + const x = Math.max(0, Math.min(1, e.clientX / window.innerWidth)); + const y = Math.max(0, Math.min(1, e.clientY / window.innerHeight)); + joyX = x; + joyY = y; + onJoystickMove(); +} + +// ============================ +// Output mode switching +// ============================ +function setOutputMode(mode) { + outputMode = mode; + const badge = document.getElementById('mode-badge'); + const synthControls = document.getElementById('synth-controls'); + const presetsVisual = document.getElementById('presets-visual'); + const label = document.getElementById('constellation-label'); + const visualCanvas = document.getElementById('visual-canvas'); + const synthVisCanvas = document.getElementById('synth-vis-canvas'); + + if (mode === 'synth') { + document.body.classList.add('synth-mode'); + badge.textContent = 'Synth'; + badge.classList.add('synth'); + synthControls.classList.remove('hidden'); + if (presetsVisual) presetsVisual.style.display = 'none'; + currentGroups = SYNTH_GROUPS; + currentNames = SYNTH_PARAM_NAMES; + currentColors = SYNTH_PARAM_COLORS; + label.textContent = `${N_SYNTH_OUTPUTS} synth parameters`; + // Show synth canvas, hide visual canvas + visualCanvas.classList.add('hidden'); + synthVisCanvas.classList.remove('hidden'); + } else { + document.body.classList.remove('synth-mode'); + badge.textContent = 'Visual'; + badge.classList.remove('synth'); + synthControls.classList.add('hidden'); + if (presetsVisual) presetsVisual.style.display = ''; + currentGroups = VISUAL_GROUPS; + currentNames = VISUAL_PARAM_NAMES; + currentColors = VISUAL_PARAM_COLORS; + label.textContent = `${N_VISUAL_OUTPUTS} parameters`; + // Show visual canvas, hide synth canvas + visualCanvas.classList.remove('hidden'); + synthVisCanvas.classList.add('hidden'); + } + + // Toggle button state + document.getElementById('toggle-visual').classList.toggle('active', mode === 'visual'); + document.getElementById('toggle-synth').classList.toggle('active', mode === 'synth'); + + buildParamGroups(); + const outputs = iml.getOutputs(); + updateAllParamBars(outputs); + routeOutputs(outputs); + const constellationN = mode === 'synth' ? N_SYNTH_OUTPUTS : N_VISUAL_OUTPUTS; + drawConstellation(constellationCanvas, outputs, constellationN); + mappingView.update(); +} + +// ============================ +// Learning mode switching +// ============================ +function setLearningMode(mode) { + learningMode = mode; + document.getElementById('mode-examples').classList.toggle('active', mode === 'examples'); + document.getElementById('mode-rl').classList.toggle('active', mode === 'rl'); + document.getElementById('examples-controls').classList.toggle('hidden', mode !== 'examples'); + document.getElementById('rl-controls').classList.toggle('hidden', mode !== 'rl'); +} + +// ============================ +// Presets +// ============================ +function loadPreset(name) { + iml.clearDataset(); + + if (name === 'calm-to-chaotic') { + iml.addExample([0.1, 0.9], padPresetOutputs([0.25, 0.3, 0.1, 0.55, 0.2, 0.3, 0.02, 0.05, 0.9, 0.45, 0.25, 0.2, 0.9, 0.0, 0.0, 0.2, 0.05, 0.0, 0.0, 0.2])); + iml.addExample([0.9, 0.1], padPresetOutputs([0.75, 0.7, 0.9, 0.05, 0.8, 0.7, 0.9, 0.95, 0.3, 0.2, 0.85, 0.7, 0.25, 0.55, 1.0, 0.92, 0.02, 0.95, 0.8, 0.85])); + iml.addExample([0.5, 0.5], padPresetOutputs([0.5, 0.5, 0.5, 0.3, 0.5, 0.5, 0.4, 0.5, 0.7, 0.6, 0.5, 0.45, 0.55, 0.9, 0.5, 0.65, 0.08, 0.45, 0.4, 0.5])); + } else if (name === 'rainbow-sweep') { + iml.addExample([0.0, 0.5], padPresetOutputs([0.5, 0.5, 0.4, 0.0, 0.3, 0.4, 0.05, 0.3, 0.8, 0.55, 0.4, 0.3, 0.8, 0.0, 0.0, 0.45, 0.08, 0.2, 0.25, 0.45])); + iml.addExample([0.5, 0.5], padPresetOutputs([0.5, 0.5, 0.4, 0.5, 0.3, 0.4, 0.05, 0.3, 0.8, 0.55, 0.55, 0.35, 0.7, 0.0, 0.4, 0.45, 0.08, 0.45, 0.4, 0.6])); + iml.addExample([1.0, 0.5], padPresetOutputs([0.5, 0.5, 0.4, 1.0, 0.3, 0.4, 0.05, 0.3, 0.8, 0.55, 0.75, 0.45, 0.6, 0.0, 0.8, 0.45, 0.08, 0.7, 0.55, 0.75])); + } else if (name === 'vortex') { + iml.addExample([0.5, 0.5], padPresetOutputs([0.0, 0.8, 0.8, 0.6, 0.1, 0.15, 0.02, 1.0, 1.0, 0.3, 0.95, 0.85, 0.25, 1.0, 0.5, 0.95, 0.01, 1.0, 1.0, 1.0])); + iml.addExample([0.0, 0.0], padPresetOutputs([0.5, 0.2, 0.3, 0.8, 0.9, 0.6, 0.08, 0.1, 0.35, 0.8, 0.25, 0.15, 0.8, 0.5, 0.2, 0.35, 0.2, 0.15, 0.2, 0.25])); + iml.addExample([1.0, 1.0], padPresetOutputs([0.5, 0.2, 0.3, 0.2, 0.9, 0.6, 0.08, 0.1, 0.35, 0.8, 0.25, 0.15, 0.8, 0.5, 0.8, 0.35, 0.2, 0.15, 0.2, 0.25])); + iml.addExample([0.0, 1.0], padPresetOutputs([0.3, 0.4, 0.5, 0.4, 0.5, 0.4, 0.05, 0.5, 0.65, 0.5, 0.55, 0.45, 0.45, 0.2, 0.4, 0.7, 0.1, 0.5, 0.4, 0.55])); + iml.addExample([1.0, 0.0], padPresetOutputs([0.7, 0.4, 0.5, 0.0, 0.5, 0.4, 0.05, 0.5, 0.65, 0.5, 0.55, 0.45, 0.45, 0.2, 0.9, 0.7, 0.1, 0.5, 0.4, 0.55])); + } + + const loss = trainModel(); + const outputs = iml.getOutputs(); + routeOutputs(outputs); + updateAllParamBars(outputs); + const constellationN = outputMode === 'synth' ? N_SYNTH_OUTPUTS : N_VISUAL_OUTPUTS; + drawConstellation(constellationCanvas, outputs, constellationN); + drawLossPlot(lossCanvas, iml.lossHistory); + mappingView.update(); + updateMetrics(); +} + +// ============================ +// Synth controls +// ============================ +function initSynthControls() { + const startBtn = document.getElementById('synth-start'); + const volumeSlider = document.getElementById('synth-volume'); + const arpToggleBtn = document.getElementById('arp-toggle'); + const arpProgression = document.getElementById('arp-progression'); + const arpTempo = document.getElementById('arp-tempo'); + const arpOctaves = document.getElementById('arp-octaves'); + const arpOffset = document.getElementById('arp-offset'); + + startBtn.addEventListener('click', async () => { + if (c15.running) { + arpeggiator.stop(); + arpToggleBtn.textContent = 'Arp: Play'; + await c15.stop(); + startBtn.textContent = 'Start Audio'; + } else { + await c15.start(); + startBtn.textContent = 'Stop Audio'; + routeOutputs(iml.getOutputs()); + } + }); + + volumeSlider.addEventListener('input', (e) => { + c15.setMasterVolume(parseFloat(e.target.value)); + }); + + arpToggleBtn.addEventListener('click', () => { + if (!c15.running) return; + if (arpeggiator.playing) { + arpeggiator.stop(); + arpToggleBtn.textContent = 'Arp: Play'; + } else { + arpeggiator.start(); + arpToggleBtn.textContent = 'Arp: Stop'; + } + }); + + arpProgression.addEventListener('change', (e) => { + arpeggiator.progression = e.target.value; + }); + + arpTempo.addEventListener('input', (e) => { + const val = parseInt(e.target.value); + arpeggiator.bpm = val; + document.getElementById('tempo-val').textContent = val; + }); + + arpOctaves.addEventListener('input', (e) => { + const val = parseInt(e.target.value); + arpeggiator.octaves = val; + document.getElementById('octaves-val').textContent = val; + }); + + arpOffset.addEventListener('input', (e) => { + const val = parseInt(e.target.value); + arpeggiator.octaveOffset = val; + document.getElementById('offset-val').textContent = val; + }); +} + +// ============================ +// localStorage persistence +// ============================ +function saveState() { + try { + const features = iml.dataset.features; + const labels = iml.dataset.labels; + if (!features || features.length === 0) return; + const data = { features, labels }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); + } catch (e) { + console.warn('[NISPS] saveState failed:', e); + } +} + +function loadState() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return false; + const data = JSON.parse(raw); + if (!data.features || !data.labels || data.features.length === 0) return false; + for (let i = 0; i < data.features.length; i++) { + // Pad old saves (20 outputs) to current N_OUTPUTS with 0.5 defaults + let labels = data.labels[i]; + if (labels.length < N_OUTPUTS) { + labels = padPresetOutputs(labels); + } + iml.addExample(data.features[i], labels); + } + trainModel(); + return true; + } catch (e) { + console.warn('[NISPS] loadState failed:', e); + return false; + } +} + +function clearState() { + try { + localStorage.removeItem(STORAGE_KEY); + } catch (e) { + console.warn('[NISPS] clearState failed:', e); + } +} + +// ============================ +// Keyboard shortcuts +// ============================ +window.addEventListener('keydown', (e) => { + if (e.repeat) return; + // RL shortcuts: 1=negative, 2=positive + if (learningMode === 'rl') { + if (e.key === '1' || e.code === 'Numpad1') { e.preventDefault(); onThumbsDown(); } + if (e.key === '2' || e.code === 'Numpad2') { e.preventDefault(); onThumbsUp(); } + } + // Follow mode toggle: f + if (e.key === 'f' || e.key === 'F') { + // Don't toggle if user is typing in an input + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return; + e.preventDefault(); + toggleFollowMode(); + } +}); + +// ============================ +// Animation +// ============================ +function animate() { + if (!animating) return; + if (outputMode === 'visual') { + visualizer.draw(); + } else if (synthVisualizer) { + synthVisualizer.draw(); + } + requestAnimationFrame(animate); +} + +// ============================ +// Init +// ============================ +function init() { + // IML — random weights, then restore saved data if available + iml = new IML(N_INPUTS, N_OUTPUTS, [32, 48, 64], 1000, 1.0, 0.00001); + iml.setLogger(msg => console.log('[NISPS]', msg)); + + // Flow field visualizer + const visCanvas = document.getElementById('visual-canvas'); + visualizer = new FlowFieldVisualizer(visCanvas); + + // Synth parameter landscape visualizer + const synthVisCanvas = document.getElementById('synth-vis-canvas'); + synthVisualizer = new SynthVisualizer(synthVisCanvas); + + // Mapping view (merged joystick + heatmap) + mappingView = new MappingView(document.getElementById('mapping-canvas')); + + // Canvases + lossCanvas = document.getElementById('loss-canvas'); + constellationCanvas = document.getElementById('constellation-canvas'); + + // Param groups + paramGroupsEl = document.getElementById('param-groups'); + buildParamGroups(); + + // Controls wiring + document.getElementById('btn-add').addEventListener('click', onAddExample); + document.getElementById('btn-train').addEventListener('click', onTrain); + document.getElementById('btn-randomize').addEventListener('click', onRandomize); + document.getElementById('btn-clear').addEventListener('click', onClear); + document.getElementById('btn-thumbsup').addEventListener('click', onThumbsUp); + document.getElementById('btn-thumbsdown').addEventListener('click', onThumbsDown); + document.getElementById('btn-randomize-rl').addEventListener('click', onRandomize); + + // Learning mode — RL is default + document.getElementById('mode-examples').addEventListener('click', () => setLearningMode('examples')); + document.getElementById('mode-rl').addEventListener('click', () => setLearningMode('rl')); + + // Output mode + document.getElementById('toggle-visual').addEventListener('click', () => setOutputMode('visual')); + document.getElementById('toggle-synth').addEventListener('click', () => setOutputMode('synth')); + + // Follow mode toggle button + document.getElementById('follow-toggle-btn').addEventListener('click', () => toggleFollowMode()); + + // Follow mode: global pointer move + window.addEventListener('pointermove', onFollowPointerMove); + + // Presets + document.querySelectorAll('.preset-pill[data-preset]').forEach(btn => { + btn.addEventListener('click', () => loadPreset(btn.dataset.preset)); + }); + + // Help + const helpBtn = document.getElementById('help-btn'); + const helpOverlay = document.getElementById('help-overlay'); + if (helpBtn && helpOverlay) { + helpBtn.addEventListener('click', () => helpOverlay.classList.toggle('hidden')); + helpOverlay.addEventListener('click', () => helpOverlay.classList.add('hidden')); + } + + // Synth + c15 = new C15Bridge(); + c15.onStatusChange = (msg) => { + const el = document.getElementById('synth-status'); + if (el) el.textContent = msg; + }; + c15.loadParams(); + arpeggiator = new Arpeggiator(c15); + initSynthControls(); + + // Resize + window.addEventListener('resize', () => { + visualizer.resize(); + visualizer.initParticles(); + if (synthVisualizer) synthVisualizer._resize(); + }); + + // Restore saved data if available + const restored = loadState(); + + // Initial inference + iml.setInput(0, 0.5); + iml.setInput(1, 0.5); + iml.process(); + const outputs = iml.getOutputs(); + visualizer.setParams(outputs.slice(0, N_VISUAL_OUTPUTS)); + updateAllParamBars(outputs); + drawConstellation(constellationCanvas, outputs, N_VISUAL_OUTPUTS); + drawLossPlot(lossCanvas, restored ? iml.lossHistory : []); + mappingView.update(); + updateMetrics(); + + // Auto-save every 10 seconds + setInterval(saveState, 10000); + + // Start animation + animate(); +} + +document.addEventListener('DOMContentLoaded', init); diff --git a/playground/js/c-app.js b/playground/js/c-app.js new file mode 100644 index 0000000..f0c2c15 --- /dev/null +++ b/playground/js/c-app.js @@ -0,0 +1,1158 @@ +// NISPS Journey — Design C +// Phases that dissolve: Explore -> Teach -> Perform + +import { IML } from './nisps/iml.js'; +import { FlowFieldVisualizer } from './ui/visualizer.js'; +import { C15Bridge } from './synth/c15-bridge.js'; +import { Arpeggiator } from './synth/arpeggiator.js'; +import { SYNTH_PARAM_MAP, SYNTH_PARAM_NAMES, SYNTH_PARAM_COLORS, applyTame } from './synth/param-map.js'; + +// ============================================================ +// Constants +// ============================================================ +const N_INPUTS = 2; +const N_VISUAL_OUTPUTS = 20; +const N_SYNTH_OUTPUTS = SYNTH_PARAM_MAP.length; // 126 +const N_OUTPUTS = N_SYNTH_OUTPUTS; +const STORAGE_KEY = 'nisps-c-journey'; +const IDLE_TIMEOUT = 5000; +const TWO_PI = Math.PI * 2; + +const VISUAL_PARAM_NAMES = [ + 'Flow', 'Scale', 'Speed', 'Hue', 'Spread', 'Size', 'Trail', 'Turb', + 'Attract', 'Radius', 'DispRate', 'DispAmt', 'Lifetime', 'Respawn', + 'Advection', 'Inertia', 'Drag', 'Repulse', 'RepCnt', 'RepRate', +]; +const VISUAL_PARAM_COLORS = [ + '#00ff88', '#00ccff', '#ff6600', '#ff00cc', '#ffcc00', '#88ff00', + '#0088ff', '#ff3366', '#9bff5f', '#59d3ff', '#ff8f3f', '#a0b7ff', + '#f4ff7a', '#ffa8db', '#7dffc8', '#ffd166', '#8ad4ff', '#ff5f5f', + '#ffc15f', '#ff8a3d', +]; + +// 6 preset output arrays — each produces a distinct visual state +const PRESETS = [ + { name: 'Calm', synth: 'Soft Pad', + outputs: [0.1, 0.3, 0.15, 0.55, 0.2, 0.35, 0.02, 0.1, 0.5, 0.5, 0.3, 0.1, 0.8, 0.0, 0.0, 0.7, 0.05, 0.0, 0.0, 0.2] }, + { name: 'Storm', synth: 'Bright FM', + outputs: [0.7, 0.7, 0.9, 0.1, 0.9, 0.6, 0.12, 0.95, 1.0, 0.8, 0.8, 0.7, 0.3, 0.33, 0.15, 0.3, 0.15, 0.6, 0.4, 0.9] }, + { name: 'Spiral', synth: 'Resonant', + outputs: [0.5, 0.5, 0.5, 0.3, 0.4, 0.3, 0.04, 0.3, 0.9, 0.6, 0.5, 0.3, 0.6, 0.0, 0.5, 0.6, 0.08, 0.0, 0.0, 0.5] }, + { name: 'Pulse', synth: 'Pluck', + outputs: [0.0, 0.4, 0.7, 0.8, 0.5, 0.5, 0.08, 0.5, 0.3, 0.3, 4.0/8.2, 1.0, 0.15, 0.67, 0.3, 0.1, 0.02, 0.2, 0.2, 0.3] }, + { name: 'Drift', synth: 'Dark Pad', + outputs: [0.2, 0.2, 0.1, 0.7, 0.15, 0.2, 0.015, 0.05, 0.4, 0.9, 0.15, 0.05, 0.95, 0.0, 0.05, 0.95, 0.01, 0.0, 0.0, 0.1] }, + { name: 'Swarm', synth: 'Metallic', + outputs: [0.9, 0.8, 0.8, 0.45, 0.7, 0.2, 0.1, 0.8, 0.6, 0.4, 0.6, 0.5, 0.2, 0.33, 0.2, 0.15, 0.2, 0.9, 0.9, 0.95] }, +]; + +// --- Tame URL param --- +const _urlParams = new URLSearchParams(window.location.search); +const tameLevel = parseFloat(_urlParams.get('tame') ?? '0'); + +// ============================================================ +// State +// ============================================================ +let iml; +let visualizer; +let c15 = null; +let arpeggiator = null; + +let phase = 'explore'; // 'explore' | 'teach' | 'perform' +let outputMode = 'visual'; // 'visual' | 'synth' +let teachMode = 'rl'; // 'examples' | 'rl' +let selectedPreset = -1; +let noiseLevel = 0.05; +let animating = true; +let joystickX = 0.5; +let joystickY = 0.5; +let joystickActive = false; +let joystickInteractionTime = 0; +let ctasShown = false; +let rlHintShown = false; +let idleTimer = null; +let currentParamNames = VISUAL_PARAM_NAMES; +let currentParamColors = VISUAL_PARAM_COLORS; + +// Synth visualizer +let synthVisualizer = null; + +// Follow mode +let followMode = false; + +// Joystick internal state +let joyDragging = false; +let joyKnobX = 0.5; +let joyKnobY = 0.5; + +// DOM refs +let app, visCanvas; + +// ============================================================ +// Synth sections for visualizer +// ============================================================ +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: 7, color: '#44ddaa' }, + { name: 'FB Mix', count: 10, 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' }, +]; + +// ============================================================ +// Pad outputs helper +// ============================================================ +function padOutputs(arr) { + const padded = new Array(N_OUTPUTS).fill(0.5); + for (let i = 0; i < arr.length; i++) padded[i] = arr[i]; + return padded; +} + +// ============================================================ +// SynthVisualizer — parameter landscape +// ============================================================ +class SynthVisualizer { + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.targets = new Array(N_SYNTH_OUTPUTS).fill(0.5); + this.current = new Array(N_SYNTH_OUTPUTS).fill(0.5); + this.resize(); + } + + resize() { + const dpr = window.devicePixelRatio || 1; + const rect = this.canvas.getBoundingClientRect(); + this.canvas.width = rect.width * dpr; + this.canvas.height = rect.height * dpr; + this.ctx.scale(dpr, dpr); + this.w = rect.width; + this.h = rect.height; + } + + setParams(outputs) { + for (let i = 0; i < outputs.length && i < this.targets.length; i++) { + this.targets[i] = Math.max(0, Math.min(1, outputs[i])); + } + } + + draw() { + const { ctx, w, h, current, targets } = this; + const dpr = window.devicePixelRatio || 1; + ctx.save(); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + + // Clear + ctx.fillStyle = '#0d0d0d'; + ctx.fillRect(0, 0, w, h); + + // Lerp current toward targets + for (let i = 0; i < current.length; i++) { + current[i] += (targets[i] - current[i]) * 0.12; + } + + const totalParams = N_SYNTH_OUTPUTS; + const margin = 40; + const bottomMargin = 24; + const topMargin = 12; + const barAreaW = w - margin * 2; + const barAreaH = h - topMargin - bottomMargin; + const gapBetweenSections = 2; + const totalGaps = (SYNTH_SECTIONS.length - 1) * gapBetweenSections; + const barWidth = Math.max(1, (barAreaW - totalGaps) / totalParams); + + let x = margin; + let paramIdx = 0; + + for (const section of SYNTH_SECTIONS) { + // Section label + const sectionWidth = barWidth * section.count; + ctx.fillStyle = 'rgba(255, 255, 255, 0.25)'; + ctx.font = '9px -apple-system, sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(section.name, x + sectionWidth / 2, h - 6); + + // Section divider line at top + ctx.strokeStyle = 'rgba(255, 255, 255, 0.06)'; + ctx.lineWidth = 0.5; + ctx.beginPath(); + ctx.moveTo(x, topMargin); + ctx.lineTo(x, h - bottomMargin); + ctx.stroke(); + + // Draw bars + for (let j = 0; j < section.count && paramIdx < totalParams; j++, paramIdx++) { + const val = current[paramIdx]; + const barH = val * barAreaH; + const barY = topMargin + barAreaH - barH; + + // Bar fill + ctx.fillStyle = section.color; + ctx.globalAlpha = 0.6 + val * 0.4; + ctx.fillRect(x, barY, Math.max(1, barWidth - 0.5), barH); + ctx.globalAlpha = 1; + + x += barWidth; + } + x += gapBetweenSections; + } + + ctx.restore(); + } +} + +// ============================================================ +// Init +// ============================================================ +function init() { + app = document.getElementById('app'); + visCanvas = document.getElementById('vis-canvas'); + + iml = new IML(N_INPUTS, N_OUTPUTS, [32, 48, 64], 1000, 1.0, 0.00001); + iml.setLogger(msg => console.log('[NISPS]', msg)); + + visualizer = new FlowFieldVisualizer(visCanvas); + + // Synth visualizer + const 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); + + // Build heatmap cells + buildHeatmap(); + + // Build param sliders + buildParamSliders(); + + // Render preset thumbnails + renderPresetThumbnails(); + + // Wire events + wirePhaseNav(); + wireModeToggle(); + wireTeachTabs(); + wirePresetGrid(); + wireTeachActions(); + wireRLButtons(); + wireSynthControls(); + wirePerformUI(); + wireJoystick(); + wireHeatmapTooltip(); + + // Keyboard shortcuts for RL (work in all phases) + window.addEventListener('keydown', (e) => { + if (e.repeat) return; + if (e.key === '1' || e.code === 'Numpad1') { + e.preventDefault(); + onThumbsDown(); + } + if (e.key === '2' || e.code === 'Numpad2') { + e.preventDefault(); + onThumbsUp(); + } + }); + + // Follow mode wiring + wireFollowMode(); + + // Resize + window.addEventListener('resize', onResize); + + // Try to load saved state + const loaded = loadState(); + if (loaded) { + setPhase('perform'); + } else { + setPhase('explore'); + } + + // Initial inference + iml.setInput(0, 0.5); + iml.setInput(1, 0.5); + iml.process(); + routeOutputs(iml.getOutputs()); + + // Start animation + animate(); +} + +// ============================================================ +// Phase management +// ============================================================ +function setPhase(p) { + phase = p; + app.className = `app phase-${p}`; + if (teachMode) app.dataset.teachMode = teachMode; + + // Update dots + document.querySelectorAll('.phase-dot').forEach(dot => { + dot.classList.toggle('active', dot.dataset.phase === p); + }); + + // Phase-specific setup + if (p === 'explore') { + ctasShown = false; + joystickInteractionTime = 0; + const prompt = document.getElementById('prompt-text'); + if (prompt) prompt.textContent = 'Move the joystick to explore'; + document.getElementById('explore-ctas')?.classList.add('hidden'); + } + + if (p === 'teach') { + updateTeachStatus(); + } + + if (p === 'perform') { + resetIdleTimer(); + // Show audio control if synth mode + const audioCtrl = document.getElementById('audio-control'); + if (audioCtrl) { + audioCtrl.classList.toggle('hidden', outputMode !== 'synth'); + } + } + + // Resize visualizer for canvas height change + requestAnimationFrame(() => { + setTimeout(() => onResize(), 450); // after CSS transition + }); +} + +function wirePhaseNav() { + document.querySelectorAll('.phase-dot').forEach(dot => { + dot.addEventListener('click', () => { + const target = dot.dataset.phase; + if (target === 'teach' || target === 'perform') { + setPhase(target); + } else { + setPhase('explore'); + } + }); + }); + + // Explore CTAs + document.getElementById('cta-teach')?.addEventListener('click', () => { + setPhase('teach'); + }); + + document.getElementById('cta-preset')?.addEventListener('click', () => { + // Load a preset, add examples at corners, train, go to perform + loadPresetAndPerform(0); + }); +} + +function loadPresetAndPerform(presetIdx) { + const preset = PRESETS[presetIdx]; + // Add examples at 4 corners + center with this preset's outputs + const positions = [[0.15, 0.15], [0.85, 0.15], [0.15, 0.85], [0.85, 0.85], [0.5, 0.5]]; + iml.clearDataset(); + const paddedOutputs = padOutputs(preset.outputs); + for (const [x, y] of positions) { + iml.addExample([x, y], paddedOutputs); + } + iml.train(); + iml.setInput(0, joystickX); + iml.setInput(1, joystickY); + iml.process(); + routeOutputs(iml.getOutputs()); + saveState(); + setPhase('perform'); +} + +// ============================================================ +// Output routing +// ============================================================ +function routeOutputs(outputs) { + if (outputMode === 'visual') { + visualizer.setParams(outputs.slice(0, N_VISUAL_OUTPUTS)); + } else { + synthVisualizer.setParams(outputs); + } + updateHeatmap(outputs); + + if (outputMode === 'synth' && c15 && c15.running) { + for (let i = 0; i < outputs.length && i < SYNTH_PARAM_MAP.length; i++) { + const tamed = applyTame(outputs[i], SYNTH_PARAM_MAP[i], tameLevel); + c15.setParameter(SYNTH_PARAM_MAP[i].id, tamed); + } + } +} + +// ============================================================ +// Heatmap strip +// ============================================================ +function buildHeatmap() { + const container = document.getElementById('heatmap-cells'); + container.innerHTML = ''; + const count = currentParamNames.length; + 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-bar'; + bar.style.backgroundColor = currentParamColors[i] || '#00ff88'; + bar.style.width = '50%'; + cell.appendChild(bar); + container.appendChild(cell); + } + const strip = document.getElementById('heatmap-strip'); + strip.classList.toggle('wide', count > 30); + document.getElementById('heatmap-count').textContent = String(count); +} + +function updateHeatmap(outputs) { + const bars = document.querySelectorAll('.heatmap-bar'); + for (let i = 0; i < bars.length && i < outputs.length; i++) { + bars[i].style.width = (Math.max(0, Math.min(1, outputs[i])) * 100) + '%'; + } +} + +function adjustBrightness(hex, brightness) { + // Parse hex to RGB, then create HSL-ish brightness adjustment + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + const factor = brightness / 100; + return `rgb(${Math.round(r * factor)}, ${Math.round(g * factor)}, ${Math.round(b * factor)})`; +} + +function wireHeatmapTooltip() { + const strip = document.getElementById('heatmap-strip'); + const tooltip = document.getElementById('heatmap-tooltip'); + + strip.addEventListener('pointerover', (e) => { + const cell = e.target.closest('.heatmap-cell'); + if (!cell) { tooltip.classList.remove('show'); return; } + const idx = parseInt(cell.dataset.index); + const outputs = iml.getOutputs(); + const name = currentParamNames[idx] || `Param ${idx}`; + const val = outputs[idx]?.toFixed(3) ?? '?'; + tooltip.textContent = `${name}: ${val}`; + tooltip.classList.add('show'); + }); + + strip.addEventListener('pointerout', () => { + tooltip.classList.remove('show'); + }); +} + +// ============================================================ +// Mode toggle (Visual/Synth) +// ============================================================ +function wireModeToggle() { + document.querySelectorAll('.mode-opt').forEach(btn => { + btn.addEventListener('click', () => { + const mode = btn.dataset.mode; + setOutputMode(mode); + document.querySelectorAll('.mode-opt').forEach(b => b.classList.toggle('active', b === btn)); + }); + }); +} + +function setOutputMode(mode) { + outputMode = mode; + const visC = document.getElementById('vis-canvas'); + const synthC = document.getElementById('synth-vis-canvas'); + if (mode === 'synth') { + currentParamNames = SYNTH_PARAM_NAMES; + currentParamColors = SYNTH_PARAM_COLORS; + document.getElementById('synth-section')?.classList.remove('hidden'); + // Toggle canvases + visC?.classList.add('hidden'); + synthC?.classList.remove('hidden'); + synthVisualizer?.resize(); + // Update preset labels to synth names + document.querySelectorAll('.preset-thumb span').forEach((span, i) => { + if (PRESETS[i]) span.textContent = PRESETS[i].synth; + }); + } else { + currentParamNames = VISUAL_PARAM_NAMES; + currentParamColors = VISUAL_PARAM_COLORS; + document.getElementById('synth-section')?.classList.add('hidden'); + // Toggle canvases + visC?.classList.remove('hidden'); + synthC?.classList.add('hidden'); + document.querySelectorAll('.preset-thumb span').forEach((span, i) => { + if (PRESETS[i]) span.textContent = PRESETS[i].name; + }); + } + buildHeatmap(); + updateHeatmap(iml.getOutputs()); + buildParamSliders(); + + // Show/hide audio control in perform phase + const audioCtrl = document.getElementById('audio-control'); + if (audioCtrl) audioCtrl.classList.toggle('hidden', mode !== 'synth' || phase !== 'perform'); +} + +// ============================================================ +// Teach tabs +// ============================================================ +function wireTeachTabs() { + document.querySelectorAll('.teach-tab').forEach(tab => { + tab.addEventListener('click', () => { + teachMode = tab.dataset.tab; + document.querySelectorAll('.teach-tab').forEach(t => t.classList.toggle('active', t === tab)); + document.getElementById('teach-examples').classList.toggle('hidden', teachMode !== 'examples'); + document.getElementById('teach-rl').classList.toggle('hidden', teachMode !== 'rl'); + app.dataset.teachMode = teachMode; + + // Show RL hint only first time + if (teachMode === 'rl' && !rlHintShown) { + rlHintShown = true; + } else if (teachMode === 'rl') { + document.getElementById('rl-hint')?.classList.add('hidden'); + } + }); + }); +} + +// ============================================================ +// Preset grid +// ============================================================ +function wirePresetGrid() { + document.querySelectorAll('.preset-thumb').forEach(btn => { + btn.addEventListener('click', () => { + const idx = parseInt(btn.dataset.preset); + selectedPreset = idx; + document.querySelectorAll('.preset-thumb').forEach(b => b.classList.toggle('selected', b === btn)); + + // Set the IML output state to this preset's values (for "Add Example") + const paddedPreset = padOutputs(PRESETS[idx].outputs); + iml.setOutputs(paddedPreset); + routeOutputs(paddedPreset); + }); + }); +} + +function renderPresetThumbnails() { + // Render a tiny visualization for each preset + const canvases = document.querySelectorAll('.preset-canvas'); + canvases.forEach((canvas, i) => { + if (!PRESETS[i]) return; + renderMiniPreview(canvas, PRESETS[i].outputs); + }); +} + +function renderMiniPreview(canvas, outputs) { + const ctx = canvas.getContext('2d'); + const w = canvas.width; + const h = canvas.height; + ctx.fillStyle = '#0d0d0d'; + ctx.fillRect(0, 0, w, h); + + // Draw a few colored dots to suggest the visual character + const hueBase = outputs[3] * 360; + const hueSpread = outputs[4] * 120; + const speed = outputs[2]; + const turb = outputs[7]; + const particleSize = 1 + outputs[5] * 3; + const count = 30; + + for (let p = 0; p < count; p++) { + const hue = (hueBase + (p / count) * hueSpread) % 360; + const x = w * 0.2 + Math.random() * w * 0.6; + const y = h * 0.2 + Math.random() * h * 0.6; + // Offset by speed/turbulence to hint at movement + const ox = (Math.random() - 0.5) * turb * 10; + const oy = (Math.random() - 0.5) * speed * 8; + ctx.fillStyle = `hsl(${hue}, 70%, 55%)`; + ctx.beginPath(); + ctx.arc(x + ox, y + oy, particleSize, 0, TWO_PI); + ctx.fill(); + } +} + +// ============================================================ +// Teach actions +// ============================================================ +function wireTeachActions() { + document.getElementById('btn-add-example')?.addEventListener('click', () => { + const outputs = iml.getOutputs(); + iml.addExample([joystickX, joystickY], outputs); + updateTeachStatus(); + drawMinimap(); + + // Pulse the train button + const trainBtn = document.getElementById('btn-train'); + trainBtn?.classList.add('pulse'); + }); + + document.getElementById('btn-train')?.addEventListener('click', () => { + if (iml.exampleCount === 0) return; + const trainBtn = document.getElementById('btn-train'); + trainBtn?.classList.remove('pulse'); + trainBtn.textContent = 'Training...'; + + // Use setTimeout to allow UI update + setTimeout(() => { + iml.train({ + onIteration: (iter, loss) => { + if (iter % 100 === 0) { + trainBtn.textContent = `Loss: ${loss.toFixed(5)}`; + } + }, + }); + + // Re-run inference + iml.setInput(0, joystickX); + iml.setInput(1, joystickY); + iml.process(); + routeOutputs(iml.getOutputs()); + + trainBtn.textContent = 'Train'; + updateTeachStatus(); + saveState(); + }, 50); + }); + + document.getElementById('btn-clear')?.addEventListener('click', () => { + iml.clearDataset(); + iml.randomiseWeights(); + selectedPreset = -1; + document.querySelectorAll('.preset-thumb').forEach(b => b.classList.remove('selected')); + updateTeachStatus(); + drawMinimap(); + clearSavedState(); + }); + + document.getElementById('btn-randomize')?.addEventListener('click', () => { + iml.randomiseWeights(); + iml.setInput(0, joystickX); + iml.setInput(1, joystickY); + iml.process(); + routeOutputs(iml.getOutputs()); + updateTeachStatus(); + }); + + document.getElementById('btn-done')?.addEventListener('click', () => { + if (iml.exampleCount > 0 && iml.lastLoss === null) { + // Auto-train if there are untrained examples + iml.train(); + iml.setInput(0, joystickX); + iml.setInput(1, joystickY); + iml.process(); + routeOutputs(iml.getOutputs()); + } + saveState(); + setPhase('perform'); + }); +} + +function updateTeachStatus() { + const text = document.getElementById('status-text'); + if (!text) return; + const count = iml.exampleCount; + const lossStr = iml.lastLoss !== null ? ` \u00b7 loss ${iml.lastLoss.toFixed(5)}` : ''; + text.textContent = `${count} example${count !== 1 ? 's' : ''}${lossStr}`; +} + +// ============================================================ +// RL functions (standalone, callable from buttons or keyboard) +// ============================================================ +function onThumbsUp() { + const inputs = [joystickX, joystickY]; + const outputs = [...iml.getOutputs()]; + iml.addExample(inputs, outputs); + iml.train(); + iml.setInput(0, joystickX); + iml.setInput(1, joystickY); + iml.process(); + routeOutputs(iml.getOutputs()); + noiseLevel = Math.max(0.005, noiseLevel * 0.7); + updateNoiseRing(); + updateTeachStatus(); + drawMinimap(); + saveState(); +} + +function onThumbsDown() { + noiseLevel = Math.min(0.3, noiseLevel * 1.5); + iml.moveWeights(noiseLevel); + iml.setInput(0, joystickX); + iml.setInput(1, joystickY); + iml.process(); + routeOutputs(iml.getOutputs()); + updateNoiseRing(); +} + +function wireRLButtons() { + document.getElementById('btn-thumbs-up')?.addEventListener('click', onThumbsUp); + document.getElementById('btn-thumbs-down')?.addEventListener('click', onThumbsDown); +} + +function updateNoiseRing() { + const ring = document.getElementById('noise-ring'); + if (!ring) return; + const thickness = 2 + noiseLevel * 20; + const opacity = 0.2 + noiseLevel * 1.2; + ring.style.borderWidth = `${thickness}px`; + ring.style.opacity = Math.min(1, opacity); + ring.style.inset = `${-(8 + thickness)}px`; +} + +// ============================================================ +// Synth controls +// ============================================================ +function wireSynthControls() { + const startBtn = document.getElementById('synth-start'); + startBtn?.addEventListener('click', async () => { + if (c15.running) { + arpeggiator.stop(); + document.getElementById('arp-toggle').textContent = 'Play'; + await c15.stop(); + startBtn.textContent = 'Start Audio'; + } else { + await c15.start(); + startBtn.textContent = 'Stop Audio'; + routeOutputs(iml.getOutputs()); + } + }); + + document.getElementById('synth-volume')?.addEventListener('input', (e) => { + c15.setMasterVolume(parseFloat(e.target.value)); + }); + + document.getElementById('arp-toggle')?.addEventListener('click', () => { + if (!c15.running) return; + const btn = document.getElementById('arp-toggle'); + if (arpeggiator.playing) { + arpeggiator.stop(); + btn.textContent = 'Play'; + } else { + arpeggiator.start(); + btn.textContent = 'Stop'; + } + }); + + document.getElementById('arp-progression')?.addEventListener('change', (e) => { + arpeggiator.progression = e.target.value; + }); + + document.getElementById('arp-tempo')?.addEventListener('input', (e) => { + arpeggiator.bpm = parseInt(e.target.value); + const label = document.getElementById('tempo-val'); + if (label) label.textContent = e.target.value; + }); + + // Perform phase audio controls + document.getElementById('audio-toggle')?.addEventListener('click', async () => { + const btn = document.getElementById('audio-toggle'); + if (c15.running) { + arpeggiator.stop(); + await c15.stop(); + btn.textContent = '\u25B6'; + } else { + await c15.start(); + arpeggiator.start(); + btn.textContent = '\u23F8'; + routeOutputs(iml.getOutputs()); + } + }); + + document.getElementById('audio-vol')?.addEventListener('input', (e) => { + c15.setMasterVolume(parseFloat(e.target.value)); + }); +} + +// ============================================================ +// Perform UI (idle fading, edit button) +// ============================================================ +function wirePerformUI() { + document.getElementById('btn-edit')?.addEventListener('click', () => { + setPhase('teach'); + }); +} + +function resetIdleTimer() { + app.classList.remove('idle'); + if (idleTimer) clearTimeout(idleTimer); + if (phase === 'perform') { + idleTimer = setTimeout(() => { + app.classList.add('idle'); + }, IDLE_TIMEOUT); + } +} + +// ============================================================ +// Joystick (canvas-based, inline) +// ============================================================ +function wireJoystick() { + const canvas = document.getElementById('joystick-canvas'); + if (!canvas) return; + + const getPos = (e) => { + const rect = canvas.getBoundingClientRect(); + const touch = e.touches ? e.touches[0] : e; + const x = (touch.clientX - rect.left) / rect.width; + const y = (touch.clientY - rect.top) / rect.height; + return [Math.max(0, Math.min(1, x)), Math.max(0, Math.min(1, y))]; + }; + + const onStart = (e) => { + e.preventDefault(); + joyDragging = true; + const [x, y] = getPos(e); + updateJoystick(x, y); + }; + + const onMove = (e) => { + e.preventDefault(); + if (!joyDragging) return; + const [x, y] = getPos(e); + updateJoystick(x, y); + }; + + const onEnd = (e) => { + e.preventDefault(); + joyDragging = false; + }; + + canvas.addEventListener('pointerdown', onStart); + canvas.addEventListener('pointermove', onMove); + canvas.addEventListener('pointerup', onEnd); + canvas.addEventListener('pointercancel', onEnd); + canvas.addEventListener('pointerleave', onEnd); + + // Draw initial state + drawJoystick(); +} + +function updateJoystick(x, y) { + joyKnobX = x; + joyKnobY = y; + joystickX = x; + joystickY = y; + joystickActive = true; + + iml.setInput(0, x); + iml.setInput(1, y); + iml.process(); + routeOutputs(iml.getOutputs()); + + // Explore phase: track interaction + if (phase === 'explore') { + joystickInteractionTime += 16; // rough frame time + if (joystickInteractionTime > 3000 && !ctasShown) { + ctasShown = true; + const prompt = document.getElementById('prompt-text'); + if (prompt) prompt.textContent = 'Ready to teach it?'; + document.getElementById('explore-ctas')?.classList.remove('hidden'); + } + } + + // Reset idle in perform + if (phase === 'perform') { + resetIdleTimer(); + } + + drawJoystick(); +} + +function drawJoystick() { + const canvas = document.getElementById('joystick-canvas'); + if (!canvas) return; + const ctx = canvas.getContext('2d'); + const dpr = window.devicePixelRatio || 1; + const rect = canvas.getBoundingClientRect(); + canvas.width = rect.width * dpr; + canvas.height = rect.height * dpr; + ctx.scale(dpr, dpr); + + const w = rect.width; + const h = rect.height; + const cx = w / 2; + const cy = h / 2; + const radius = Math.min(w, h) / 2 - 4; + + // Background ring + ctx.clearRect(0, 0, w, h); + ctx.beginPath(); + ctx.arc(cx, cy, radius, 0, TWO_PI); + ctx.fillStyle = 'rgba(13, 13, 13, 0.5)'; + ctx.fill(); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)'; + ctx.lineWidth = 1.5; + ctx.stroke(); + + // Crosshair + ctx.strokeStyle = 'rgba(255, 255, 255, 0.05)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(cx, cy - radius * 0.7); + ctx.lineTo(cx, cy + radius * 0.7); + ctx.moveTo(cx - radius * 0.7, cy); + ctx.lineTo(cx + radius * 0.7, cy); + ctx.stroke(); + + // Knob + const knobX = 4 + joyKnobX * (w - 8); + const knobY = 4 + joyKnobY * (h - 8); + const knobRadius = 10; + + ctx.beginPath(); + ctx.arc(knobX, knobY, knobRadius, 0, TWO_PI); + ctx.fillStyle = 'rgba(0, 255, 136, 0.7)'; + ctx.fill(); + ctx.strokeStyle = 'rgba(0, 255, 136, 0.4)'; + ctx.lineWidth = 2; + ctx.stroke(); + + // Glow + const glow = ctx.createRadialGradient(knobX, knobY, 0, knobX, knobY, knobRadius * 2.5); + glow.addColorStop(0, 'rgba(0, 255, 136, 0.15)'); + glow.addColorStop(1, 'rgba(0, 255, 136, 0)'); + ctx.fillStyle = glow; + ctx.beginPath(); + ctx.arc(knobX, knobY, knobRadius * 2.5, 0, TWO_PI); + ctx.fill(); +} + +// ============================================================ +// Follow mode +// ============================================================ +function wireFollowMode() { + const joyCanvas = document.getElementById('joystick-canvas'); + const followIndicator = document.getElementById('follow-indicator'); + const followPill = document.getElementById('follow-pill'); + + function toggleFollowMode() { + followMode = !followMode; + followIndicator?.classList.toggle('hidden', !followMode); + followPill?.classList.toggle('active', followMode); + } + + // Double-click joystick canvas toggles follow mode + joyCanvas?.addEventListener('dblclick', (e) => { + e.preventDefault(); + toggleFollowMode(); + }); + + // Follow pill toggle + followPill?.addEventListener('click', (e) => { + e.preventDefault(); + toggleFollowMode(); + }); + + // Global pointermove for follow mode + const visCanvas = document.getElementById('vis-canvas'); + window.addEventListener('pointermove', (e) => { + if (!followMode) return; + // Map cursor position to 0..1 relative to vis-canvas or full window + const target = visCanvas || document.body; + const rect = target.getBoundingClientRect(); + const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height)); + updateJoystick(x, y); + }); +} + +// ============================================================ +// Minimap (training examples positions) +// ============================================================ +function drawMinimap() { + const canvas = document.getElementById('minimap'); + if (!canvas) return; + const ctx = canvas.getContext('2d'); + const w = canvas.width; + const h = canvas.height; + + ctx.fillStyle = 'rgba(13, 13, 13, 0.9)'; + ctx.fillRect(0, 0, w, h); + + // Grid + ctx.strokeStyle = 'rgba(255, 255, 255, 0.05)'; + ctx.lineWidth = 1; + for (let i = 1; i < 4; i++) { + const p = (i / 4) * w; + ctx.beginPath(); + ctx.moveTo(p, 0); ctx.lineTo(p, h); + ctx.moveTo(0, p); ctx.lineTo(w, p); + ctx.stroke(); + } + + // Example dots + const features = iml.dataset.features; + if (!features) return; + for (let i = 0; i < features.length; i++) { + const f = features[i]; + if (!f || f.length < 2) continue; + const x = f[0] * w; + const y = f[1] * h; + ctx.fillStyle = 'rgba(0, 255, 136, 0.8)'; + ctx.beginPath(); + ctx.arc(x, y, 3, 0, TWO_PI); + ctx.fill(); + } + + // Current position + ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.arc(joystickX * w, joystickY * h, 5, 0, TWO_PI); + ctx.stroke(); +} + +// ============================================================ +// Param sliders (fine-tune) +// ============================================================ +function buildParamSliders() { + const container = document.getElementById('param-sliders'); + if (!container) return; + container.innerHTML = ''; + + const outputs = iml.getOutputs(); + for (let i = 0; i < currentParamNames.length; i++) { + const row = document.createElement('div'); + row.className = 'param-slider-row'; + const label = document.createElement('label'); + label.textContent = currentParamNames[i]; + const input = document.createElement('input'); + input.type = 'range'; + input.min = '0'; + input.max = '1'; + input.step = '0.01'; + input.value = outputs[i] ?? 0.5; + input.dataset.index = i; + + input.addEventListener('input', (e) => { + const idx = parseInt(e.target.dataset.index); + iml.setOutput(idx, parseFloat(e.target.value)); + routeOutputs(iml.getOutputs()); + }); + + row.appendChild(label); + row.appendChild(input); + container.appendChild(row); + } +} + +function updateParamSliders() { + const sliders = document.querySelectorAll('.param-slider-row input[type="range"]'); + const outputs = iml.getOutputs(); + sliders.forEach(slider => { + const idx = parseInt(slider.dataset.index); + if (idx < outputs.length) { + slider.value = outputs[idx]; + } + }); +} + +// ============================================================ +// Resize +// ============================================================ +function onResize() { + visualizer.resize(); + visualizer.initParticles(); + if (synthVisualizer) synthVisualizer.resize(); + drawJoystick(); +} + +// ============================================================ +// Animation loop +// ============================================================ +function animate() { + if (!animating) return; + + if (outputMode === 'synth') { + synthVisualizer.draw(); + } else { + visualizer.draw(); + } + + // Keep minimap updated if in teach phase + if (phase === 'teach' && teachMode === 'examples') { + // Only redraw minimap occasionally + if (Math.random() < 0.05) drawMinimap(); + } + + requestAnimationFrame(animate); +} + +// ============================================================ +// Save / Load state +// ============================================================ +function saveState() { + try { + const data = { + features: iml.dataset.features.slice(0, iml.dataset.size), + labels: iml.dataset.labels.slice(0, iml.dataset.size), + weights: iml.mlp.getWeights(), + outputMode, + joystickX, + joystickY, + }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); + } catch (e) { + console.warn('Failed to save state:', e); + } +} + +function loadState() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return false; + const data = JSON.parse(raw); + if (!data.features || data.features.length === 0) return false; + + // Restore dataset + for (let i = 0; i < data.features.length; i++) { + iml.addExample(data.features[i], data.labels[i]); + } + + // Restore weights + if (data.weights) { + iml.mlp.setWeights(data.weights); + } + + // Restore output mode + if (data.outputMode) { + setOutputMode(data.outputMode); + document.querySelectorAll('.mode-opt').forEach(b => { + b.classList.toggle('active', b.dataset.mode === data.outputMode); + }); + } + + // Restore joystick position + if (data.joystickX !== undefined) { + joystickX = data.joystickX; + joystickY = data.joystickY; + joyKnobX = joystickX; + joyKnobY = joystickY; + } + + return true; + } catch (e) { + console.warn('Failed to load state:', e); + return false; + } +} + +function clearSavedState() { + localStorage.removeItem(STORAGE_KEY); +} + +// ============================================================ +// Boot +// ============================================================ +document.addEventListener('DOMContentLoaded', init);