feat(playground): implement Phases 2-4 of control surface spec

Phase 2 — Pinning + History:
- snapshot-stack.js: ring buffer (20 max) with auto-snapshot on
  train/randomize/thumbs-down, multi-level undo, tagged entries
- ab-compare.js: A/B weight state comparison with capture/toggle/accept/revert
- region-pin.js: pin rectangular input-space regions (Approach A: example
  pinning), pinned examples always included in training
- param-pin.js: per-output pin flags, pin mask skips pinned nodes in moveWeights
- phase2-ui.js: undo button with history popup, A/B toggle, long-press region
  pin, double-tap param pin
- Modified mlp.js/iml.js/nisps-wasm.js to accept outputPinMask in moveWeights

Phase 3 — Input Refinement + Exploration:
- pressure-feedback.js: touch force + hold duration → intensity multiplier
- auto-explore.js: automated thumbs-down at configurable interval, zoom-scaled
- input-heatmap.js: 16×16 MLP sampling, 3 color modes (luminance/variance/
  divergence), zoom-aware resampling, offscreen canvas rendering
- phase3-ui.js: auto-explore toggle with progress ring, heatmap eye icon,
  pressure indicators, settings drawer section
- joy-map-enhanced.js: added setHeatmap() for background layer rendering

Phase 4 — Output Pipeline + Visualization + Polish:
- output-pipeline.js: global curve → smoothing → slew rate → freeze gate
- weight-health.js: weight magnitude histogram, dead/saturating/healthy status
- gradient-flow.js: per-layer weight-delta analysis, vanishing/exploding detection
- session-presets.js: save/load full state, URL sharing via compact params
- phase4-ui.js: freeze button, network health panel, session preset UI

All phases merged into a-app.js with proper integration: auto-snapshots,
pressure-modulated RL, heatmap triggers, output pipeline in routeOutputs,
gradient capture around training, persistence for all new state.
This commit is contained in:
w1n5t0n 2026-03-26 10:48:12 +02:00
parent 73eeaac0cc
commit 1f21494dee
21 changed files with 4617 additions and 37 deletions

View file

@ -96,14 +96,28 @@ Presets use `curve` values to bias parameter distributions (< 0.5 = spend more t
The immersive app (`a-immersive.html`) has a control surface system for tuning how exploration and learning feel. Full spec: `playground/SPEC-controls.md`. The immersive app (`a-immersive.html`) has a control surface system for tuning how exploration and learning feel. Full spec: `playground/SPEC-controls.md`.
**Architecture** — three standalone ES modules wired into `a-app.js`: **Architecture** — modular ES modules organized by phase, wired into `a-app.js`:
| Module | Purpose | | Module | Phase | Purpose |
|--------|---------| |--------|-------|---------|
| `js/ui/input-pipeline.js` | Processes raw joystick input through deadzone → zoom → curve → smoothing → momentum-as-zoom. Pure math, no DOM. | | `js/ui/input-pipeline.js` | 1 | Processes raw joystick input through deadzone → zoom → curve → smoothing → momentum-as-zoom. Pure math, no DOM. |
| `js/ui/control-surface.js` | Compound axes (Boldness, Memory, Precision) that map single sliders to multiple underlying params. Offset-based override resolution (trim-pot model). 6 built-in control presets. | | `js/ui/control-surface.js` | 1 | Compound axes (Boldness, Memory, Precision) that map single sliders to multiple underlying params. Offset-based override resolution (trim-pot model). 6 built-in control presets. |
| `js/ui/control-surface-ui.js` | DOM layer: 3 axis sliders on floating bar, gear icon settings drawer with per-param overrides. Injects its own CSS. | | `js/ui/control-surface-ui.js` | 1 | DOM layer: 3 axis sliders on floating bar, gear icon settings drawer with per-param overrides. Injects its own CSS. |
| `js/ui/joy-map-enhanced.js` | Enhanced joy-map canvas: zoom minimap with adaptive grid, vanishing trail with Catmull-Rom spline and tap-to-return, dual concentric noise rings (zoom + noise), frozen state overlay. | | `js/ui/joy-map-enhanced.js` | 1 | Enhanced joy-map canvas: zoom minimap with adaptive grid, vanishing trail with Catmull-Rom spline and tap-to-return, dual concentric noise rings, frozen state overlay. |
| `js/ui/snapshot-stack.js` | 2 | Ring buffer (20 max) of weight snapshots. Auto-snapshot on train/randomize/thumbs-down. Multi-level undo. |
| `js/ui/ab-compare.js` | 2 | Rapid A/B weight state comparison. Capture, toggle, accept or revert. |
| `js/ui/region-pin.js` | 2 | Pins rectangular input-space regions (Approach A: example pinning). Pinned examples always included in training. |
| `js/ui/param-pin.js` | 2 | Per-output pin flags. Pin mask passed to `moveWeights()` to skip pinned output nodes. |
| `js/ui/phase2-ui.js` | 2 | DOM: undo button with history popup, A/B toggle, region pin via long-press, param pin via double-tap. |
| `js/ui/pressure-feedback.js` | 3 | Touch force + hold duration → intensity multiplier for noise growth/decay. |
| `js/ui/auto-explore.js` | 3 | Automated thumbs-down at configurable interval. Zoom-scaled intensity. |
| `js/ui/input-heatmap.js` | 3 | 2D color field sampling MLP across input space. 3 color modes, zoom-aware resampling. |
| `js/ui/phase3-ui.js` | 3 | DOM: auto-explore toggle with progress ring, heatmap eye icon, pressure indicators. |
| `js/ui/output-pipeline.js` | 4 | Global curve → smoothing → slew rate → freeze gate on MLP outputs before synth/visual routing. |
| `js/ui/weight-health.js` | 4 | Weight magnitude histogram, dead/saturating/healthy status detection, ambient visualization. |
| `js/ui/gradient-flow.js` | 4 | Per-layer weight-delta analysis after training. Vanishing/exploding/converged detection. |
| `js/ui/session-presets.js` | 4 | Save/load full session state. URL sharing via compact params. |
| `js/ui/phase4-ui.js` | 4 | DOM: freeze button, network health panel, session preset UI, output pipeline slider wiring. |
**Compound Axes** — each controls 4-6 underlying parameters via interpolation tables: **Compound Axes** — each controls 4-6 underlying parameters via interpolation tables:
@ -119,7 +133,7 @@ When a user manually overrides an individual param, the offset from the axis-der
**Integration** — the control surface dispatches `controlsurface:change` CustomEvents. `a-app.js` listens and updates the input pipeline config, spread level, and RL parameters (noise cap, growth, decay, floor, zoom-aware feedback scaling). Pipeline-processed coordinates are cached (`_lastPipeX/Y`) so `getCurrentInputs()` and `setCurrentInputs()` use the same values the MLP sees. State is persisted to localStorage alongside existing app state. **Integration** — the control surface dispatches `controlsurface:change` CustomEvents. `a-app.js` listens and updates the input pipeline config, spread level, and RL parameters (noise cap, growth, decay, floor, zoom-aware feedback scaling). Pipeline-processed coordinates are cached (`_lastPipeX/Y`) so `getCurrentInputs()` and `setCurrentInputs()` use the same values the MLP sees. State is persisted to localStorage alongside existing app state.
**Remaining spec phases** (not yet implemented): Phase 2 (pinning + history + A/B compare), Phase 3 (momentum-zoom, pressure feedback, auto-explore, heatmap), Phase 4 (output pipeline, weight health, gradient flow, engine config, session presets). **Remaining**: Engine configuration panel (Part 8 of spec) — network architecture, loss function, optimizer selection.
## Build System ## Build System

View file

@ -656,22 +656,26 @@ Control presets define a complete control surface state (all parameters from Par
9. ✅ (bonus) Control presets with offset-based override resolution — 6 built-in presets, trim-pot model 9. ✅ (bonus) Control presets with offset-based override resolution — 6 built-in presets, trim-pot model
10. ✅ (bonus) Dual concentric noise rings (zoom + noise) replacing CSS-only ring 10. ✅ (bonus) Dual concentric noise rings (zoom + noise) replacing CSS-only ring
### Phase 2 — Pinning + History ### Phase 2 — Pinning + History ✅ IMPLEMENTED
7. Parameter pinning (per-output, in synth visualizer drawer) 7. ✅ Parameter pinning — per-output pin flags, pin mask passed to `moveWeights()`, double-tap to toggle in synth visualizer
8. Region pinning (on joy-map, Approach A: example pinning) 8. ✅ Region pinning (Approach A: example pinning) — long-press joy-map pins current zoom window, pinned examples always included in training with high weight
9. Snapshot stack with undo button 9. ✅ Snapshot stack with undo — ring buffer (20 max), auto-snapshot on train/randomize/thumbs-down, long-press for history popup
10. A/B Compare toggle 10. ✅ A/B Compare toggle — capture A, toggle between states, accept B or revert to A
11. ✅ Modified `mlp.js` moveWeights to accept optional `outputPinMask` for pinned output nodes
### Phase 3 — Input Refinement + Exploration ### Phase 3 — Input Refinement + Exploration ✅ IMPLEMENTED
11. Pressure/hold-duration feedback 12. ✅ Pressure/hold-duration feedback — touch force + hold duration modulate noise growth/decay strength
12. Auto-Explore mode 13. ✅ Auto-Explore mode — automated thumbs-down at configurable interval, zoom-scaled intensity, emerald toggle button with progress ring
13. Input space heatmap on joy-map 14. ✅ Input space heatmap — 16×16 grid inference sampling, 3 color modes (luminance/variance/divergence), zoom-aware resampling, throttled updates
### Phase 4 — Output, Persistence + Polish ### Phase 4 — Output, Persistence + Polish ✅ IMPLEMENTED
14. Output smoothing, slew rate, and freeze output gate — (sliders exist in drawer but not yet wired to output pipeline) 15. ✅ Output pipeline — global curve → smoothing → slew rate → freeze gate, wired into `routeOutputs()`
15. Weight health indicator + gradient flow 16. ✅ Weight health indicator — weight magnitude histogram, dead/saturating/healthy status, ambient glow visualization
16. Session presets (control + synth bundled) 17. ✅ Gradient flow indicator — per-layer weight-delta analysis, vanishing/exploding/converged detection, bar visualization
17. Engine configuration panel 18. ✅ Session presets — save/load full state (control surface + synth preset + pipelines), URL sharing via compact params
### Remaining
- Engine configuration panel (Part 8) — network architecture, loss function, optimizer selection
--- ---

View file

@ -10,6 +10,20 @@ import { InputPipeline } from './ui/input-pipeline.js';
import { initControlSurfaceUI } from './ui/control-surface-ui.js'; import { initControlSurfaceUI } from './ui/control-surface-ui.js';
import { JoyMapEnhanced } from './ui/joy-map-enhanced.js'; import { JoyMapEnhanced } from './ui/joy-map-enhanced.js';
// Phase 2: Pinning + History
import { SnapshotStack } from './ui/snapshot-stack.js';
import { ABCompare } from './ui/ab-compare.js';
import { RegionPinManager } from './ui/region-pin.js';
import { ParamPinManager } from './ui/param-pin.js';
import { initPhase2UI } from './ui/phase2-ui.js';
// Phase 3: Pressure, Auto-Explore, Heatmap
import { initPhase3UI } from './ui/phase3-ui.js';
// Phase 4: Output Pipeline + Visualization + Polish
import { OutputPipeline } from './ui/output-pipeline.js';
import { initPhase4UI } from './ui/phase4-ui.js';
// ---- ShapeSeq (feature-flagged, enable with ?shapeseq=1) ---- // ---- ShapeSeq (feature-flagged, enable with ?shapeseq=1) ----
const ENABLE_SHAPESEQ = new URLSearchParams(window.location.search).get('shapeseq') === '1'; const ENABLE_SHAPESEQ = new URLSearchParams(window.location.search).get('shapeseq') === '1';
let _shapeSeqImports = null; let _shapeSeqImports = null;
@ -117,6 +131,20 @@ let joyMapEnhanced = null;
let _lastFrameTime = 0; let _lastFrameTime = 0;
let _lastPipeX = 0.5, _lastPipeY = 0.5; // cached pipeline output for getCurrentInputs() let _lastPipeX = 0.5, _lastPipeY = 0.5; // cached pipeline output for getCurrentInputs()
// Phase 2: Pinning + History
let snapshotStack = null;
let abCompare = null;
let regionPins = null;
let paramPins = null;
let phase2UI = null;
// Phase 3: Pressure, Auto-Explore, Heatmap
let phase3 = null;
// Phase 4: Output Pipeline + Visualization + Polish
let outputPipeline = null;
let phase4 = null;
// Joystick state // Joystick state
let joyX = 0.5; let joyX = 0.5;
let joyY = 0.5; let joyY = 0.5;
@ -490,14 +518,28 @@ class SynthVisualizer {
const barH = val * maxBarHeight; const barH = val * maxBarHeight;
const barY = topPad + usableHeight - barH; const barY = topPad + usableHeight - barH;
// Bar with slight transparency // Phase 2: Dim pinned param bars
ctx.fillStyle = sec.color + 'cc'; const isPinned = paramPins && paramPins.isPinned(i);
// Bar with slight transparency (dimmer if pinned)
ctx.fillStyle = sec.color + (isPinned ? '66' : 'cc');
ctx.fillRect(x, barY, Math.max(barWidth - 0.5, 1), barH); ctx.fillRect(x, barY, Math.max(barWidth - 0.5, 1), barH);
// Bright top edge // Bright top edge
ctx.fillStyle = sec.color; ctx.fillStyle = isPinned ? (sec.color + '88') : sec.color;
ctx.fillRect(x, barY, Math.max(barWidth - 0.5, 1), Math.min(2 * dpr, barH)); ctx.fillRect(x, barY, Math.max(barWidth - 0.5, 1), Math.min(2 * dpr, barH));
// Phase 2: Draw small lock indicator on pinned params
if (isPinned) {
const dotR = Math.max(2 * dpr, barWidth * 0.2);
const dotX = x + barWidth / 2;
const dotY = topPad + usableHeight + 6 * dpr;
ctx.fillStyle = 'rgba(255, 180, 0, 0.8)';
ctx.beginPath();
ctx.arc(dotX, dotY, dotR, 0, Math.PI * 2);
ctx.fill();
}
x += barWidth; x += barWidth;
} }
@ -813,6 +855,12 @@ async function init() {
const csUI = initControlSurfaceUI(); const csUI = initControlSurfaceUI();
controlSurface = csUI.surface; controlSurface = csUI.surface;
// ---- Phase 2: Data models — init before loadState so restore works ----
snapshotStack = new SnapshotStack(20);
abCompare = new ABCompare();
regionPins = new RegionPinManager();
paramPins = new ParamPinManager(N_OUTPUTS);
// Restore saved state (if any) // Restore saved state (if any)
loadState(); loadState();
@ -842,9 +890,115 @@ async function init() {
}), }),
}); });
// ---- Phase 2: UI (data models already created above, before loadState) ----
phase2UI = initPhase2UI({
snapshotStack,
abCompare,
regionPins,
paramPins,
getWeights: () => iml._getFlatWeights(),
setWeights: (w) => { iml._setFlatWeights(w); },
getNoiseLevel: () => noiseLevel,
setNoiseLevel: (n) => { noiseLevel = n; updateNoiseRing(); },
getZoomLevel: () => inputPipeline ? inputPipeline.getZoomLevel() : 1.0,
getZoomWindow: () => inputPipeline ? inputPipeline.getZoomWindow() : null,
getTrainingData: () => ({
features: iml.dataset.features,
labels: iml.dataset.labels,
}),
runInference: () => {
iml.inputUpdated = true;
iml.process();
const outputs = iml.getOutputs();
routeOutputs(outputs);
updateHeatmap(outputs);
syncRawParamsFromOutputs(outputs);
updateStatus();
},
joyMapCanvas: $joyMap,
synthVisualizer,
});
// Sync region pin overlays to the enhanced joy-map
document.addEventListener('regionpin:sync', (e) => {
if (joyMapEnhanced) {
joyMapEnhanced.setPinnedRegions(e.detail.regions);
}
});
// ---- Phase 3: Pressure, Auto-Explore, Input Heatmap ----
phase3 = initPhase3UI({
onAutoExplore: ({ intensity }) => {
const csParams = controlSurface ? controlSurface.getParams() : null;
const noiseCap = csParams ? csParams.noiseCap : (0.3 * (1 - spreadLevel) + 0.05 * spreadLevel);
const growth = csParams ? csParams.noiseGrowth : 1.5;
let effectiveGrowth = growth * intensity;
if (csParams && csParams.zoomAwareFeedback && inputPipeline) {
effectiveGrowth *= inputPipeline.getZoomLevel();
}
noiseLevel = Math.min(noiseLevel * effectiveGrowth, noiseCap);
const pinMask = paramPins ? paramPins.getPinMask() : null;
iml.moveWeights(noiseLevel, spreadLevel, pinMask);
const outputs = iml.getOutputs();
routeOutputs(outputs);
updateHeatmap(outputs);
syncRawParamsFromOutputs(outputs);
updateStatus();
updateNoiseRing();
if (phase3) phase3.updateHeatmap();
},
getZoomLevel: () => inputPipeline ? inputPipeline.getZoomLevel() : 1.0,
inferFn: (inputs) => {
if (inputMode === 'hands') return [];
const savedInput = [...iml.inputState];
const savedUpdated = iml.inputUpdated;
iml.setInput(0, inputs[0]);
iml.setInput(1, inputs[1]);
iml.inputUpdated = true;
iml.process();
const result = [...iml.getOutputs()];
for (let i = 0; i < savedInput.length; i++) iml.inputState[i] = savedInput[i];
iml.inputUpdated = savedUpdated;
return result;
},
getZoomWindow: () => inputPipeline ? inputPipeline.getZoomWindow() : null,
});
// Wire heatmap into the enhanced joy-map
if (joyMapEnhanced && phase3) {
joyMapEnhanced.setHeatmap(phase3.heatmap);
}
// ---- Phase 4: Output Pipeline + Visualization + Polish ----
outputPipeline = new OutputPipeline(N_OUTPUTS);
phase4 = initPhase4UI({
outputPipeline,
iml,
controlSurface,
getGroupOverrides: () => groupOverrides,
getActiveSynthPresetId: () => activeSynthPresetId,
inputPipeline,
onSessionLoad: (preset) => {
if (preset.controlSurface && controlSurface) {
controlSurface.setState(preset.controlSurface);
}
if (preset.inputPipeline && inputPipeline) {
inputPipeline.setConfig(preset.inputPipeline);
}
if (preset.outputPipeline && outputPipeline) {
outputPipeline.setConfig(preset.outputPipeline);
}
if (preset.synthPresetId) {
applyPreset(preset.synthPresetId);
}
if (inputMode === 'joystick') onJoystickMove();
},
});
// Wire control surface changes to pipeline + RL params // Wire control surface changes to pipeline + RL params
document.addEventListener('controlsurface:change', (e) => { document.addEventListener('controlsurface:change', (e) => {
const p = e.detail; const p = e.detail;
if (p._phase3) return; // Phase 3 drawer events handled internally
// Input pipeline // Input pipeline
inputPipeline.setConfig({ inputPipeline.setConfig({
zoom: p.zoom, zoom: p.zoom,
@ -857,6 +1011,12 @@ async function init() {
}); });
// Sync spread (used by moveWeights and randomise) // Sync spread (used by moveWeights and randomise)
spreadLevel = p.spread; spreadLevel = p.spread;
// Phase 4: wire output pipeline params
if (outputPipeline) {
if (p.outputSmoothing !== undefined) outputPipeline.setSmoothing(p.outputSmoothing);
if (p.outputSlewRate !== undefined) outputPipeline.setSlewRate(p.outputSlewRate);
if (p.globalCurve !== undefined) outputPipeline.setGlobalCurve(p.globalCurve);
}
}); });
// Initial inference — run through pipeline for consistency // Initial inference — run through pipeline for consistency
@ -1164,15 +1324,25 @@ function onJoystickMove() {
const _lastSentParams = new Float32Array(N_OUTPUTS); const _lastSentParams = new Float32Array(N_OUTPUTS);
const PARAM_DEAD_ZONE = 0.002; // ~0.2% change threshold const PARAM_DEAD_ZONE = 0.002; // ~0.2% change threshold
let _lastParamSendTime = 0; let _lastParamSendTime = 0;
let _lastRouteTime = 0;
const PARAM_SEND_INTERVAL = 50; // max ~20fps for synth param updates const PARAM_SEND_INTERVAL = 50; // max ~20fps for synth param updates
function routeOutputs(outputs) { function routeOutputs(outputs) {
// Phase 4: Output Pipeline (curve, smoothing, slew, freeze)
let pipelined = outputs;
if (outputPipeline) {
const now = performance.now();
const dt = _lastRouteTime > 0 ? (now - _lastRouteTime) / 1000 : 1 / 60;
_lastRouteTime = now;
pipelined = outputPipeline.process(outputs, dt);
}
let overridden = null; let overridden = null;
if (outputMode === 'synth') { if (outputMode === 'synth') {
overridden = new Array(outputs.length); overridden = new Array(pipelined.length);
for (let i = 0; i < outputs.length; i++) { for (let i = 0; i < pipelined.length; i++) {
overridden[i] = applyGroupOverrides(outputs[i], i); overridden[i] = applyGroupOverrides(pipelined[i], i);
} }
// Visualizer always gets every frame (it's local, no buffer) // Visualizer always gets every frame (it's local, no buffer)
synthVisualizer.setParams(overridden); synthVisualizer.setParams(overridden);
@ -1192,13 +1362,13 @@ function routeOutputs(outputs) {
} }
} }
} else { } else {
visualizer.setParams(outputs.slice(0, N_VISUAL_OUTPUTS)); visualizer.setParams(Array.from(pipelined).slice(0, N_VISUAL_OUTPUTS));
} }
// OSC output — sends in both modes (has its own throttle + dead-zone). // OSC output — sends in both modes (has its own throttle + dead-zone).
// In synth mode sends post-override values; in visual mode sends raw outputs. // In synth mode sends post-override values; in visual mode sends raw outputs.
if (oscOutput) { if (oscOutput) {
oscOutput.sendParams(overridden || outputs); oscOutput.sendParams(overridden || Array.from(pipelined));
} }
} }
@ -1495,11 +1665,15 @@ function onAddExample() {
function onTrain() { function onTrain() {
if (iml.isTraining) return; if (iml.isTraining) return;
// Phase 2: auto-snapshot before training
if (phase2UI) phase2UI.pushSnapshot('before train');
flash('btn-train'); flash('btn-train');
trainModelAsync(); trainModelAsync();
} }
function onRandomize() { function onRandomize() {
// Phase 2: auto-snapshot before randomize
if (phase2UI) phase2UI.pushSnapshot('before randomize');
iml.randomiseWeights(spreadLevel); iml.randomiseWeights(spreadLevel);
setCurrentInputs(); setCurrentInputs();
iml.process(); iml.process();
@ -1509,6 +1683,7 @@ function onRandomize() {
syncRawParamsFromOutputs(outputs); syncRawParamsFromOutputs(outputs);
noiseLevel = 0.05; noiseLevel = 0.05;
updateStatus(); updateStatus();
if (phase3) phase3.updateHeatmap();
} }
function onClearExamples() { function onClearExamples() {
@ -1541,15 +1716,22 @@ function onThumbsUp() {
const csParams = controlSurface ? controlSurface.getParams() : null; const csParams = controlSurface ? controlSurface.getParams() : null;
const decay = csParams ? csParams.noiseDecay : rlExplorationDecay; const decay = csParams ? csParams.noiseDecay : rlExplorationDecay;
const floor = csParams ? csParams.noiseFloor : 0.005; const floor = csParams ? csParams.noiseFloor : 0.005;
noiseLevel *= decay; // Phase 3: pressure/hold modulates decay strength
const p3Intensity = phase3 ? phase3.pressure.getIntensity() : 1.0;
const modulatedDecay = 1 - (1 - decay) * p3Intensity;
noiseLevel *= modulatedDecay;
noiseLevel = Math.max(noiseLevel, floor); noiseLevel = Math.max(noiseLevel, floor);
flash('btn-thumbsup'); flash('btn-thumbsup');
updateNoiseRing(); updateNoiseRing();
trainModelAsync(); trainModelAsync();
if (phase3) phase3.updateHeatmap();
} }
function onThumbsDown() { function onThumbsDown() {
// Phase 2: auto-snapshot before thumbs-down
if (phase2UI) phase2UI.pushSnapshot('before thumbs-down');
// Use control surface params if available, else legacy // Use control surface params if available, else legacy
const csParams = controlSurface ? controlSurface.getParams() : null; const csParams = controlSurface ? controlSurface.getParams() : null;
const noiseCap = csParams ? csParams.noiseCap : (0.3 * (1 - spreadLevel) + 0.05 * spreadLevel); const noiseCap = csParams ? csParams.noiseCap : (0.3 * (1 - spreadLevel) + 0.05 * spreadLevel);
@ -1560,10 +1742,15 @@ function onThumbsDown() {
if (csParams && csParams.zoomAwareFeedback && inputPipeline) { if (csParams && csParams.zoomAwareFeedback && inputPipeline) {
effectiveGrowth *= inputPipeline.getZoomLevel(); effectiveGrowth *= inputPipeline.getZoomLevel();
} }
// Phase 3: pressure/hold modulates noise growth
const p3DownIntensity = phase3 ? phase3.pressure.getIntensity() : 1.0;
effectiveGrowth *= p3DownIntensity;
noiseLevel = Math.min(noiseLevel * effectiveGrowth, noiseCap); noiseLevel = Math.min(noiseLevel * effectiveGrowth, noiseCap);
iml.moveWeights(noiseLevel, spreadLevel); // Phase 2: pass param pin mask to moveWeights (pinned outputs are not perturbed)
const pinMask = paramPins ? paramPins.getPinMask() : null;
iml.moveWeights(noiseLevel, spreadLevel, pinMask);
const outputs = iml.getOutputs(); const outputs = iml.getOutputs();
routeOutputs(outputs); routeOutputs(outputs);
@ -1572,6 +1759,7 @@ function onThumbsDown() {
updateStatus(); updateStatus();
updateNoiseRing(); updateNoiseRing();
flash('btn-thumbsdown'); flash('btn-thumbsdown');
if (phase3) phase3.updateHeatmap();
} }
// ---- Training ---- // ---- Training ----
@ -1581,8 +1769,39 @@ function trainModel() {
} }
// Async — used for interactive training (thumbs-up, train button) // Async — used for interactive training (thumbs-up, train button)
// Phase 2: includes pinned region examples and freezes pinned param labels.
function trainModelAsync(onDone) { function trainModelAsync(onDone) {
// Phase 2: Inject pinned region examples into the dataset before training.
// These anchor behavior in pinned regions and can't be evicted by FIFO.
if (regionPins && regionPins.count > 0) {
const pinned = regionPins.getPinnedExamples();
for (let i = 0; i < pinned.features.length; i++) {
iml.addExample(pinned.features[i], pinned.labels[i]);
}
}
// Phase 2: If any output params are pinned, freeze their labels to current
// inferred values so the network maintains its mapping for those outputs.
if (paramPins && paramPins.pinnedCount > 0) {
const mask = paramPins.getPinMask();
const currentOutputs = iml.getOutputs();
const labels = iml.dataset.labels;
for (let i = 0; i < labels.length; i++) {
for (let j = 0; j < labels[i].length; j++) {
if (mask[j]) {
labels[i][j] = currentOutputs[j];
}
}
}
}
// Phase 4: Capture weights before training for gradient flow analysis
if (phase4) phase4.captureBeforeTrain();
iml.trainAsync(({ loss, outputs }) => { iml.trainAsync(({ loss, outputs }) => {
// Phase 4: Capture weights after training for gradient flow analysis
if (phase4) phase4.captureAfterTrain();
routeOutputs(outputs); routeOutputs(outputs);
updateHeatmap(outputs); updateHeatmap(outputs);
syncRawParamsFromOutputs(outputs); syncRawParamsFromOutputs(outputs);
@ -2468,6 +2687,11 @@ function wireHelp() {
// ---- Animation ---- // ---- Animation ----
function animate() { function animate() {
// Phase 3: tick auto-explore timer and pressure indicators
const _animNow = performance.now();
const _animDt = _lastFrameTime > 0 ? (_animNow - _lastFrameTime) / 1000 : 1 / 60;
if (phase3) phase3.tick(_animDt);
if (gamepad) gamepad.poll(); if (gamepad) gamepad.poll();
if (outputMode === 'synth') { if (outputMode === 'synth') {
synthVisualizer.draw(); synthVisualizer.draw();
@ -2511,6 +2735,14 @@ function saveState() {
synthPresetId: activeSynthPresetId, synthPresetId: activeSynthPresetId,
controlSurface: controlSurface ? controlSurface.getState() : null, controlSurface: controlSurface ? controlSurface.getState() : null,
inputPipeline: inputPipeline ? inputPipeline.getConfig() : null, inputPipeline: inputPipeline ? inputPipeline.getConfig() : null,
// Phase 2: Pinning + History
snapshotStack: snapshotStack ? snapshotStack.getState() : null,
regionPins: regionPins ? regionPins.getState() : null,
paramPins: paramPins ? paramPins.getState() : null,
// Phase 3
phase3: phase3 ? phase3.getConfig() : null,
// Phase 4
outputPipeline: outputPipeline ? outputPipeline.getConfig() : null,
}; };
localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (e) { } catch (e) {
@ -2589,6 +2821,25 @@ function loadState() {
inputPipeline.setConfig(state.inputPipeline); inputPipeline.setConfig(state.inputPipeline);
} }
// Phase 2: Restore pinning + history state
if (state.snapshotStack && snapshotStack) {
snapshotStack.setState(state.snapshotStack);
}
if (state.regionPins && regionPins) {
regionPins.setState(state.regionPins);
}
if (state.paramPins && paramPins) {
paramPins.setState(state.paramPins);
}
// Phase 3: restore config
if (state.phase3 && phase3) {
phase3.setConfig(state.phase3);
}
// Phase 4: restore output pipeline
if (state.outputPipeline && outputPipeline) {
outputPipeline.setConfig(state.outputPipeline);
}
// Note: don't auto-restore inputMode='hands' — requires camera permission // Note: don't auto-restore inputMode='hands' — requires camera permission
console.log(`[NISPS] Restored ${state.features?.length || 0} joy examples, ${state.handFeatures?.length || 0} hand examples from storage`); console.log(`[NISPS] Restored ${state.features?.length || 0} joy examples, ${state.handFeatures?.length || 0} hand examples from storage`);
} catch (e) { } catch (e) {

View file

@ -159,8 +159,9 @@ export class IML {
// Add Gaussian noise to weights (for RL exploration) // Add Gaussian noise to weights (for RL exploration)
// spread: 0 = flat noise, 1 = Xavier-scaled per layer // spread: 0 = flat noise, 1 = Xavier-scaled per layer
moveWeights(speed, spread = 0) { // outputPinMask: optional Uint8Array[nOutputs], 1 = skip that output node
this.mlp.moveWeights(speed, spread); moveWeights(speed, spread = 0, outputPinMask = null) {
this.mlp.moveWeights(speed, spread, outputPinMask);
// Run inference to show effect // Run inference to show effect
this.inputUpdated = true; this.inputUpdated = true;
this.process(); this.process();

View file

@ -228,13 +228,22 @@ export class MLP {
// preventing unbounded magnitude drift from repeated thumbs-down. At spread=0 there is // preventing unbounded magnitude drift from repeated thumbs-down. At spread=0 there is
// no decay (original behavior). At spread=1 each call decays weights by ~10%, creating // no decay (original behavior). At spread=1 each call decays weights by ~10%, creating
// a natural equilibrium where exploration can't permanently saturate sigmoid. // a natural equilibrium where exploration can't permanently saturate sigmoid.
moveWeights(speed, spread = 0) { //
// outputPinMask: optional Uint8Array[numOutputs]. If provided, output-layer nodes where
// mask[i] === 1 are skipped (their weights are not perturbed). Only affects the last layer.
moveWeights(speed, spread = 0, outputPinMask = null) {
const decay = 1 - 0.1 * spread; // spread=0 → 1.0 (no decay), spread=1 → 0.9 const decay = 1 - 0.1 * spread; // spread=0 → 1.0 (no decay), spread=1 → 0.9
for (let l = 0; l < this.layers.length; l++) { for (let l = 0; l < this.layers.length; l++) {
const fanIn = this.layersNodes[l]; const fanIn = this.layersNodes[l];
const xavierScale = 1 / Math.sqrt(fanIn); const xavierScale = 1 / Math.sqrt(fanIn);
const layerScale = 1 * (1 - spread) + xavierScale * spread; const layerScale = 1 * (1 - spread) + xavierScale * spread;
for (const node of this.layers[l].nodes) { const isOutputLayer = l === this.layers.length - 1;
const nodes = this.layers[l].nodes;
for (let ni = 0; ni < nodes.length; ni++) {
// Skip pinned output nodes in the final layer
if (isOutputLayer && outputPinMask && outputPinMask[ni]) continue;
const node = nodes[ni];
for (let j = 0; j < node.weights.length; j++) { for (let j = 0; j < node.weights.length; j++) {
// Decay toward zero to prevent magnitude drift // Decay toward zero to prevent magnitude drift
node.weights[j] *= decay; node.weights[j] *= decay;

View file

@ -287,8 +287,47 @@ export class WasmIML {
this.log('Weights randomised.'); this.log('Weights randomised.');
} }
moveWeights(speed, spread = 0) { // outputPinMask: optional Uint8Array[nOutputs], 1 = skip that output node.
// Since WASM moveWeights doesn't support pin masks, we save pinned nodes'
// weights before the call and restore them after.
moveWeights(speed, spread = 0, outputPinMask = null) {
let savedSlices = null;
if (outputPinMask && outputPinMask.some(v => v)) {
// Compute the flat-array offset of the last layer's nodes.
// Flat format: for each layer, for each node: [w0..wN, bias]
const allWeights = this._getFlatWeights();
const lastLayerInputSize = this.layerSizes[this.layerSizes.length - 2];
const numOutputNodes = this.layerSizes[this.layerSizes.length - 1];
const weightsPerOutputNode = lastLayerInputSize + 1; // weights + bias
// Offset of the last layer in the flat array
const lastLayerOffset = this._weightCount - (numOutputNodes * weightsPerOutputNode);
// Save pinned nodes' weight slices
savedSlices = [];
for (let i = 0; i < numOutputNodes; i++) {
if (outputPinMask[i]) {
const start = lastLayerOffset + i * weightsPerOutputNode;
const end = start + weightsPerOutputNode;
savedSlices.push({ start, end, data: allWeights.slice(start, end) });
}
}
}
this._w.moveWeightsSpread(this._mlp, speed, spread); this._w.moveWeightsSpread(this._mlp, speed, spread);
// Restore pinned nodes' weights
if (savedSlices && savedSlices.length > 0) {
const allWeights = this._getFlatWeights();
for (const slice of savedSlices) {
for (let j = 0; j < slice.data.length; j++) {
allWeights[slice.start + j] = slice.data[j];
}
}
this._setFlatWeights(allWeights);
}
this.inputUpdated = true; this.inputUpdated = true;
this.process(); this.process();
} }

View file

@ -0,0 +1,153 @@
// A/B Compare — Rapid toggle between two weight states
//
// Workflow:
// 1. User presses "A" to capture current state as the reference
// 2. User continues exploring (this becomes the live "B" state)
// 3. Toggle switches instantly between A and B
// 4. "Accept B" discards A and exits A/B mode
// 5. "Revert to A" restores the A snapshot and discards B
//
// The state object stored for each side includes weights, noiseLevel, and
// an optional controlState (for any control surface params worth preserving).
//
// Usage:
// import { ABCompare } from './ab-compare.js';
// const ab = new ABCompare();
// ab.captureA({ weights, noiseLevel, controlState });
// // ... user explores ...
// const stateToRestore = ab.toggle(); // switches to A, returns A's state
// const stateToRestore2 = ab.toggle(); // switches back to B, returns B's state
export class ABCompare {
constructor() {
this._a = null; // { weights, noiseLevel, controlState }
this._b = null; // { weights, noiseLevel, controlState }
this._current = null; // 'a' | 'b' | null (null = inactive)
}
// ---- Public API ----
/**
* Capture the current state as side "A" and enter A/B mode.
* The live state after this call becomes "B".
* @param {{ weights: Array, noiseLevel: number, controlState?: any }} state
*/
captureA(state) {
this._a = {
weights: Array.isArray(state.weights)
? [...state.weights]
: Array.from(state.weights),
noiseLevel: state.noiseLevel ?? 0,
controlState: state.controlState ?? null,
};
this._b = null; // B is "live" — captured on first toggle away from B
this._current = 'b'; // We're currently hearing the B (live) side
this._dispatch('ab:activate', { side: 'b' });
}
/**
* Whether A/B mode is currently active.
* @returns {boolean}
*/
get active() {
return this._current !== null;
}
/**
* Which side is currently active: 'a', 'b', or null if inactive.
* @returns {'a'|'b'|null}
*/
get current() {
return this._current;
}
/**
* Toggle between A and B. Returns the state for whichever side we're
* switching TO, so the caller can restore weights/noise from it.
*
* The caller must pass the current live state so we can snapshot whichever
* side we're leaving.
*
* @param {{ weights: Array, noiseLevel: number, controlState?: any }} currentLiveState
* @returns {{ weights: Array, noiseLevel: number, controlState?: any }|null}
*/
toggle(currentLiveState) {
if (!this.active) return null;
if (this._current === 'b') {
// Switching B → A: save current live state as B, return A
this._b = {
weights: Array.isArray(currentLiveState.weights)
? [...currentLiveState.weights]
: Array.from(currentLiveState.weights),
noiseLevel: currentLiveState.noiseLevel ?? 0,
controlState: currentLiveState.controlState ?? null,
};
this._current = 'a';
this._dispatch('ab:toggle', { side: 'a' });
return this._cloneState(this._a);
} else {
// Switching A → B: save current live state as A, return B
this._a = {
weights: Array.isArray(currentLiveState.weights)
? [...currentLiveState.weights]
: Array.from(currentLiveState.weights),
noiseLevel: currentLiveState.noiseLevel ?? 0,
controlState: currentLiveState.controlState ?? null,
};
this._current = 'b';
this._dispatch('ab:toggle', { side: 'b' });
return this._cloneState(this._b);
}
}
/**
* Accept B: discard A, exit A/B mode.
* The current live state (B) is kept as-is nothing to restore.
*/
acceptB() {
this._a = null;
this._b = null;
this._current = null;
this._dispatch('ab:deactivate', { accepted: 'b' });
}
/**
* Revert to A: restore A state and discard B, exit A/B mode.
* @returns {{ weights: Array, noiseLevel: number, controlState?: any }|null}
*/
revertToA() {
if (!this._a) return null;
const state = this._cloneState(this._a);
this._a = null;
this._b = null;
this._current = null;
this._dispatch('ab:deactivate', { accepted: 'a' });
return state;
}
/**
* Cancel A/B mode without choosing. Stays on current live state.
*/
cancel() {
this._a = null;
this._b = null;
this._current = null;
this._dispatch('ab:deactivate', { accepted: null });
}
// ---- Internal ----
_cloneState(s) {
if (!s) return null;
return {
weights: [...s.weights],
noiseLevel: s.noiseLevel,
controlState: s.controlState,
};
}
_dispatch(type, detail) {
document.dispatchEvent(new CustomEvent(type, { detail }));
}
}

View file

@ -0,0 +1,160 @@
/**
* Auto-Explore automated thumbs-down at regular intervals.
*
* "Wander" mode for weight space: the system drifts continuously while
* the user only gives thumbs-up when it lands on something good.
*
* Respects noise cap, spread, zoom-aware feedback same rules as manual
* thumbs-down. Intensity scales with zoom: zoomed in = gentler steps,
* zoomed out = bigger leaps.
*
* @module auto-explore
*/
// ---- Constants ----
const MIN_INTERVAL = 0.5; // seconds
const MAX_INTERVAL = 10; // seconds
const DEFAULT_INTERVAL = 2; // seconds
const MIN_INTENSITY = 0.1;
const MAX_INTENSITY = 1.0;
const DEFAULT_INTENSITY = 0.5;
export class AutoExplore {
/**
* @param {object} [options]
* @param {number} [options.interval=2] - seconds between auto-steps
* @param {number} [options.intensity=0.5] - 0.1-1.0, scales noise per step
*/
constructor(options = {}) {
this._interval = clamp(options.interval ?? DEFAULT_INTERVAL, MIN_INTERVAL, MAX_INTERVAL);
this._intensity = clamp(options.intensity ?? DEFAULT_INTENSITY, MIN_INTENSITY, MAX_INTENSITY);
this._active = false;
this._elapsed = 0; // seconds since last auto-step
this._callback = null;
}
// ---- Control ----
/** Start auto-exploration. */
start() {
this._active = true;
this._elapsed = 0;
}
/** Stop auto-exploration. */
stop() {
this._active = false;
this._elapsed = 0;
}
/** Toggle on/off. */
toggle() {
if (this._active) this.stop();
else this.start();
}
/** Whether auto-explore is currently running. */
get active() {
return this._active;
}
// ---- Configuration ----
/**
* Set interval between auto-steps.
* @param {number} seconds - 0.5 to 10
*/
setInterval(seconds) {
this._interval = clamp(seconds, MIN_INTERVAL, MAX_INTERVAL);
}
/** @returns {number} */
getInterval() { return this._interval; }
/**
* Set exploration intensity.
* @param {number} intensity - 0.1 to 1.0
*/
setIntensity(intensity) {
this._intensity = clamp(intensity, MIN_INTENSITY, MAX_INTENSITY);
}
/** @returns {number} */
getIntensity() { return this._intensity; }
// ---- Tick (call every frame) ----
/**
* Advance the auto-explore timer. When the interval elapses, fires
* the registered callback with { intensity, zoomLevel }.
*
* @param {number} deltaTime - seconds since last frame
* @param {number} [zoomLevel=1.0] - current zoom level (0-1)
*/
tick(deltaTime, zoomLevel = 1.0) {
if (!this._active) return;
if (deltaTime <= 0) return;
this._elapsed += deltaTime;
if (this._elapsed >= this._interval) {
this._elapsed -= this._interval;
// Prevent runaway accumulation if tab was backgrounded
if (this._elapsed > this._interval) this._elapsed = 0;
this._fireExplore(zoomLevel);
}
}
// ---- Callback ----
/**
* Register the explore callback. Called when auto-step fires.
* @param {function} callback - receives { intensity }
*/
onExplore(callback) {
this._callback = callback;
}
// ---- Visual state ----
/**
* Progress toward next auto-step (0-1).
* Useful for a countdown/progress ring UI.
* @returns {number}
*/
getProgress() {
if (!this._active) return 0;
return Math.min(1, this._elapsed / this._interval);
}
// ---- Serialization ----
getConfig() {
return {
interval: this._interval,
intensity: this._intensity,
active: this._active,
};
}
setConfig(config) {
if (config.interval != null) this.setInterval(config.interval);
if (config.intensity != null) this.setIntensity(config.intensity);
// Note: we don't auto-start from config — caller decides
}
// ---- Internal ----
_fireExplore(zoomLevel) {
if (!this._callback) return;
// Scale intensity by zoom: zoomed in = gentler, zoomed out = bigger leaps
const scaledIntensity = this._intensity * Math.max(0.05, zoomLevel);
this._callback({ intensity: scaledIntensity });
}
}
// ---- Utility ----
function clamp(v, lo, hi) {
return v < lo ? lo : v > hi ? hi : v;
}

View file

@ -0,0 +1,261 @@
/**
* Gradient Flow Indicator per-layer gradient magnitude visualization.
*
* Uses the weight-delta approach: snapshot weights before training, snapshot after,
* compute per-layer L2 norm of the delta. No WASM changes needed.
*
* @module gradient-flow
*/
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Ratio threshold: if next layer's gradient < VANISHING_RATIO * previous, it's vanishing */
const VANISHING_RATIO = 0.5;
/** Ratio threshold: if next layer's gradient > EXPLODING_RATIO * previous, it's exploding */
const EXPLODING_RATIO = 2.0;
/** Absolute threshold: if all gradient norms below this, network has converged */
const CONVERGED_THRESHOLD = 1e-6;
// ---------------------------------------------------------------------------
// Colors
// ---------------------------------------------------------------------------
const COLOR_HEALTHY = { r: 100, g: 200, b: 120 };
const COLOR_WARNING = { r: 230, g: 200, b: 60 };
const COLOR_DANGER = { r: 240, g: 80, b: 60 };
const COLOR_CONVERGED = { r: 100, g: 140, b: 200 };
// ---------------------------------------------------------------------------
// GradientFlowIndicator
// ---------------------------------------------------------------------------
export class GradientFlowIndicator {
/**
* @param {number[]} layerSizes e.g. [3, 32, 48, 64, 126]
*/
constructor(layerSizes) {
this._layerSizes = layerSizes;
this._numLayers = layerSizes.length - 1; // number of weight matrices
// Compute per-layer weight counts: layer i has layerSizes[i] * layerSizes[i+1] weights + layerSizes[i+1] biases
this._layerWeightCounts = [];
this._layerOffsets = [];
let offset = 0;
for (let i = 0; i < this._numLayers; i++) {
const count = layerSizes[i] * layerSizes[i + 1] + layerSizes[i + 1];
this._layerWeightCounts.push(count);
this._layerOffsets.push(offset);
offset += count;
}
this._totalWeights = offset;
// Build layer labels
this._layerLabels = [];
for (let i = 0; i < this._numLayers; i++) {
if (i === this._numLayers - 1) {
this._layerLabels.push('Out');
} else {
this._layerLabels.push(`L${i + 1}`);
}
}
// Weight snapshots
this._beforeWeights = null;
this._afterWeights = null;
this._flow = null;
}
/**
* Capture weight snapshot before training.
* @param {number[]|Float32Array} weightsArray flat array of all weights
*/
captureBeforeTrain(weightsArray) {
if (!weightsArray) return;
this._beforeWeights = weightsArray instanceof Float32Array
? new Float32Array(weightsArray)
: new Float32Array(weightsArray);
}
/**
* Capture weight snapshot after training and compute gradient flow.
* @param {number[]|Float32Array} weightsArray flat array of all weights
*/
captureAfterTrain(weightsArray) {
if (!weightsArray || !this._beforeWeights) return;
this._afterWeights = weightsArray instanceof Float32Array
? weightsArray
: new Float32Array(weightsArray);
this._computeFlow();
}
/**
* Get per-layer gradient flow info.
* @returns {object|null}
*/
getFlow() {
return this._flow;
}
/**
* Draw per-layer gradient flow bars.
*
* @param {CanvasRenderingContext2D} ctx
* @param {number} x
* @param {number} y
* @param {number} width
* @param {number} height
*/
draw(ctx, x, y, width, height) {
if (!this._flow) {
ctx.fillStyle = 'rgba(60, 60, 60, 0.4)';
ctx.fillRect(x, y, width, height);
ctx.font = '8px monospace';
ctx.fillStyle = 'rgba(120, 120, 120, 0.6)';
ctx.textBaseline = 'middle';
ctx.textAlign = 'center';
ctx.fillText('no grad data', x + width / 2, y + height / 2);
return;
}
const { layers, status } = this._flow;
const n = layers.length;
if (n === 0) return;
// Background
ctx.fillStyle = 'rgba(20, 20, 20, 0.6)';
ctx.fillRect(x, y, width, height);
const labelHeight = 12;
const barAreaHeight = height - labelHeight - 2;
const barWidth = Math.floor((width - 4) / n);
const startX = x + 2 + (width - 4 - barWidth * n) / 2;
for (let i = 0; i < n; i++) {
const layer = layers[i];
const barH = Math.max(1, layer.relativeFlow * barAreaHeight);
const bx = startX + i * barWidth;
const by = y + barAreaHeight - barH + 1;
// Color based on per-layer health
let color;
if (status === 'converged') {
color = COLOR_CONVERGED;
} else if (layer.relativeFlow < 0.15 && i > 0) {
color = COLOR_DANGER; // vanishing at this layer
} else if (layer.relativeFlow > 0.85 && i === n - 1 && n > 1) {
color = COLOR_WARNING; // potential explosion
} else {
color = COLOR_HEALTHY;
}
ctx.fillStyle = `rgba(${color.r}, ${color.g}, ${color.b}, 0.75)`;
ctx.fillRect(bx + 1, by, barWidth - 2, barH);
// Layer label
ctx.font = '7px monospace';
ctx.fillStyle = `rgba(${color.r}, ${color.g}, ${color.b}, 0.7)`;
ctx.textBaseline = 'top';
ctx.textAlign = 'center';
ctx.fillText(this._layerLabels[i], bx + barWidth / 2, y + barAreaHeight + 2);
}
// Status label at top
ctx.font = '8px monospace';
ctx.textBaseline = 'top';
ctx.textAlign = 'left';
let statusColor;
if (status === 'healthy') statusColor = COLOR_HEALTHY;
else if (status === 'vanishing') statusColor = COLOR_DANGER;
else if (status === 'exploding') statusColor = COLOR_WARNING;
else statusColor = COLOR_CONVERGED;
ctx.fillStyle = `rgba(${statusColor.r}, ${statusColor.g}, ${statusColor.b}, 0.8)`;
ctx.fillText(`G: ${status}`, x + 2, y + 1);
}
// -----------------------------------------------------------------------
// Internal
// -----------------------------------------------------------------------
_computeFlow() {
const before = this._beforeWeights;
const after = this._afterWeights;
if (!before || !after || before.length !== after.length) {
this._flow = null;
return;
}
const layers = [];
const norms = [];
for (let li = 0; li < this._numLayers; li++) {
const offset = this._layerOffsets[li];
const count = this._layerWeightCounts[li];
// Compute L2 norm of weight delta for this layer
let sumSq = 0;
for (let j = 0; j < count; j++) {
const idx = offset + j;
if (idx < before.length && idx < after.length) {
const delta = after[idx] - before[idx];
sumSq += delta * delta;
}
}
const norm = Math.sqrt(sumSq);
norms.push(norm);
layers.push({
name: `${this._layerLabels[li]} (${this._layerSizes[li]}\u2192${this._layerSizes[li + 1]})`,
gradientNorm: norm,
relativeFlow: 0, // computed below
});
}
// Compute relative flow (normalize to max)
const maxNorm = Math.max(...norms, 1e-12);
for (let i = 0; i < layers.length; i++) {
layers[i].relativeFlow = norms[i] / maxNorm;
}
// Detect status
let status = 'healthy';
// Check convergence: all norms very small
if (norms.every(n => n < CONVERGED_THRESHOLD)) {
status = 'converged';
} else if (norms.length >= 2) {
// Check vanishing: each successive layer < VANISHING_RATIO of previous
let vanishing = true;
let exploding = true;
for (let i = 1; i < norms.length; i++) {
const prev = norms[i - 1];
const curr = norms[i];
if (prev <= CONVERGED_THRESHOLD) {
// Can't assess ratio with near-zero denominator
vanishing = false;
exploding = false;
break;
}
const ratio = curr / prev;
if (ratio >= VANISHING_RATIO) vanishing = false;
if (ratio <= EXPLODING_RATIO) exploding = false;
}
if (vanishing) status = 'vanishing';
else if (exploding) status = 'exploding';
}
this._flow = { layers, status };
}
}

View file

@ -0,0 +1,382 @@
/**
* Input Space Heatmap 2D color field on the joy-map showing what the
* network produces across the entire input space.
*
* Samples the MLP at a grid of input points, reduces the output vector
* to a color, and renders as a background layer on the joy-map canvas.
*
* Three color modes:
* - luminance: mean output -> brightness (shows "loud" vs "quiet" regions)
* - variance: output variance -> saturation (shows "interesting" vs "flat")
* - divergence: difference from center point output (how each region diverges)
*
* Performance: 16x16 = 256 inferences at ~20us each = ~5ms.
* Throttled to max 5 updates/sec by default.
*
* @module input-heatmap
*/
// ---- Constants ----
const DEFAULT_RESOLUTION = 16;
const MIN_RESOLUTION = 4;
const MAX_RESOLUTION = 32;
const DEFAULT_THROTTLE = 200; // ms
const COLOR_MODES = ['luminance', 'variance', 'divergence'];
// ---- Color palette ----
// Dark-to-warm gradient: dark blue/purple -> amber -> white
// Pre-computed as HSL stops for fast interpolation
const PALETTE = [
{ h: 260, s: 60, l: 8 }, // 0.0 — very dark purple
{ h: 250, s: 65, l: 18 }, // 0.2 — deep blue-purple
{ h: 220, s: 55, l: 30 }, // 0.4 — medium blue
{ h: 35, s: 80, l: 45 }, // 0.6 — warm amber
{ h: 38, s: 90, l: 60 }, // 0.8 — bright amber
{ h: 42, s: 95, l: 85 }, // 1.0 — near-white warm
];
function samplePalette(t) {
const clamped = Math.max(0, Math.min(1, t));
const idx = clamped * (PALETTE.length - 1);
const lo = Math.floor(idx);
const hi = Math.min(lo + 1, PALETTE.length - 1);
const frac = idx - lo;
const a = PALETTE[lo];
const b = PALETTE[hi];
const h = a.h + (b.h - a.h) * frac;
const s = a.s + (b.s - a.s) * frac;
const l = a.l + (b.l - a.l) * frac;
return { h, s, l };
}
function hslToRGB(h, s, l) {
s /= 100;
l /= 100;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = l - c / 2;
let r, g, b;
if (h < 60) { r = c; g = x; b = 0; }
else if (h < 120) { r = x; g = c; b = 0; }
else if (h < 180) { r = 0; g = c; b = x; }
else if (h < 240) { r = 0; g = x; b = c; }
else if (h < 300) { r = x; g = 0; b = c; }
else { r = c; g = 0; b = x; }
return {
r: Math.round((r + m) * 255),
g: Math.round((g + m) * 255),
b: Math.round((b + m) * 255),
};
}
export class InputHeatmap {
/**
* @param {object} [options]
* @param {number} [options.resolution=16] - grid points per axis
* @param {string} [options.colorMode='luminance'] - 'luminance'|'variance'|'divergence'
* @param {number} [options.throttle=200] - min ms between recomputes
* @param {number} [options.opacity=0.55] - heatmap alpha
*/
constructor(options = {}) {
this._resolution = clamp(options.resolution ?? DEFAULT_RESOLUTION, MIN_RESOLUTION, MAX_RESOLUTION);
this._colorMode = COLOR_MODES.includes(options.colorMode) ? options.colorMode : 'luminance';
this._throttle = Math.max(50, options.throttle ?? DEFAULT_THROTTLE);
this._opacity = Math.max(0, Math.min(1, options.opacity ?? 0.55));
this._enabled = false;
this._lastUpdate = 0;
// Cached heatmap data: Float32Array of reduced values (resolution x resolution)
this._grid = null; // raw reduced values per cell
this._gridWindow = null; // zoom window used when computing this grid
this._imageData = null; // cached ImageData for rendering
this._offscreen = null; // offscreen canvas for compositing
this._offscreenCtx = null;
// Center-point output cache (for divergence mode)
this._centerOutput = null;
}
// ---- Public API ----
/**
* Recompute the heatmap. Call on weight changes (train, randomize, moveWeights).
*
* @param {function} inferFn - (inputArray: number[]) => number[]
* Runs inference for a given 2D input. Must NOT corrupt the main inference state.
* @param {object} [options]
* @param {object} [options.zoomWindow] - { x1, y1, x2, y2 } in [0,1] space
* @param {number} [options.resolution] - override resolution for this update
*/
update(inferFn, options = {}) {
if (!this._enabled) return;
const now = performance.now();
if (now - this._lastUpdate < this._throttle) return;
this._lastUpdate = now;
const res = clamp(options.resolution ?? this._resolution, MIN_RESOLUTION, MAX_RESOLUTION);
const zw = options.zoomWindow || { x1: 0, y1: 0, x2: 1, y2: 1 };
// Sample grid
const grid = new Float32Array(res * res);
const outputs = [];
// Pre-compute center output for divergence mode
if (this._colorMode === 'divergence') {
const cx = (zw.x1 + zw.x2) / 2;
const cy = (zw.y1 + zw.y2) / 2;
this._centerOutput = inferFn([cx, cy]);
}
// Collect all outputs for normalization
for (let gy = 0; gy < res; gy++) {
for (let gx = 0; gx < res; gx++) {
// Map grid cell to input space
const inputX = zw.x1 + (gx + 0.5) / res * (zw.x2 - zw.x1);
const inputY = zw.y1 + (gy + 0.5) / res * (zw.y2 - zw.y1);
const out = inferFn([inputX, inputY]);
outputs.push(out);
}
}
// Reduce outputs to scalar values based on color mode
let minVal = Infinity;
let maxVal = -Infinity;
for (let i = 0; i < outputs.length; i++) {
const val = this._reduceOutput(outputs[i]);
grid[i] = val;
if (val < minVal) minVal = val;
if (val > maxVal) maxVal = val;
}
// Normalize to [0,1]
const range = maxVal - minVal;
if (range > 1e-8) {
for (let i = 0; i < grid.length; i++) {
grid[i] = (grid[i] - minVal) / range;
}
} else {
grid.fill(0.5);
}
this._grid = grid;
this._gridWindow = { ...zw };
this._gridRes = res;
// Build ImageData
this._buildImageData(res);
}
/**
* Draw the heatmap onto a canvas context as a background layer.
* Should be called before other joy-map layers.
*
* @param {CanvasRenderingContext2D} ctx
* @param {number} canvasWidth - CSS pixel width
* @param {number} canvasHeight - CSS pixel height
* @param {object} [zoomWindow] - current zoom window { x1, y1, x2, y2 }
*/
draw(ctx, canvasWidth, canvasHeight, zoomWindow) {
if (!this._enabled || !this._imageData) return;
const res = this._gridRes;
if (!this._offscreen || this._offscreen.width !== res || this._offscreen.height !== res) {
this._offscreen = new OffscreenCanvas(res, res);
this._offscreenCtx = this._offscreen.getContext('2d');
}
this._offscreenCtx.putImageData(this._imageData, 0, 0);
// Determine draw rect: if the heatmap was computed for a zoom window,
// draw it into that region of the canvas
const gw = this._gridWindow || { x1: 0, y1: 0, x2: 1, y2: 1 };
// Canvas Y is inverted (y=0 is top, but our y1 is bottom of input space)
const dx = gw.x1 * canvasWidth;
const dy = (1 - gw.y2) * canvasHeight;
const dw = (gw.x2 - gw.x1) * canvasWidth;
const dh = (gw.y2 - gw.y1) * canvasHeight;
ctx.save();
ctx.globalAlpha = this._opacity;
// Use bilinear interpolation for smooth gradients
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'low';
ctx.drawImage(this._offscreen, dx, dy, dw, dh);
ctx.restore();
}
// ---- Color mode ----
/**
* Set the color reduction mode.
* @param {string} mode - 'luminance' | 'variance' | 'divergence'
*/
setColorMode(mode) {
if (!COLOR_MODES.includes(mode)) return;
this._colorMode = mode;
// Invalidate cache so next update recomputes
this._grid = null;
this._imageData = null;
}
/** @returns {string} */
getColorMode() { return this._colorMode; }
/**
* Cycle to next color mode.
* @returns {string} the new mode
*/
cycleColorMode() {
const idx = COLOR_MODES.indexOf(this._colorMode);
const next = COLOR_MODES[(idx + 1) % COLOR_MODES.length];
this.setColorMode(next);
return next;
}
// ---- Toggle ----
/** @param {boolean} enabled */
setEnabled(enabled) {
this._enabled = !!enabled;
if (!this._enabled) {
this._grid = null;
this._imageData = null;
}
}
/** @returns {boolean} */
get enabled() { return this._enabled; }
// ---- Configuration ----
/** @param {number} ms - minimum time between recomputes */
setThrottle(ms) {
this._throttle = Math.max(50, ms);
}
/** @param {number} res - grid points per axis (4-32) */
setResolution(res) {
this._resolution = clamp(res, MIN_RESOLUTION, MAX_RESOLUTION);
}
/** @returns {number} */
getResolution() { return this._resolution; }
/** @param {number} alpha - 0-1 */
setOpacity(alpha) {
this._opacity = Math.max(0, Math.min(1, alpha));
}
/**
* Force a recompute on next update() call (clears throttle timer).
*/
invalidate() {
this._lastUpdate = 0;
}
// ---- Serialization ----
getConfig() {
return {
enabled: this._enabled,
resolution: this._resolution,
colorMode: this._colorMode,
throttle: this._throttle,
opacity: this._opacity,
};
}
setConfig(config) {
if (config.enabled != null) this.setEnabled(config.enabled);
if (config.resolution != null) this.setResolution(config.resolution);
if (config.colorMode != null) this.setColorMode(config.colorMode);
if (config.throttle != null) this.setThrottle(config.throttle);
if (config.opacity != null) this.setOpacity(config.opacity);
}
// ---- Internal ----
/**
* Reduce an output vector to a single scalar based on color mode.
*/
_reduceOutput(output) {
switch (this._colorMode) {
case 'luminance':
return this._meanOutput(output);
case 'variance':
return this._varianceOutput(output);
case 'divergence':
return this._divergenceOutput(output);
default:
return this._meanOutput(output);
}
}
/** Mean of all outputs. */
_meanOutput(output) {
let sum = 0;
for (let i = 0; i < output.length; i++) sum += output[i];
return sum / output.length;
}
/** Variance of outputs (how "interesting" / spread out the values are). */
_varianceOutput(output) {
const mean = this._meanOutput(output);
let sumSq = 0;
for (let i = 0; i < output.length; i++) {
const d = output[i] - mean;
sumSq += d * d;
}
return sumSq / output.length;
}
/** Euclidean distance from center-point output (normalized by dimension). */
_divergenceOutput(output) {
if (!this._centerOutput || this._centerOutput.length !== output.length) {
return this._meanOutput(output);
}
let sumSq = 0;
for (let i = 0; i < output.length; i++) {
const d = output[i] - this._centerOutput[i];
sumSq += d * d;
}
// Normalize: max possible distance for [0,1] outputs = sqrt(N)
return Math.sqrt(sumSq / output.length);
}
/**
* Build an ImageData from the normalized grid values.
*/
_buildImageData(res) {
if (!this._grid) return;
this._imageData = new ImageData(res, res);
const data = this._imageData.data;
for (let gy = 0; gy < res; gy++) {
for (let gx = 0; gx < res; gx++) {
// Grid is stored bottom-to-top (y=0 is bottom of input space)
// ImageData is top-to-bottom, so flip Y
const gridIdx = (res - 1 - gy) * res + gx;
const val = this._grid[gridIdx];
const { h, s, l } = samplePalette(val);
const { r, g, b } = hslToRGB(h, s, l);
const pixIdx = (gy * res + gx) * 4;
data[pixIdx + 0] = r;
data[pixIdx + 1] = g;
data[pixIdx + 2] = b;
data[pixIdx + 3] = 255;
}
}
}
}
// ---- Utility ----
function clamp(v, lo, hi) {
return v < lo ? lo : v > hi ? hi : v;
}

View file

@ -107,6 +107,9 @@ export class JoyMapEnhanced {
this.pinnedRegions = []; this.pinnedRegions = [];
// Optional heatmap layer (InputHeatmap instance)
this._heatmap = null;
// Flash state for tapped trail point // Flash state for tapped trail point
this._flashPoint = null; this._flashPoint = null;
this._flashTime = 0; this._flashTime = 0;
@ -168,6 +171,7 @@ export class JoyMapEnhanced {
ctx.fillRect(0, 0, w, h); ctx.fillRect(0, 0, w, h);
// Layers (back to front): // Layers (back to front):
// 0. Input heatmap (background)
// 1. Dim area outside zoom window // 1. Dim area outside zoom window
// 2. Grid (adapts to zoom) // 2. Grid (adapts to zoom)
// 3. Zoom window border // 3. Zoom window border
@ -181,6 +185,11 @@ export class JoyMapEnhanced {
const zw = this._normalizeZoomWindow(zoomWindow, zoomLevel); const zw = this._normalizeZoomWindow(zoomWindow, zoomLevel);
// Heatmap background layer
if (this._heatmap && this._heatmap.enabled) {
this._heatmap.draw(ctx, w, h, zw);
}
this._drawDimOverlay(ctx, w, h, zw); this._drawDimOverlay(ctx, w, h, zw);
this._drawGrid(ctx, w, h, zw, zoomLevel); this._drawGrid(ctx, w, h, zw, zoomLevel);
this._drawZoomWindowBorder(ctx, w, h, zw); this._drawZoomWindowBorder(ctx, w, h, zw);
@ -256,6 +265,14 @@ export class JoyMapEnhanced {
return null; return null;
} }
/**
* Set a heatmap instance to draw as the background layer.
* @param {InputHeatmap|null} heatmap
*/
setHeatmap(heatmap) {
this._heatmap = heatmap || null;
}
setPinnedRegions(regions) { setPinnedRegions(regions) {
this.pinnedRegions = regions || []; this.pinnedRegions = regions || [];
} }

View file

@ -0,0 +1,312 @@
/**
* Output Pipeline processes MLP outputs before they reach the synth/visualizer.
*
* Pipeline stages (in order):
* 1. Global Curve power curve applied to all outputs
* 2. Output Smoothing per-output EMA (frame-rate-independent)
* 3. Slew Rate Limiting max change per second per output
* 4. Freeze Gate when frozen, outputs don't update
*
* Pure math module no DOM dependencies.
*
* @module output-pipeline
*/
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const DEFAULT_GLOBAL_CURVE = 1.0;
const GLOBAL_CURVE_MIN = 0.2;
const GLOBAL_CURVE_MAX = 5.0;
const DEFAULT_SMOOTHING = 0;
const SMOOTHING_MAX = 0.95;
const DEFAULT_SLEW_RATE = Infinity; // unlimited
const SLEW_RATE_MIN = 0.005;
const REFERENCE_DT = 1 / 60; // 60fps reference for frame-rate-independent EMA
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function clamp(v, lo, hi) {
return v < lo ? lo : v > hi ? hi : v;
}
/**
* Frame-rate-independent EMA.
* Same approach as input pipeline: converts per-frame smoothing factor into
* a time-domain factor so perceived smoothing is consistent regardless of frame rate.
*/
function emaSmooth(prev, raw, smoothing, dt) {
if (smoothing <= 0) return raw;
const effectiveDt = dt > 0 ? dt : REFERENCE_DT;
const alpha = 1 - smoothing;
const alphaEff = 1 - Math.pow(1 - alpha, effectiveDt / REFERENCE_DT);
return prev + alphaEff * (raw - prev);
}
// ---------------------------------------------------------------------------
// OutputPipeline
// ---------------------------------------------------------------------------
export class OutputPipeline {
/**
* @param {number} [numOutputs=126] number of MLP outputs
*/
constructor(numOutputs = 126) {
this._numOutputs = numOutputs;
// Configuration
this._globalCurve = DEFAULT_GLOBAL_CURVE;
this._smoothing = DEFAULT_SMOOTHING;
this._slewRate = DEFAULT_SLEW_RATE;
this._frozen = false;
// Per-output state
this._smoothed = new Float32Array(numOutputs);
this._lastOutput = new Float32Array(numOutputs);
this._initialized = false;
// Per-output freeze mask (for parameter pinning)
this._outputFrozen = new Uint8Array(numOutputs); // 0 = not frozen, 1 = frozen
// Working buffer for processed output (avoid allocation per frame)
this._processed = new Float32Array(numOutputs);
// Store raw outputs separately (for heatmap preview while globally frozen)
this._lastRawAfterCurve = new Float32Array(numOutputs);
}
// -----------------------------------------------------------------------
// Main processing
// -----------------------------------------------------------------------
/**
* Process raw MLP outputs through the pipeline.
*
* @param {number[]|Float32Array} rawOutputs raw MLP output values in [0,1]
* @param {number} deltaTime time since last call in seconds (e.g. 0.016)
* @returns {Float32Array} processed outputs
*/
process(rawOutputs, deltaTime) {
const dt = Math.max(0, deltaTime || 0);
const n = Math.min(rawOutputs.length, this._numOutputs);
// On first call, initialize smoothed state to raw values
if (!this._initialized) {
for (let i = 0; i < n; i++) {
this._smoothed[i] = rawOutputs[i];
this._lastOutput[i] = rawOutputs[i];
}
this._initialized = true;
}
// Stage 1: Global Curve
for (let i = 0; i < n; i++) {
const raw = clamp(rawOutputs[i], 0, 1);
this._lastRawAfterCurve[i] = this._globalCurve === 1.0
? raw
: Math.pow(raw, this._globalCurve);
}
// If globally frozen, return the last non-frozen output
// (raw-after-curve is still updated for preview access)
if (this._frozen) {
this._processed.set(this._lastOutput);
return this._processed;
}
for (let i = 0; i < n; i++) {
// Per-output freeze: skip this output if individually pinned
if (this._outputFrozen[i]) {
this._processed[i] = this._lastOutput[i];
continue;
}
let value = this._lastRawAfterCurve[i];
// Stage 2: Output Smoothing (EMA)
value = emaSmooth(this._smoothed[i], value, this._smoothing, dt);
this._smoothed[i] = value;
// Stage 3: Slew Rate Limiting
if (this._slewRate !== Infinity && isFinite(this._slewRate)) {
const maxDelta = this._slewRate * dt;
if (maxDelta > 0) {
const delta = value - this._lastOutput[i];
if (Math.abs(delta) > maxDelta) {
value = this._lastOutput[i] + Math.sign(delta) * maxDelta;
}
}
}
value = clamp(value, 0, 1);
this._processed[i] = value;
this._lastOutput[i] = value;
}
return this._processed;
}
// -----------------------------------------------------------------------
// Configuration
// -----------------------------------------------------------------------
/**
* Set global curve exponent.
* <1 pushes outputs toward extremes (0 and 1), >1 pushes toward center.
* @param {number} exponent 0.2 to 5.0, default 1.0 (linear)
*/
setGlobalCurve(exponent) {
this._globalCurve = clamp(exponent, GLOBAL_CURVE_MIN, GLOBAL_CURVE_MAX);
}
/** @returns {number} current global curve exponent */
getGlobalCurve() {
return this._globalCurve;
}
/**
* Set output smoothing factor (EMA).
* @param {number} factor 0 (off) to 0.95
*/
setSmoothing(factor) {
this._smoothing = clamp(factor, 0, SMOOTHING_MAX);
}
/** @returns {number} current smoothing factor */
getSmoothing() {
return this._smoothing;
}
/**
* Set slew rate limit (max change per second per output).
* @param {number} maxChangePerSec 0.005 to Infinity, default Infinity (unlimited)
*/
setSlewRate(maxChangePerSec) {
if (maxChangePerSec >= 1.0 || !isFinite(maxChangePerSec)) {
this._slewRate = Infinity;
} else {
this._slewRate = Math.max(SLEW_RATE_MIN, maxChangePerSec);
}
}
/** @returns {number} current slew rate */
getSlewRate() {
return this._slewRate;
}
/**
* Set global freeze state.
* When frozen, process() returns the last non-frozen output.
* @param {boolean} frozen
*/
setFrozen(frozen) {
this._frozen = !!frozen;
}
// -----------------------------------------------------------------------
// State queries
// -----------------------------------------------------------------------
/** @returns {boolean} whether output is globally frozen */
isFrozen() {
return this._frozen;
}
/** @returns {Float32Array} last processed (post-pipeline) output */
getLastOutput() {
return new Float32Array(this._lastOutput);
}
/**
* Get the last raw-after-curve output (useful for heatmap preview while frozen).
* @returns {Float32Array}
*/
getRawPreview() {
return new Float32Array(this._lastRawAfterCurve);
}
// -----------------------------------------------------------------------
// Per-output freeze (parameter pinning)
// -----------------------------------------------------------------------
/**
* Freeze a specific output index (its value will not update).
* @param {number} index
*/
freezeOutput(index) {
if (index >= 0 && index < this._numOutputs) {
this._outputFrozen[index] = 1;
}
}
/**
* Unfreeze a specific output index.
* @param {number} index
*/
unfreezeOutput(index) {
if (index >= 0 && index < this._numOutputs) {
this._outputFrozen[index] = 0;
}
}
/**
* Check if a specific output is frozen.
* @param {number} index
* @returns {boolean}
*/
isOutputFrozen(index) {
return index >= 0 && index < this._numOutputs && this._outputFrozen[index] === 1;
}
// -----------------------------------------------------------------------
// Serialization
// -----------------------------------------------------------------------
/** Export configuration as a plain object (no internal state). */
getConfig() {
return {
globalCurve: this._globalCurve,
smoothing: this._smoothing,
slewRate: this._slewRate === Infinity ? 1.0 : this._slewRate,
frozen: this._frozen,
frozenOutputs: Array.from(this._outputFrozen),
};
}
/** Restore configuration from a plain object. */
setConfig(config) {
if (config.globalCurve != null) this.setGlobalCurve(config.globalCurve);
if (config.smoothing != null) this.setSmoothing(config.smoothing);
if (config.slewRate != null) this.setSlewRate(config.slewRate);
if (config.frozen != null) this.setFrozen(config.frozen);
if (Array.isArray(config.frozenOutputs)) {
for (let i = 0; i < config.frozenOutputs.length && i < this._numOutputs; i++) {
this._outputFrozen[i] = config.frozenOutputs[i] ? 1 : 0;
}
}
}
// -----------------------------------------------------------------------
// Reset
// -----------------------------------------------------------------------
/** Reset all settings to defaults and clear internal state. */
reset() {
this._globalCurve = DEFAULT_GLOBAL_CURVE;
this._smoothing = DEFAULT_SMOOTHING;
this._slewRate = DEFAULT_SLEW_RATE;
this._frozen = false;
this._smoothed.fill(0);
this._lastOutput.fill(0);
this._lastRawAfterCurve.fill(0);
this._outputFrozen.fill(0);
this._processed.fill(0);
this._initialized = false;
}
}

View file

@ -0,0 +1,151 @@
// Parameter Pin Manager — Per-output pin flags
//
// Pinned parameters keep their current mapping during moveWeights and training.
// During moveWeights, weights in the final layer connecting to pinned output nodes
// are skipped. During training, pinned outputs have their labels frozen to current
// inferred values so the network maintains the learned mapping.
//
// For the WASM path (which doesn't support pin masks natively), the approach is:
// 1. Before moveWeights: snapshot weights for pinned output nodes' final-layer connections
// 2. After moveWeights: restore those weights
// This is handled by the app integration, not inside this module.
//
// Usage:
// import { ParamPinManager } from './param-pin.js';
// const paramPins = new ParamPinManager(126);
// paramPins.pin(42); // pin output #42
// paramPins.toggle(42); // unpin
// const mask = paramPins.getPinMask(); // Uint8Array[126], 1 = pinned
export class ParamPinManager {
/**
* @param {number} numOutputs Total number of output parameters
*/
constructor(numOutputs) {
this._numOutputs = numOutputs;
this._pinned = new Uint8Array(numOutputs); // 0 = unpinned, 1 = pinned
}
// ---- Public API ----
/**
* Pin a specific output index.
* @param {number} outputIndex
*/
pin(outputIndex) {
if (outputIndex < 0 || outputIndex >= this._numOutputs) return;
if (this._pinned[outputIndex] === 1) return; // already pinned
this._pinned[outputIndex] = 1;
this._dispatch('parampin:change', {
index: outputIndex,
pinned: true,
totalPinned: this.pinnedCount,
});
}
/**
* Unpin a specific output index.
* @param {number} outputIndex
*/
unpin(outputIndex) {
if (outputIndex < 0 || outputIndex >= this._numOutputs) return;
if (this._pinned[outputIndex] === 0) return; // already unpinned
this._pinned[outputIndex] = 0;
this._dispatch('parampin:change', {
index: outputIndex,
pinned: false,
totalPinned: this.pinnedCount,
});
}
/**
* Toggle pin state for a specific output index.
* @param {number} outputIndex
* @returns {boolean} New pin state (true = pinned).
*/
toggle(outputIndex) {
if (outputIndex < 0 || outputIndex >= this._numOutputs) return false;
if (this._pinned[outputIndex]) {
this.unpin(outputIndex);
return false;
} else {
this.pin(outputIndex);
return true;
}
}
/**
* Check if a specific output is pinned.
* @param {number} outputIndex
* @returns {boolean}
*/
isPinned(outputIndex) {
if (outputIndex < 0 || outputIndex >= this._numOutputs) return false;
return this._pinned[outputIndex] === 1;
}
/**
* Get the full pin mask.
* @returns {Uint8Array} Length = numOutputs. 1 = pinned, 0 = unpinned.
*/
getPinMask() {
return this._pinned;
}
/**
* Get array of pinned output indices.
* @returns {number[]}
*/
getPinnedIndices() {
const indices = [];
for (let i = 0; i < this._numOutputs; i++) {
if (this._pinned[i]) indices.push(i);
}
return indices;
}
/**
* Number of currently pinned outputs.
* @returns {number}
*/
get pinnedCount() {
let count = 0;
for (let i = 0; i < this._numOutputs; i++) {
if (this._pinned[i]) count++;
}
return count;
}
/**
* Unpin all outputs.
*/
clearAll() {
this._pinned.fill(0);
this._dispatch('parampin:clearall', { totalPinned: 0 });
}
// ---- Serialization ----
getState() {
return {
numOutputs: this._numOutputs,
pinned: Array.from(this._pinned),
};
}
setState(saved) {
if (!saved) return;
if (Array.isArray(saved.pinned)) {
const len = Math.min(saved.pinned.length, this._numOutputs);
for (let i = 0; i < len; i++) {
this._pinned[i] = saved.pinned[i] ? 1 : 0;
}
}
}
// ---- Internal ----
_dispatch(type, detail) {
document.dispatchEvent(new CustomEvent(type, { detail }));
}
}

View file

@ -0,0 +1,626 @@
// Phase 2 UI — Undo button, A/B Compare toggle, Region Pin, Parameter Pin icons
//
// Adds Phase 2 controls to the immersive app:
// - Undo button (near RL thumbs) with badge showing snapshot depth + long-press list
// - A/B toggle button pair near RL buttons
// - Region pin: long-press on joy-map (>500ms) pins the current zoom window
// - Parameter pin: pin icon overlay on synth visualizer bars
//
// All state management is delegated to the data modules (SnapshotStack, ABCompare,
// RegionPinManager, ParamPinManager). This module only builds DOM and dispatches events.
//
// Usage:
// import { initPhase2UI } from './phase2-ui.js';
// const phase2 = initPhase2UI({
// snapshotStack, abCompare, regionPins, paramPins,
// getWeights, setWeights, getNoiseLevel, setNoiseLevel,
// getZoomWindow, onRegionPin, onParamPinToggle,
// });
export function initPhase2UI(options) {
const {
snapshotStack,
abCompare,
regionPins,
paramPins,
// Callbacks the app provides so we can read/write state
getWeights, // () => Array<number> (flat weights)
setWeights, // (weights: Array<number>) => void
getNoiseLevel, // () => number
setNoiseLevel, // (level: number) => void
getZoomLevel, // () => number
getZoomWindow, // () => { x1, y1, x2, y2 } | null
getTrainingData, // () => { features, labels }
runInference, // () => void (re-run inference after weight change)
joyMapCanvas, // the joy-map canvas element (for long-press detection)
synthVisualizer, // SynthVisualizer instance (for param pin overlay)
} = options;
// ---- Inject styles ----
injectPhase2Styles();
// ==== 1. UNDO BUTTON ====
const $rlButtons = document.getElementById('rl-buttons');
const $undoBtn = document.createElement('button');
$undoBtn.className = 'rl-btn phase2-undo-btn';
$undoBtn.title = 'Undo (long-press for history)';
$undoBtn.innerHTML = `
<span class="rl-icon phase2-undo-icon">&#8617;</span>
<span class="phase2-undo-badge" id="phase2-undo-badge">0</span>
`;
// Insert undo button at the start of the RL buttons container
if ($rlButtons) {
$rlButtons.insertBefore($undoBtn, $rlButtons.firstChild);
}
const $undoBadge = $undoBtn.querySelector('#phase2-undo-badge');
function updateUndoBadge() {
const depth = snapshotStack.depth;
$undoBadge.textContent = depth;
$undoBadge.classList.toggle('hidden', depth === 0);
$undoBtn.disabled = depth === 0;
}
updateUndoBadge();
// Tap: single undo
$undoBtn.addEventListener('click', () => {
const state = snapshotStack.pop();
if (state) {
setWeights(state.weights);
setNoiseLevel(state.noiseLevel);
runInference();
}
updateUndoBadge();
});
// Long-press: show snapshot list popup
let _undoLongPressTimer = null;
let _undoLongPressActive = false;
$undoBtn.addEventListener('pointerdown', (e) => {
_undoLongPressActive = false;
_undoLongPressTimer = setTimeout(() => {
_undoLongPressActive = true;
showSnapshotListPopup();
}, 500);
});
$undoBtn.addEventListener('pointerup', () => {
clearTimeout(_undoLongPressTimer);
});
$undoBtn.addEventListener('pointerleave', () => {
clearTimeout(_undoLongPressTimer);
});
// Prevent click from firing after long-press
$undoBtn.addEventListener('click', (e) => {
if (_undoLongPressActive) {
e.stopImmediatePropagation();
_undoLongPressActive = false;
}
}, true);
// Snapshot list popup
let $snapshotPopup = null;
function showSnapshotListPopup() {
removeSnapshotPopup();
const snapshots = snapshotStack.list();
if (snapshots.length === 0) return;
$snapshotPopup = document.createElement('div');
$snapshotPopup.className = 'phase2-snapshot-popup';
const header = document.createElement('div');
header.className = 'phase2-snapshot-popup-header';
header.textContent = 'Snapshot History';
$snapshotPopup.appendChild(header);
const list = document.createElement('div');
list.className = 'phase2-snapshot-list';
// Show newest first
for (let i = snapshots.length - 1; i >= 0; i--) {
const snap = snapshots[i];
const item = document.createElement('button');
item.className = 'phase2-snapshot-item';
const age = formatAge(snap.timestamp);
item.innerHTML = `
<span class="phase2-snap-tag">${escapeHtml(snap.tag)}</span>
<span class="phase2-snap-age">${age}</span>
`;
item.addEventListener('click', () => {
const state = snapshotStack.jumpTo(snap.index);
if (state) {
setWeights(state.weights);
setNoiseLevel(state.noiseLevel);
runInference();
}
updateUndoBadge();
removeSnapshotPopup();
});
list.appendChild(item);
}
$snapshotPopup.appendChild(list);
document.body.appendChild($snapshotPopup);
// Position near the undo button
const rect = $undoBtn.getBoundingClientRect();
$snapshotPopup.style.left = `${rect.left}px`;
$snapshotPopup.style.bottom = `${window.innerHeight - rect.top + 8}px`;
// Close on click outside
setTimeout(() => {
document.addEventListener('pointerdown', handlePopupOutsideClick);
}, 50);
}
function handlePopupOutsideClick(e) {
if ($snapshotPopup && !$snapshotPopup.contains(e.target)) {
removeSnapshotPopup();
}
}
function removeSnapshotPopup() {
if ($snapshotPopup) {
$snapshotPopup.remove();
$snapshotPopup = null;
document.removeEventListener('pointerdown', handlePopupOutsideClick);
}
}
// Listen for snapshot events to update badge
document.addEventListener('snapshot:push', updateUndoBadge);
document.addEventListener('snapshot:pop', updateUndoBadge);
document.addEventListener('snapshot:jump', updateUndoBadge);
document.addEventListener('snapshot:clear', updateUndoBadge);
// ==== 2. A/B COMPARE TOGGLE ====
const $abContainer = document.createElement('div');
$abContainer.className = 'phase2-ab-container';
$abContainer.innerHTML = `
<button class="phase2-ab-btn phase2-ab-capture" id="phase2-ab-capture" title="Capture A (reference)">A</button>
<button class="phase2-ab-btn phase2-ab-toggle hidden" id="phase2-ab-toggle" title="Toggle A/B">A/B</button>
<button class="phase2-ab-btn phase2-ab-accept hidden" id="phase2-ab-accept" title="Accept B (keep current)">Accept B</button>
<button class="phase2-ab-btn phase2-ab-revert hidden" id="phase2-ab-revert" title="Revert to A">Revert A</button>
`;
if ($rlButtons) {
$rlButtons.appendChild($abContainer);
}
const $abCapture = $abContainer.querySelector('#phase2-ab-capture');
const $abToggle = $abContainer.querySelector('#phase2-ab-toggle');
const $abAccept = $abContainer.querySelector('#phase2-ab-accept');
const $abRevert = $abContainer.querySelector('#phase2-ab-revert');
function updateABUI() {
const active = abCompare.active;
const side = abCompare.current;
$abCapture.classList.toggle('hidden', active);
$abToggle.classList.toggle('hidden', !active);
$abAccept.classList.toggle('hidden', !active);
$abRevert.classList.toggle('hidden', !active);
if (active) {
$abToggle.textContent = side === 'a' ? 'A' : 'B';
$abToggle.classList.toggle('phase2-ab-side-a', side === 'a');
$abToggle.classList.toggle('phase2-ab-side-b', side === 'b');
}
}
$abCapture.addEventListener('click', () => {
abCompare.captureA({
weights: getWeights(),
noiseLevel: getNoiseLevel(),
});
updateABUI();
});
$abToggle.addEventListener('click', () => {
const state = abCompare.toggle({
weights: getWeights(),
noiseLevel: getNoiseLevel(),
});
if (state) {
setWeights(state.weights);
setNoiseLevel(state.noiseLevel);
runInference();
}
updateABUI();
});
$abAccept.addEventListener('click', () => {
abCompare.acceptB();
updateABUI();
});
$abRevert.addEventListener('click', () => {
const state = abCompare.revertToA();
if (state) {
setWeights(state.weights);
setNoiseLevel(state.noiseLevel);
runInference();
}
updateABUI();
});
// Listen for AB events
document.addEventListener('ab:activate', updateABUI);
document.addEventListener('ab:toggle', updateABUI);
document.addEventListener('ab:deactivate', updateABUI);
// ==== 3. REGION PIN (long-press on joy-map) ====
let _regionLongPressTimer = null;
if (joyMapCanvas) {
joyMapCanvas.addEventListener('pointerdown', (e) => {
_regionLongPressTimer = setTimeout(() => {
// Pin the current zoom window as a region
const zoomWindow = getZoomWindow ? getZoomWindow() : null;
let region;
if (zoomWindow) {
region = {
x1: zoomWindow.x1,
y1: zoomWindow.y1,
x2: zoomWindow.x2,
y2: zoomWindow.y2,
};
} else {
// No zoom — pin the full space (not very useful, but valid)
region = { x1: 0, y1: 0, x2: 1, y2: 1 };
}
// Snapshot before pinning
snapshotStack.push('before region pin', {
weights: getWeights(),
noiseLevel: getNoiseLevel(),
zoomLevel: getZoomLevel ? getZoomLevel() : 1.0,
});
updateUndoBadge();
const data = getTrainingData ? getTrainingData() : { features: [], labels: [] };
const pinId = regionPins.pin(region, data);
if (pinId >= 0) {
// Flash feedback
joyMapCanvas.classList.add('phase2-pin-flash');
setTimeout(() => joyMapCanvas.classList.remove('phase2-pin-flash'), 300);
// Update joy-map overlay
syncJoyMapPins();
}
}, 500);
});
joyMapCanvas.addEventListener('pointerup', () => {
clearTimeout(_regionLongPressTimer);
});
joyMapCanvas.addEventListener('pointerleave', () => {
clearTimeout(_regionLongPressTimer);
});
// Prevent context menu on long-press
joyMapCanvas.addEventListener('contextmenu', (e) => e.preventDefault());
}
// Keep joy-map overlay in sync when pins change
function syncJoyMapPins() {
document.dispatchEvent(new CustomEvent('regionpin:sync', {
detail: { regions: regionPins.getRegions() },
}));
}
document.addEventListener('regionpin:add', syncJoyMapPins);
document.addEventListener('regionpin:remove', syncJoyMapPins);
// ==== 4. PARAMETER PIN (overlay on synth visualizer) ====
// Param pin state is tracked by paramPins (ParamPinManager).
// We add a click handler to the synth visualizer canvas to toggle pins.
// The SynthVisualizer's draw loop can check paramPins.isPinned(i) for rendering.
if (synthVisualizer && synthVisualizer.canvas) {
// Double-tap on a param bar to toggle pin
let _lastParamTapTime = 0;
let _lastParamTapIndex = -1;
synthVisualizer.canvas.addEventListener('pointerdown', (e) => {
const idx = synthVisualizer.hitTest(e.clientX, e.clientY);
if (idx < 0) return;
const now = Date.now();
if (now - _lastParamTapTime < 400 && idx === _lastParamTapIndex) {
// Double-tap: toggle pin
const newState = paramPins.toggle(idx);
// Visual feedback is handled by the draw loop checking paramPins
_lastParamTapTime = 0;
_lastParamTapIndex = -1;
} else {
_lastParamTapTime = now;
_lastParamTapIndex = idx;
}
});
}
// ==== PUBLIC API ====
/**
* Push a snapshot (called by app before destructive operations).
* @param {string} tag
*/
function pushSnapshot(tag) {
snapshotStack.push(tag, {
weights: getWeights(),
noiseLevel: getNoiseLevel(),
zoomLevel: getZoomLevel ? getZoomLevel() : 1.0,
});
updateUndoBadge();
}
/**
* Cleanup all Phase 2 UI elements.
*/
function destroy() {
$undoBtn.remove();
$abContainer.remove();
removeSnapshotPopup();
document.removeEventListener('snapshot:push', updateUndoBadge);
document.removeEventListener('snapshot:pop', updateUndoBadge);
document.removeEventListener('snapshot:jump', updateUndoBadge);
document.removeEventListener('snapshot:clear', updateUndoBadge);
document.removeEventListener('ab:activate', updateABUI);
document.removeEventListener('ab:toggle', updateABUI);
document.removeEventListener('ab:deactivate', updateABUI);
document.removeEventListener('regionpin:add', syncJoyMapPins);
document.removeEventListener('regionpin:remove', syncJoyMapPins);
const styleEl = document.getElementById('phase2-styles');
if (styleEl) styleEl.remove();
}
return {
pushSnapshot,
updateUndoBadge,
syncJoyMapPins,
destroy,
};
}
// ---- Helpers ----
function formatAge(timestamp) {
const seconds = Math.floor((Date.now() - timestamp) / 1000);
if (seconds < 5) return 'just now';
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
return `${Math.floor(minutes / 60)}h ago`;
}
function escapeHtml(str) {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// ---- CSS ----
function injectPhase2Styles() {
if (document.getElementById('phase2-styles')) return;
const style = document.createElement('style');
style.id = 'phase2-styles';
style.textContent = `
/* ==== Phase 2: Undo Button ==== */
.phase2-undo-btn {
position: relative;
}
.phase2-undo-icon {
font-size: 18px;
line-height: 1;
}
.phase2-undo-badge {
position: absolute;
top: -4px;
right: -4px;
min-width: 16px;
height: 16px;
border-radius: 8px;
background: var(--accent, #ff6a00);
color: #000;
font-size: 9px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
padding: 0 3px;
pointer-events: none;
transition: opacity 0.15s;
}
.phase2-undo-badge.hidden {
opacity: 0;
}
.phase2-undo-btn:disabled {
opacity: 0.3;
pointer-events: none;
}
/* ==== Phase 2: Snapshot Popup ==== */
.phase2-snapshot-popup {
position: fixed;
z-index: 100;
width: 240px;
max-height: 300px;
background: rgba(0, 0, 0, 0.92);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
overflow: hidden;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
font-family: 'JetBrains Mono', 'SF Mono', monospace;
}
.phase2-snapshot-popup-header {
padding: 10px 12px 8px;
font-size: 10px;
font-weight: 600;
color: rgba(255, 255, 255, 0.6);
text-transform: uppercase;
letter-spacing: 0.8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.phase2-snapshot-list {
overflow-y: auto;
max-height: 250px;
-webkit-overflow-scrolling: touch;
}
.phase2-snapshot-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 8px 12px;
border: none;
background: transparent;
color: rgba(255, 255, 255, 0.8);
font-family: inherit;
font-size: 11px;
cursor: pointer;
transition: background 0.1s;
text-align: left;
}
.phase2-snapshot-item:hover {
background: rgba(255, 255, 255, 0.06);
}
.phase2-snap-tag {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: 8px;
}
.phase2-snap-age {
font-size: 9px;
color: rgba(255, 255, 255, 0.35);
white-space: nowrap;
}
/* ==== Phase 2: A/B Compare ==== */
.phase2-ab-container {
display: flex;
gap: 4px;
align-items: center;
margin-left: 6px;
}
.phase2-ab-btn {
height: 36px;
min-width: 36px;
border-radius: 18px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: var(--glass-bg, rgba(13, 13, 13, 0.65));
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
color: rgba(255, 255, 255, 0.7);
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-size: 11px;
font-weight: 600;
cursor: pointer;
padding: 0 10px;
transition: all 0.15s;
white-space: nowrap;
}
.phase2-ab-btn:hover {
border-color: rgba(255, 255, 255, 0.25);
color: white;
}
.phase2-ab-btn.hidden {
display: none;
}
/* A/B side indicators */
.phase2-ab-side-a {
border-color: #4488ff;
color: #4488ff;
background: rgba(68, 136, 255, 0.12);
}
.phase2-ab-side-b {
border-color: #ff8844;
color: #ff8844;
background: rgba(255, 136, 68, 0.12);
}
.phase2-ab-capture {
border-color: rgba(68, 136, 255, 0.4);
color: rgba(68, 136, 255, 0.8);
}
.phase2-ab-accept {
border-color: rgba(139, 195, 74, 0.4);
color: rgba(139, 195, 74, 0.9);
font-size: 10px;
}
.phase2-ab-revert {
border-color: rgba(255, 152, 0, 0.4);
color: rgba(255, 152, 0, 0.9);
font-size: 10px;
}
/* ==== Phase 2: Region Pin Flash ==== */
.phase2-pin-flash {
animation: phase2-pin-flash-anim 0.3s ease-out;
}
@keyframes phase2-pin-flash-anim {
0% { box-shadow: 0 0 0 0 rgba(0, 188, 212, 0.5); }
100% { box-shadow: 0 0 0 12px rgba(0, 188, 212, 0); }
}
/* ==== Phase 2: Param Pin overlay (rendered on synth visualizer) ==== */
/* Lock icon styling — drawn by SynthVisualizer, these are for any DOM overlays */
.phase2-param-pinned-bar {
opacity: 0.5;
}
/* ==== Responsive ==== */
@media (max-width: 600px) {
.phase2-ab-container {
gap: 3px;
margin-left: 4px;
}
.phase2-ab-btn {
height: 32px;
min-width: 32px;
font-size: 10px;
padding: 0 8px;
}
.phase2-snapshot-popup {
width: 200px;
}
}
`;
document.head.appendChild(style);
}

View file

@ -0,0 +1,588 @@
/**
* Phase 3 UI Integration Auto-Explore button, Heatmap toggle,
* Pressure indicator, and settings drawer additions.
*
* Wires PressureFeedback, AutoExplore, and InputHeatmap into the
* immersive app's DOM and event flow.
*
* Usage:
* import { initPhase3UI } from './ui/phase3-ui.js';
* const p3 = initPhase3UI({
* onAutoExplore: ({ intensity }) => { ... },
* getZoomLevel: () => pipeline.getZoomLevel(),
* });
* // In animation loop:
* p3.tick(deltaTime);
*
* @module phase3-ui
*/
import { PressureFeedback } from './pressure-feedback.js';
import { AutoExplore } from './auto-explore.js';
import { InputHeatmap } from './input-heatmap.js';
// ---- Style injection ----
const PHASE3_STYLES = `
/* ---- Auto-Explore button ---- */
.auto-explore-btn {
width: 56px;
height: 56px;
border-radius: 50%;
border: 2px solid rgba(52, 211, 153, 0.4);
background: rgba(13, 13, 13, 0.65);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
color: rgba(52, 211, 153, 0.7);
font-family: inherit;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.3px;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
transition: all 0.2s ease;
position: relative;
overflow: visible;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
}
.auto-explore-btn:active {
transform: scale(0.9);
}
.auto-explore-btn.active {
border-color: rgba(52, 211, 153, 0.7);
background: rgba(52, 211, 153, 0.15);
color: rgba(52, 211, 153, 1);
box-shadow: 0 0 16px rgba(52, 211, 153, 0.3);
}
.auto-explore-btn.active .ae-icon {
animation: ae-wander 2s ease-in-out infinite;
}
@keyframes ae-wander {
0%, 100% { transform: translate(0, 0); }
25% { transform: translate(2px, -1px); }
50% { transform: translate(-1px, 2px); }
75% { transform: translate(-2px, -1px); }
}
/* Progress ring (SVG overlay) */
.ae-progress-ring {
position: absolute;
inset: -3px;
width: calc(100% + 6px);
height: calc(100% + 6px);
pointer-events: none;
transform: rotate(-90deg);
}
.ae-progress-ring circle {
fill: none;
stroke: rgba(52, 211, 153, 0.6);
stroke-width: 2;
stroke-dasharray: 175;
stroke-dashoffset: 175;
stroke-linecap: round;
transition: stroke-dashoffset 0.1s linear;
}
/* ---- Heatmap toggle ---- */
.heatmap-toggle {
position: absolute;
top: 4px;
right: 4px;
width: 24px;
height: 24px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.15);
background: rgba(13, 13, 13, 0.5);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
color: rgba(255, 255, 255, 0.4);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: 5;
transition: all 0.15s ease;
padding: 0;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
}
.heatmap-toggle:active {
transform: scale(0.85);
}
.heatmap-toggle.active {
border-color: rgba(255, 200, 100, 0.5);
background: rgba(255, 200, 100, 0.15);
color: rgba(255, 200, 100, 0.9);
}
.heatmap-toggle svg {
width: 14px;
height: 14px;
}
/* Color mode label (appears briefly on mode switch) */
.heatmap-mode-label {
position: absolute;
top: 30px;
right: 0;
background: rgba(13, 13, 13, 0.8);
color: rgba(255, 200, 100, 0.8);
font-size: 9px;
padding: 2px 6px;
border-radius: 4px;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s ease;
z-index: 5;
}
.heatmap-mode-label.visible {
opacity: 1;
}
/* ---- Pressure indicator ---- */
.pressure-indicator {
position: absolute;
bottom: -10px;
left: 50%;
transform: translateX(-50%);
width: 40px;
height: 4px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.08);
overflow: hidden;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s ease;
}
.pressure-indicator.visible {
opacity: 1;
}
.pressure-indicator-fill {
height: 100%;
width: 0%;
border-radius: 2px;
background: linear-gradient(90deg, rgba(255, 200, 100, 0.6), rgba(255, 106, 0, 0.8));
transition: width 0.1s ease;
}
/* ---- Settings drawer additions ---- */
.cs-section[data-section="phase3"] .cs-section-header {
color: rgba(52, 211, 153, 0.8);
}
`;
// ---- Eye icon SVG ----
const EYE_SVG = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M1 8s2.5-5 7-5 7 5 7 5-2.5 5-7 5-7-5-7-5z"/>
<circle cx="8" cy="8" r="2"/>
</svg>`;
// ---- Wander icon SVG ----
const WANDER_SVG = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="ae-icon">
<path d="M3 12c2-3 4 1 5-2s3-5 5-3"/>
<circle cx="3" cy="12" r="1.5" fill="currentColor"/>
</svg>`;
/**
* Initialize Phase 3 UI.
*
* @param {object} options
* @param {function} options.onAutoExplore - callback({ intensity }) on auto-step
* @param {function} options.getZoomLevel - () => number (current zoom 0-1)
* @param {function} [options.inferFn] - (inputs) => outputs for heatmap
* @param {function} [options.getZoomWindow] - () => { x1, y1, x2, y2 }
* @returns {{ pressure, autoExplore, heatmap, tick, updateHeatmap, destroy, getConfig, setConfig }}
*/
export function initPhase3UI(options = {}) {
const { onAutoExplore, getZoomLevel, inferFn, getZoomWindow } = options;
// ---- Instances ----
const pressure = new PressureFeedback({ holdEnabled: true, pressureEnabled: false });
const autoExplore = new AutoExplore();
const heatmap = new InputHeatmap();
// Wire auto-explore callback
autoExplore.onExplore((data) => {
if (onAutoExplore) onAutoExplore(data);
});
// ---- Inject styles ----
if (!document.getElementById('phase3-styles')) {
const style = document.createElement('style');
style.id = 'phase3-styles';
style.textContent = PHASE3_STYLES;
document.head.appendChild(style);
}
// ---- Auto-Explore button ----
const $rlButtons = document.getElementById('rl-buttons');
const $autoBtn = document.createElement('button');
$autoBtn.className = 'auto-explore-btn';
$autoBtn.title = 'Auto-Explore: automated wandering';
$autoBtn.innerHTML = `
${WANDER_SVG}
<span style="font-size:9px;line-height:1">Auto</span>
<svg class="ae-progress-ring" viewBox="0 0 62 62">
<circle cx="31" cy="31" r="28"/>
</svg>
`;
if ($rlButtons) {
$rlButtons.appendChild($autoBtn);
}
const $progressCircle = $autoBtn.querySelector('.ae-progress-ring circle');
const circumference = 2 * Math.PI * 28; // r=28
if ($progressCircle) {
$progressCircle.style.strokeDasharray = circumference;
$progressCircle.style.strokeDashoffset = circumference;
}
$autoBtn.addEventListener('click', () => {
autoExplore.toggle();
$autoBtn.classList.toggle('active', autoExplore.active);
});
// ---- Heatmap toggle (on joy-map) ----
const $joystickContainer = document.querySelector('.joystick-container');
let $heatmapToggle = null;
let $hmModeLabel = null;
let _hmLabelTimeout = null;
if ($joystickContainer) {
// Ensure position context
if (getComputedStyle($joystickContainer).position === 'static') {
$joystickContainer.style.position = 'relative';
}
$heatmapToggle = document.createElement('button');
$heatmapToggle.className = 'heatmap-toggle';
$heatmapToggle.title = 'Toggle input heatmap';
$heatmapToggle.innerHTML = EYE_SVG;
$joystickContainer.appendChild($heatmapToggle);
$hmModeLabel = document.createElement('div');
$hmModeLabel.className = 'heatmap-mode-label';
$joystickContainer.appendChild($hmModeLabel);
// Tap to toggle, long-press to cycle color mode
let _pressTimer = null;
let _didLongPress = false;
$heatmapToggle.addEventListener('pointerdown', (e) => {
e.stopPropagation();
_didLongPress = false;
_pressTimer = setTimeout(() => {
_didLongPress = true;
if (heatmap.enabled) {
const mode = heatmap.cycleColorMode();
showModeLabel(mode);
// Trigger recompute
if (inferFn) {
heatmap.invalidate();
const zw = getZoomWindow ? getZoomWindow() : undefined;
heatmap.update(inferFn, { zoomWindow: zw });
}
}
}, 500);
});
$heatmapToggle.addEventListener('pointerup', (e) => {
e.stopPropagation();
clearTimeout(_pressTimer);
if (!_didLongPress) {
heatmap.setEnabled(!heatmap.enabled);
$heatmapToggle.classList.toggle('active', heatmap.enabled);
if (heatmap.enabled && inferFn) {
heatmap.invalidate();
const zw = getZoomWindow ? getZoomWindow() : undefined;
heatmap.update(inferFn, { zoomWindow: zw });
}
}
});
$heatmapToggle.addEventListener('pointercancel', () => {
clearTimeout(_pressTimer);
});
// Prevent joy-map from capturing these events
$heatmapToggle.addEventListener('touchstart', (e) => e.stopPropagation(), { passive: true });
}
function showModeLabel(mode) {
if (!$hmModeLabel) return;
$hmModeLabel.textContent = mode;
$hmModeLabel.classList.add('visible');
clearTimeout(_hmLabelTimeout);
_hmLabelTimeout = setTimeout(() => {
$hmModeLabel.classList.remove('visible');
}, 1500);
}
// ---- Pressure indicators (on RL buttons) ----
const $thumbsUp = document.getElementById('btn-thumbsup');
const $thumbsDown = document.getElementById('btn-thumbsdown');
const pressureIndicators = [];
for (const $btn of [$thumbsUp, $thumbsDown]) {
if (!$btn) continue;
// Ensure position context
if (getComputedStyle($btn).position === 'static') {
$btn.style.position = 'relative';
}
const $indicator = document.createElement('div');
$indicator.className = 'pressure-indicator';
$indicator.innerHTML = '<div class="pressure-indicator-fill"></div>';
$btn.appendChild($indicator);
pressureIndicators.push({ el: $indicator, fill: $indicator.querySelector('.pressure-indicator-fill'), btn: $btn });
// Wire pointer events for pressure tracking
$btn.addEventListener('pointerdown', (e) => pressure.onPointerDown(e));
$btn.addEventListener('pointermove', (e) => pressure.onPointerMove(e));
$btn.addEventListener('pointerup', (e) => {
pressure.onPointerUp(e);
$indicator.classList.remove('visible');
});
$btn.addEventListener('pointercancel', (e) => {
pressure.onPointerUp(e);
$indicator.classList.remove('visible');
});
}
// ---- Settings drawer additions ----
addDrawerSection();
// ---- Frame tick ----
/**
* Call once per animation frame.
* @param {number} deltaTime - seconds since last frame
*/
function tick(deltaTime) {
const zoomLevel = getZoomLevel ? getZoomLevel() : 1.0;
// Auto-explore
autoExplore.tick(deltaTime, zoomLevel);
// Update progress ring
if ($progressCircle && autoExplore.active) {
const progress = autoExplore.getProgress();
const offset = circumference * (1 - progress);
$progressCircle.style.strokeDashoffset = offset;
} else if ($progressCircle) {
$progressCircle.style.strokeDashoffset = circumference;
}
// Update pressure indicators
if (pressure.active && pressure.enabled) {
const intensity = pressure.getIntensity();
for (const { el, fill } of pressureIndicators) {
el.classList.add('visible');
fill.style.width = `${intensity * 100}%`;
}
}
}
/**
* Trigger a heatmap update. Call after weight changes (train, randomize, moveWeights).
*/
function updateHeatmap() {
if (!heatmap.enabled || !inferFn) return;
const zw = getZoomWindow ? getZoomWindow() : undefined;
heatmap.update(inferFn, { zoomWindow: zw });
}
// ---- Settings drawer: Phase 3 section ----
function addDrawerSection() {
// Find the drawer body
const $drawerBody = document.querySelector('.cs-drawer-body');
if (!$drawerBody) return;
const section = document.createElement('div');
section.className = 'cs-section';
section.dataset.section = 'phase3';
section.innerHTML = `
<div class="cs-section-header">
<span>Exploration &amp; Viz</span>
<span class="cs-section-chevron">&#9660;</span>
</div>
<div class="cs-section-body">
<div class="cs-param-row" data-param="autoExploreInterval">
<label class="cs-param-label">Auto Interval</label>
<div class="cs-slider-row">
<input type="range" class="cs-slider" min="0.5" max="10" step="0.5" value="2">
<span class="cs-param-value">2.0s</span>
</div>
</div>
<div class="cs-param-row" data-param="autoExploreIntensity">
<label class="cs-param-label">Auto Intensity</label>
<div class="cs-slider-row">
<input type="range" class="cs-slider" min="0.1" max="1" step="0.05" value="0.5">
<span class="cs-param-value">0.50</span>
</div>
</div>
<div class="cs-param-row" data-param="pressureHold">
<label class="cs-param-label">Hold Feedback</label>
<input type="checkbox" class="cs-toggle" checked>
</div>
<div class="cs-param-row" data-param="pressureFeedback">
<label class="cs-param-label">Pressure FB</label>
<input type="checkbox" class="cs-toggle">
</div>
<div class="cs-param-row" data-param="holdCurve">
<label class="cs-param-label">Hold Curve</label>
<div class="cs-slider-row">
<input type="range" class="cs-slider" min="0.5" max="5" step="0.1" value="2">
<span class="cs-param-value">2.0s</span>
</div>
</div>
<div class="cs-param-row" data-param="heatmapResolution">
<label class="cs-param-label">Heatmap Res</label>
<div class="cs-slider-row">
<input type="range" class="cs-slider" min="4" max="32" step="4" value="16">
<span class="cs-param-value">16</span>
</div>
</div>
<div class="cs-param-row" data-param="heatmapOpacity">
<label class="cs-param-label">Heatmap Opacity</label>
<div class="cs-slider-row">
<input type="range" class="cs-slider" min="0.1" max="1" step="0.05" value="0.55">
<span class="cs-param-value">0.55</span>
</div>
</div>
</div>
`;
$drawerBody.appendChild(section);
// Wire collapse
const header = section.querySelector('.cs-section-header');
if (header) {
header.addEventListener('click', () => section.classList.toggle('collapsed'));
}
// Wire sliders
wireSlider(section, 'autoExploreInterval', (val) => {
autoExplore.setInterval(val);
return `${val.toFixed(1)}s`;
});
wireSlider(section, 'autoExploreIntensity', (val) => {
autoExplore.setIntensity(val);
return val.toFixed(2);
});
wireSlider(section, 'holdCurve', (val) => {
pressure.setHoldCurve(val);
return `${val.toFixed(1)}s`;
});
wireSlider(section, 'heatmapResolution', (val) => {
heatmap.setResolution(val);
return String(Math.round(val));
});
wireSlider(section, 'heatmapOpacity', (val) => {
heatmap.setOpacity(val);
return val.toFixed(2);
});
// Wire toggles
wireToggle(section, 'pressureHold', (checked) => {
pressure.setEnabled(pressure.getPressureEnabled(), checked);
});
wireToggle(section, 'pressureFeedback', (checked) => {
pressure.setEnabled(checked, pressure.getHoldEnabled());
});
}
function wireSlider(container, paramName, onChange) {
const row = container.querySelector(`[data-param="${paramName}"]`);
if (!row) return;
const slider = row.querySelector('input[type="range"]');
const valueSpan = row.querySelector('.cs-param-value');
if (!slider) return;
slider.addEventListener('input', () => {
const val = parseFloat(slider.value);
const display = onChange(val);
if (valueSpan && display) valueSpan.textContent = display;
// Dispatch controlsurface:change for consistency
document.dispatchEvent(new CustomEvent('controlsurface:change', {
detail: { _phase3: true, [paramName]: val },
}));
});
}
function wireToggle(container, paramName, onChange) {
const row = container.querySelector(`[data-param="${paramName}"]`);
if (!row) return;
const checkbox = row.querySelector('input[type="checkbox"]');
if (!checkbox) return;
checkbox.addEventListener('change', () => {
onChange(checkbox.checked);
});
}
// ---- Cleanup ----
function destroy() {
$autoBtn.remove();
if ($heatmapToggle) $heatmapToggle.remove();
if ($hmModeLabel) $hmModeLabel.remove();
for (const { el } of pressureIndicators) el.remove();
const $styles = document.getElementById('phase3-styles');
if ($styles) $styles.remove();
const $section = document.querySelector('.cs-section[data-section="phase3"]');
if ($section) $section.remove();
clearTimeout(_hmLabelTimeout);
}
// ---- Serialization ----
function getConfig() {
return {
pressure: pressure.getConfig(),
autoExplore: autoExplore.getConfig(),
heatmap: heatmap.getConfig(),
};
}
function setConfig(config) {
if (config.pressure) pressure.setConfig(config.pressure);
if (config.autoExplore) autoExplore.setConfig(config.autoExplore);
if (config.heatmap) heatmap.setConfig(config.heatmap);
// Sync UI
$autoBtn.classList.toggle('active', autoExplore.active);
if ($heatmapToggle) $heatmapToggle.classList.toggle('active', heatmap.enabled);
}
return {
pressure,
autoExplore,
heatmap,
tick,
updateHeatmap,
destroy,
getConfig,
setConfig,
};
}

View file

@ -0,0 +1,583 @@
/**
* Phase 4 UI Output Pipeline + Visualization + Polish
*
* Integrates:
* - Output freeze button on floating bar
* - Weight health + gradient flow panel in settings drawer
* - Session preset UI in settings drawer
* - Wires output pipeline sliders to actual OutputPipeline instance
*
* Usage:
* import { initPhase4UI } from './ui/phase4-ui.js';
* const phase4 = initPhase4UI({ outputPipeline, iml, controlSurface, ... });
*
* @module phase4-ui
*/
import { WeightHealthIndicator } from './weight-health.js';
import { GradientFlowIndicator } from './gradient-flow.js';
import { SessionPresetManager } from './session-presets.js';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const HEALTH_UPDATE_INTERVAL = 500; // ms between weight health updates
// Snowflake SVG icon (for output freeze button)
const SNOWFLAKE_SVG = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">
<line x1="7" y1="1" x2="7" y2="13"/>
<line x1="1" y1="7" x2="13" y2="7"/>
<line x1="3" y1="3" x2="11" y2="11"/>
<line x1="11" y1="3" x2="3" y2="11"/>
<line x1="7" y1="1" x2="5.5" y2="2.5"/>
<line x1="7" y1="1" x2="8.5" y2="2.5"/>
<line x1="7" y1="13" x2="5.5" y2="11.5"/>
<line x1="7" y1="13" x2="8.5" y2="11.5"/>
</svg>`;
// ---------------------------------------------------------------------------
// Main initialization
// ---------------------------------------------------------------------------
/**
* Initialize Phase 4 UI components.
*
* @param {object} opts
* @param {import('./output-pipeline.js').OutputPipeline} opts.outputPipeline
* @param {object} opts.iml WasmIML or IML instance
* @param {import('./control-surface.js').ControlSurface} opts.controlSurface
* @param {function} opts.getGroupOverrides returns current groupOverrides array
* @param {function} opts.getActiveSynthPresetId returns active synth preset id
* @param {import('./input-pipeline.js').InputPipeline} opts.inputPipeline
* @param {function} opts.onSessionLoad callback(preset) when a session is loaded
* @returns {object} { weightHealth, gradientFlow, sessionManager, destroy }
*/
export function initPhase4UI(opts) {
const {
outputPipeline,
iml,
controlSurface,
getGroupOverrides,
getActiveSynthPresetId,
inputPipeline,
onSessionLoad,
} = opts;
// ---- Weight Health ----
const weightHealth = new WeightHealthIndicator();
// ---- Gradient Flow ----
// layerSizes from the IML instance
const layerSizes = iml.layerSizes || [3, 32, 48, 64, 126];
const gradientFlow = new GradientFlowIndicator(layerSizes);
// ---- Session Presets ----
const sessionManager = new SessionPresetManager();
// ---- 1. Output Freeze Button ----
const freezeBtn = createFreezeButton(outputPipeline);
// ---- 2. Network Health Panel (in settings drawer) ----
const healthPanel = createHealthPanel(weightHealth, gradientFlow);
// ---- 3. Session Preset UI (in settings drawer) ----
const sessionUI = createSessionUI(sessionManager, {
controlSurface,
outputPipeline,
inputPipeline,
getGroupOverrides,
getActiveSynthPresetId,
onSessionLoad,
});
// ---- 4. Wire output pipeline sliders ----
wireOutputPipelineSliders(outputPipeline, controlSurface);
// ---- Periodic weight health updates ----
let healthInterval = setInterval(() => {
updateWeightHealth(iml, weightHealth, healthPanel.canvas, healthPanel.ctx, gradientFlow, healthPanel.gradCanvas, healthPanel.gradCtx);
}, HEALTH_UPDATE_INTERVAL);
// ---- Inject styles ----
injectPhase4Styles();
// ---- Cleanup ----
function destroy() {
clearInterval(healthInterval);
freezeBtn.remove();
healthPanel.container.remove();
sessionUI.container.remove();
const styleEl = document.getElementById('phase4-styles');
if (styleEl) styleEl.remove();
}
return {
weightHealth,
gradientFlow,
sessionManager,
freezeBtn,
/**
* Call before iml.train() / iml.trainAsync() to capture weight snapshot.
*/
captureBeforeTrain() {
const weights = getFlatWeights(iml);
if (weights) gradientFlow.captureBeforeTrain(weights);
},
/**
* Call after training completes to capture weight snapshot and update gradient flow.
*/
captureAfterTrain() {
const weights = getFlatWeights(iml);
if (weights) {
gradientFlow.captureAfterTrain(weights);
// Immediately refresh the health panel
updateWeightHealth(iml, weightHealth, healthPanel.canvas, healthPanel.ctx, gradientFlow, healthPanel.gradCanvas, healthPanel.gradCtx);
}
},
destroy,
};
}
// ---------------------------------------------------------------------------
// Freeze Button
// ---------------------------------------------------------------------------
function createFreezeButton(outputPipeline) {
const $floatingBar = document.getElementById('floating-bar');
if (!$floatingBar) {
console.warn('[Phase4] floating-bar not found');
return document.createElement('button');
}
const btn = document.createElement('button');
btn.className = 'float-btn p4-freeze-btn';
btn.id = 'btn-freeze-output';
btn.title = 'Freeze output (synth/visual stays locked)';
btn.innerHTML = SNOWFLAKE_SVG;
btn.addEventListener('click', () => {
const frozen = !outputPipeline.isFrozen();
outputPipeline.setFrozen(frozen);
btn.classList.toggle('active', frozen);
btn.title = frozen ? 'Unfreeze output' : 'Freeze output';
});
// Insert before the chevron
const $chevron = document.getElementById('chevron-btn');
if ($chevron) {
$floatingBar.insertBefore(btn, $chevron);
} else {
$floatingBar.appendChild(btn);
}
return btn;
}
// ---------------------------------------------------------------------------
// Network Health Panel
// ---------------------------------------------------------------------------
function createHealthPanel(weightHealth, gradientFlow) {
// Find the settings drawer body
const drawerBody = document.querySelector('.cs-drawer-body');
if (!drawerBody) {
// Drawer not yet created; defer by creating a detached element
const container = document.createElement('div');
return {
container,
canvas: document.createElement('canvas'),
ctx: null,
gradCanvas: document.createElement('canvas'),
gradCtx: null,
};
}
const container = document.createElement('div');
container.className = 'cs-section p4-health-section';
container.innerHTML = `
<div class="cs-section-header">
<span>Network Health</span>
<span class="cs-section-chevron">&#9660;</span>
</div>
<div class="cs-section-body">
<div class="p4-health-row">
<div class="p4-health-label">Weight Health</div>
<canvas class="p4-health-canvas" width="120" height="32"></canvas>
</div>
<div class="p4-health-row">
<div class="p4-health-label">Gradient Flow</div>
<canvas class="p4-grad-canvas" width="120" height="48"></canvas>
</div>
</div>
`;
// Collapse toggle
const header = container.querySelector('.cs-section-header');
header.addEventListener('click', () => {
container.classList.toggle('collapsed');
});
// Append to drawer body (at the bottom)
drawerBody.appendChild(container);
const canvas = container.querySelector('.p4-health-canvas');
const gradCanvas = container.querySelector('.p4-grad-canvas');
return {
container,
canvas,
ctx: canvas.getContext('2d'),
gradCanvas,
gradCtx: gradCanvas.getContext('2d'),
};
}
// ---------------------------------------------------------------------------
// Session Preset UI
// ---------------------------------------------------------------------------
function createSessionUI(sessionManager, deps) {
const drawerBody = document.querySelector('.cs-drawer-body');
if (!drawerBody) {
return { container: document.createElement('div') };
}
const container = document.createElement('div');
container.className = 'p4-session-section';
const presets = sessionManager.list();
container.innerHTML = `
<div class="p4-session-header">Session Presets</div>
<div class="p4-session-row">
<select class="p4-session-select" id="p4-session-select">
<option value="">-- load session --</option>
${presets.map(p => `<option value="${p.name}">${p.name}</option>`).join('')}
</select>
<button class="p4-session-btn p4-session-load" id="p4-session-load" title="Load selected session">Load</button>
<button class="p4-session-btn p4-session-del" id="p4-session-delete" title="Delete selected session">&times;</button>
</div>
<div class="p4-session-row">
<input type="text" class="p4-session-name" id="p4-session-name" placeholder="Session name..." maxlength="40">
<button class="p4-session-btn p4-session-save" id="p4-session-save" title="Save current session">Save</button>
<button class="p4-session-btn p4-session-share" id="p4-session-share" title="Copy URL to clipboard">URL</button>
</div>
`;
// Insert at the top of the drawer body (before the preset row)
const firstChild = drawerBody.firstChild;
drawerBody.insertBefore(container, firstChild);
// Wire interactions
const $select = container.querySelector('#p4-session-select');
const $loadBtn = container.querySelector('#p4-session-load');
const $deleteBtn = container.querySelector('#p4-session-delete');
const $nameInput = container.querySelector('#p4-session-name');
const $saveBtn = container.querySelector('#p4-session-save');
const $shareBtn = container.querySelector('#p4-session-share');
function refreshDropdown() {
const list = sessionManager.list();
$select.innerHTML = `<option value="">-- load session --</option>` +
list.map(p => `<option value="${p.name}">${p.name}</option>`).join('');
}
function getCurrentState() {
return {
controlSurface: deps.controlSurface ? deps.controlSurface.getState() : null,
synthPresetId: deps.getActiveSynthPresetId ? deps.getActiveSynthPresetId() : null,
groupOverrides: deps.getGroupOverrides ? deps.getGroupOverrides() : null,
inputPipeline: deps.inputPipeline ? deps.inputPipeline.getConfig() : null,
outputPipeline: deps.outputPipeline ? deps.outputPipeline.getConfig() : null,
};
}
$saveBtn.addEventListener('click', () => {
const name = $nameInput.value.trim();
if (!name) return;
sessionManager.save(name, getCurrentState());
$nameInput.value = '';
refreshDropdown();
$select.value = name;
});
$loadBtn.addEventListener('click', () => {
const name = $select.value;
if (!name) return;
const preset = sessionManager.load(name);
if (preset && deps.onSessionLoad) {
deps.onSessionLoad(preset);
}
});
$deleteBtn.addEventListener('click', () => {
const name = $select.value;
if (!name) return;
sessionManager.delete(name);
refreshDropdown();
});
$shareBtn.addEventListener('click', () => {
const state = getCurrentState();
const preset = sessionManager.capture('share', state);
const params = sessionManager.toURL(preset);
const url = new URL(window.location.href);
// Clear existing session params
for (const key of ['sp', 'cs', 'op', 'co']) {
url.searchParams.delete(key);
}
// Append session params
const sessionParams = new URLSearchParams(params);
for (const [k, v] of sessionParams) {
url.searchParams.set(k, v);
}
navigator.clipboard.writeText(url.toString()).then(() => {
$shareBtn.textContent = 'Copied!';
setTimeout(() => { $shareBtn.textContent = 'URL'; }, 1500);
}).catch(() => {
$shareBtn.textContent = 'Error';
setTimeout(() => { $shareBtn.textContent = 'URL'; }, 1500);
});
});
return { container, refreshDropdown };
}
// ---------------------------------------------------------------------------
// Wire Output Pipeline Sliders
// ---------------------------------------------------------------------------
/**
* Listen for controlsurface:change events and pipe output-related params
* into the OutputPipeline instance.
*/
function wireOutputPipelineSliders(outputPipeline, controlSurface) {
document.addEventListener('controlsurface:change', (e) => {
const p = e.detail;
if (p.outputSmoothing != null) {
outputPipeline.setSmoothing(p.outputSmoothing);
}
if (p.outputSlewRate != null) {
outputPipeline.setSlewRate(p.outputSlewRate);
}
if (p.globalCurve != null) {
outputPipeline.setGlobalCurve(p.globalCurve);
}
// Note: 'tame' is handled at groupOverrides init time (baked into min/max),
// not as a runtime pipeline stage.
});
}
// ---------------------------------------------------------------------------
// Weight Health Update
// ---------------------------------------------------------------------------
function getFlatWeights(iml) {
// WasmIML has _getFlatWeights()
if (typeof iml._getFlatWeights === 'function') {
return iml._getFlatWeights();
}
// JS IML: flatten from mlp.getWeights() structure
if (iml.mlp && typeof iml.mlp.getWeights === 'function') {
const structured = iml.mlp.getWeights();
const flat = [];
for (const layer of structured) {
for (const node of layer) {
flat.push(...node.weights, node.bias);
}
}
return new Float32Array(flat);
}
return null;
}
function updateWeightHealth(iml, weightHealth, canvas, ctx, gradientFlow, gradCanvas, gradCtx) {
const weights = getFlatWeights(iml);
if (weights) {
weightHealth.update(weights);
}
if (ctx && canvas) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
weightHealth.draw(ctx, 0, 0, canvas.width, canvas.height);
}
if (gradCtx && gradCanvas) {
gradCtx.clearRect(0, 0, gradCanvas.width, gradCanvas.height);
gradientFlow.draw(gradCtx, 0, 0, gradCanvas.width, gradCanvas.height);
}
}
// ---------------------------------------------------------------------------
// CSS injection
// ---------------------------------------------------------------------------
function injectPhase4Styles() {
if (document.getElementById('phase4-styles')) return;
const style = document.createElement('style');
style.id = 'phase4-styles';
style.textContent = `
/* ---- Phase 4: Freeze Button ---- */
.p4-freeze-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 0 6px;
min-width: 28px;
color: rgba(255, 255, 255, 0.5);
transition: color 0.15s, background 0.15s;
}
.p4-freeze-btn.active {
color: #5bcefa;
background: rgba(91, 206, 250, 0.12);
border-color: rgba(91, 206, 250, 0.3);
}
.p4-freeze-btn:hover {
color: #5bcefa;
}
/* ---- Phase 4: Network Health Section ---- */
.p4-health-section {
border-top: 1px solid rgba(255, 255, 255, 0.06);
margin-top: 4px;
}
.p4-health-row {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
}
.p4-health-label {
font-size: 10px;
color: rgba(255, 255, 255, 0.45);
min-width: 70px;
flex-shrink: 0;
}
.p4-health-canvas {
border-radius: 3px;
flex: 1;
max-width: 140px;
height: 32px;
}
.p4-grad-canvas {
border-radius: 3px;
flex: 1;
max-width: 140px;
height: 48px;
}
/* ---- Phase 4: Session Presets ---- */
.p4-session-section {
padding: 10px 16px 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.p4-session-header {
font-size: 10px;
font-weight: 600;
color: rgba(255, 255, 255, 0.6);
text-transform: uppercase;
letter-spacing: 0.7px;
margin-bottom: 6px;
}
.p4-session-row {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 4px;
}
.p4-session-select {
flex: 1;
min-width: 0;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
color: rgba(255, 255, 255, 0.8);
font-size: 11px;
padding: 4px 6px;
font-family: inherit;
}
.p4-session-name {
flex: 1;
min-width: 0;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
color: rgba(255, 255, 255, 0.8);
font-size: 11px;
padding: 4px 6px;
font-family: inherit;
outline: none;
}
.p4-session-name::placeholder {
color: rgba(255, 255, 255, 0.25);
}
.p4-session-name:focus {
border-color: var(--accent, #ff6a00);
}
.p4-session-btn {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
color: rgba(255, 255, 255, 0.6);
font-size: 10px;
padding: 4px 8px;
cursor: pointer;
font-family: inherit;
white-space: nowrap;
transition: all 0.12s;
}
.p4-session-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.9);
}
.p4-session-save {
color: var(--accent, #ff6a00);
border-color: rgba(255, 106, 0, 0.2);
}
.p4-session-save:hover {
background: rgba(255, 106, 0, 0.12);
}
.p4-session-del {
color: rgba(255, 80, 80, 0.7);
font-size: 14px;
padding: 2px 6px;
line-height: 1;
}
.p4-session-del:hover {
color: rgba(255, 80, 80, 1);
background: rgba(255, 80, 80, 0.1);
}
.p4-session-share {
color: rgba(150, 180, 255, 0.7);
border-color: rgba(150, 180, 255, 0.15);
}
.p4-session-share:hover {
background: rgba(150, 180, 255, 0.1);
color: rgba(150, 180, 255, 1);
}
`;
document.head.appendChild(style);
}

View file

@ -0,0 +1,194 @@
/**
* Pressure / Hold-Duration Feedback
*
* Combines touch pressure (Force Touch, stylus) and hold duration into a
* single intensity multiplier (0-1). Applied to noise growth on thumbs-down
* and train intensity on thumbs-up.
*
* When disabled, getIntensity() returns 1.0 (passthrough).
*
* @module pressure-feedback
*/
export class PressureFeedback {
/**
* @param {object} [options]
* @param {boolean} [options.pressureEnabled=false] - use event.pressure
* @param {boolean} [options.holdEnabled=true] - ramp intensity by hold duration
* @param {number} [options.holdCurve=2] - seconds to reach max intensity
* @param {number} [options.minIntensity=0.15] - floor when active (quick tap)
*/
constructor(options = {}) {
this._pressureEnabled = options.pressureEnabled ?? false;
this._holdEnabled = options.holdEnabled ?? true;
this._holdCurve = Math.max(0.1, options.holdCurve ?? 2);
this._minIntensity = Math.max(0, Math.min(1, options.minIntensity ?? 0.15));
// Runtime state
this._active = false; // pointer is currently down
this._startTime = 0; // timestamp of pointerdown (ms)
this._currentPressure = 0; // latest event.pressure (0-1)
this._holdIntensity = 0; // computed hold intensity (0-1)
}
// ---- Pointer event handlers ----
/**
* Call on pointerdown. Starts tracking hold duration and pressure.
* @param {PointerEvent} event
*/
onPointerDown(event) {
this._active = true;
this._startTime = performance.now();
this._currentPressure = this._extractPressure(event);
this._holdIntensity = 0;
}
/**
* Call on pointermove while pointer is down.
* Updates pressure reading.
* @param {PointerEvent} event
*/
onPointerMove(event) {
if (!this._active) return;
this._currentPressure = this._extractPressure(event);
this._updateHold();
}
/**
* Call on pointerup / pointercancel.
*/
onPointerUp(_event) {
this.reset();
}
// ---- Queries ----
/**
* Get current intensity multiplier.
*
* - If both pressure and hold are disabled, returns 1.0 (passthrough).
* - If active and at least one source is enabled, returns max(pressure, holdIntensity).
* - If not active (no pointer down), returns the minIntensity floor.
*
* @returns {number} 0-1
*/
getIntensity() {
if (!this._pressureEnabled && !this._holdEnabled) return 1.0;
if (!this._active) return this._minIntensity;
this._updateHold();
let intensity = 0;
if (this._pressureEnabled) {
intensity = Math.max(intensity, this._currentPressure);
}
if (this._holdEnabled) {
intensity = Math.max(intensity, this._holdIntensity);
}
// Ensure at least minIntensity when active
return Math.max(this._minIntensity, intensity);
}
/**
* Whether a pointer is currently held down.
* @returns {boolean}
*/
get active() {
return this._active;
}
/**
* Whether any source is enabled.
* @returns {boolean}
*/
get enabled() {
return this._pressureEnabled || this._holdEnabled;
}
// ---- Configuration ----
/**
* Toggle pressure and hold-duration sources independently.
* @param {boolean} pressure
* @param {boolean} holdDuration
*/
setEnabled(pressure, holdDuration) {
this._pressureEnabled = !!pressure;
this._holdEnabled = !!holdDuration;
}
/**
* Set the hold-duration ramp time.
* @param {number} seconds - time to reach max intensity (0.1-10)
*/
setHoldCurve(seconds) {
this._holdCurve = Math.max(0.1, Math.min(10, seconds));
}
/** @returns {number} */
getHoldCurve() { return this._holdCurve; }
/** @returns {boolean} */
getPressureEnabled() { return this._pressureEnabled; }
/** @returns {boolean} */
getHoldEnabled() { return this._holdEnabled; }
/**
* Reset state (called on pointer up).
*/
reset() {
this._active = false;
this._startTime = 0;
this._currentPressure = 0;
this._holdIntensity = 0;
}
// ---- Serialization ----
getConfig() {
return {
pressureEnabled: this._pressureEnabled,
holdEnabled: this._holdEnabled,
holdCurve: this._holdCurve,
minIntensity: this._minIntensity,
};
}
setConfig(config) {
if (config.pressureEnabled != null) this._pressureEnabled = !!config.pressureEnabled;
if (config.holdEnabled != null) this._holdEnabled = !!config.holdEnabled;
if (config.holdCurve != null) this.setHoldCurve(config.holdCurve);
if (config.minIntensity != null) this._minIntensity = Math.max(0, Math.min(1, config.minIntensity));
}
// ---- Internal ----
/**
* Extract pressure from a pointer event.
* Falls back to 1.0 on devices without pressure support.
* A pressure of 0 on non-touch events means "no pressure data" treat as 1.0.
*/
_extractPressure(event) {
if (!event) return 1.0;
const p = event.pressure;
// Most mouse/touch events report 0.5 for button-down without pressure sensing.
// A true 0 means "no pressure data" (mouse hover, etc.) — fall back to 1.0.
if (typeof p !== 'number' || p === 0) return 1.0;
return Math.max(0, Math.min(1, p));
}
/**
* Update hold intensity based on elapsed time since pointer down.
* Uses an ease-in curve (quadratic).
*/
_updateHold() {
if (!this._active || !this._holdEnabled) return;
const elapsed = (performance.now() - this._startTime) / 1000; // seconds
const t = Math.min(1, elapsed / this._holdCurve);
// Ease-in (quadratic) — slow start, fast finish
this._holdIntensity = t * t;
}
}

View file

@ -0,0 +1,205 @@
// Region Pin Manager — Pin rectangular input-space regions (Approach A: Example Pinning)
//
// Pinned regions capture training examples whose inputs fall within a rectangle
// in the 2D input space. These pinned examples:
// - Are always included in training with high weight
// - Cannot be evicted by FIFO example rotation
// - Anchor behavior in that region while the user explores elsewhere
//
// Up to 5 pinned regions, each with a distinct color from the palette.
//
// Usage:
// import { RegionPinManager } from './region-pin.js';
// const regionPins = new RegionPinManager();
// const id = regionPins.pin({ x1: 0.2, y1: 0.3, x2: 0.5, y2: 0.7 }, examples);
// const pinned = regionPins.getPinnedExamples(); // { features: [...], labels: [...] }
const MAX_PINS = 5;
// Distinct colors for pinned region overlays (used for joy-map rendering)
const PIN_PALETTE = [
'rgba(0, 188, 212, 0.15)', // teal
'rgba(156, 39, 176, 0.15)', // purple
'rgba(255, 193, 7, 0.15)', // amber
'rgba(233, 30, 99, 0.15)', // rose
'rgba(139, 195, 74, 0.15)', // lime
];
let _nextId = 1;
export class RegionPinManager {
constructor() {
// Array of { id, region: {x1,y1,x2,y2}, color, examples: { features: [[]], labels: [[]] } }
this._pins = [];
}
// ---- Public API ----
/**
* Pin a region. Captures examples whose inputs fall within the region.
* @param {{ x1: number, y1: number, x2: number, y2: number }} region
* Normalized [0,1] coordinates. x1 < x2, y1 < y2.
* @param {{ features: Array<Array<number>>, labels: Array<Array<number>> }} examples
* The current full training dataset to filter from.
* @returns {number} Pin ID, or -1 if at capacity.
*/
pin(region, examples) {
if (this._pins.length >= MAX_PINS) {
console.warn('[RegionPin] Max pins reached (' + MAX_PINS + ')');
return -1;
}
// Normalize region bounds
const r = {
x1: Math.min(region.x1, region.x2),
y1: Math.min(region.y1, region.y2),
x2: Math.max(region.x1, region.x2),
y2: Math.max(region.y1, region.y2),
};
// Filter examples whose inputs fall within the region
const pinnedFeatures = [];
const pinnedLabels = [];
if (examples && examples.features) {
for (let i = 0; i < examples.features.length; i++) {
const f = examples.features[i];
// Inputs are the first 2 elements (joystick X, Y) — without bias
const x = f[0];
const y = f.length > 1 ? f[1] : 0.5;
if (x >= r.x1 && x <= r.x2 && y >= r.y1 && y <= r.y2) {
pinnedFeatures.push([...f]);
pinnedLabels.push([...(examples.labels[i] || [])]);
}
}
}
const id = _nextId++;
const colorIndex = this._pins.length % PIN_PALETTE.length;
this._pins.push({
id,
region: r,
color: PIN_PALETTE[colorIndex],
examples: {
features: pinnedFeatures,
labels: pinnedLabels,
},
});
this._dispatch('regionpin:add', {
id,
region: r,
exampleCount: pinnedFeatures.length,
total: this._pins.length,
});
return id;
}
/**
* Remove a pinned region by ID.
* @param {number} pinId
* @returns {boolean} True if removed.
*/
unpin(pinId) {
const idx = this._pins.findIndex(p => p.id === pinId);
if (idx < 0) return false;
this._pins.splice(idx, 1);
this._dispatch('regionpin:remove', { id: pinId, total: this._pins.length });
return true;
}
/**
* Get all pinned regions for joy-map overlay rendering.
* @returns {Array<{ id: number, region: {x1,y1,x2,y2}, color: string, exampleCount: number }>}
*/
getRegions() {
return this._pins.map(p => ({
id: p.id,
region: { ...p.region },
color: p.color,
exampleCount: p.examples.features.length,
}));
}
/**
* Get all pinned examples merged together (for training).
* These should always be included in the training set alongside regular examples.
* @returns {{ features: Array<Array<number>>, labels: Array<Array<number>> }}
*/
getPinnedExamples() {
const features = [];
const labels = [];
for (const pin of this._pins) {
for (let i = 0; i < pin.examples.features.length; i++) {
features.push(pin.examples.features[i]);
labels.push(pin.examples.labels[i]);
}
}
return { features, labels };
}
/**
* Check if a 2D input point falls within any pinned region.
* @param {Array<number>} inputs [x, y, ...]
* @returns {boolean}
*/
isInPinnedRegion(inputs) {
const x = inputs[0];
const y = inputs.length > 1 ? inputs[1] : 0.5;
for (const pin of this._pins) {
const r = pin.region;
if (x >= r.x1 && x <= r.x2 && y >= r.y1 && y <= r.y2) {
return true;
}
}
return false;
}
/**
* Current number of pinned regions.
* @returns {number}
*/
get count() {
return this._pins.length;
}
// ---- Serialization ----
getState() {
return {
pins: this._pins.map(p => ({
id: p.id,
region: { ...p.region },
color: p.color,
examples: {
features: p.examples.features.map(f => [...f]),
labels: p.examples.labels.map(l => [...l]),
},
})),
};
}
setState(saved) {
if (!saved || !Array.isArray(saved.pins)) return;
this._pins = saved.pins.map(p => ({
id: p.id || _nextId++,
region: p.region || { x1: 0, y1: 0, x2: 1, y2: 1 },
color: p.color || PIN_PALETTE[0],
examples: {
features: (p.examples?.features || []).map(f => [...f]),
labels: (p.examples?.labels || []).map(l => [...l]),
},
}));
// Advance ID counter past any restored IDs
for (const p of this._pins) {
if (p.id >= _nextId) _nextId = p.id + 1;
}
}
// ---- Internal ----
_dispatch(type, detail) {
document.dispatchEvent(new CustomEvent(type, { detail }));
}
}

View file

@ -0,0 +1,243 @@
/**
* Session Preset Manager bundles control surface state + synth preset into
* a single loadable configuration.
*
* Presets are stored in localStorage under a dedicated key, separate from the
* main app state.
*
* @module session-presets
*/
const STORAGE_KEY = 'nisps-session-presets';
// ---------------------------------------------------------------------------
// URL encoding helpers
// ---------------------------------------------------------------------------
/**
* Encode a compact session state into URL search params.
* Only encodes the most important state for sharing.
*/
function encodeToURL(preset) {
const params = new URLSearchParams();
// Synth preset
if (preset.synthPresetId) {
params.set('sp', preset.synthPresetId);
}
// Compound axes (3 values, comma-separated)
if (preset.controlSurface?.axes) {
const a = preset.controlSurface.axes;
params.set('cs', [
(a.boldness ?? 0.5).toFixed(2),
(a.memory ?? 0.5).toFixed(2),
(a.precision ?? 0.3).toFixed(2),
].join(','));
}
// Output pipeline (only non-default values)
if (preset.outputPipeline) {
const op = preset.outputPipeline;
const parts = [];
if (op.globalCurve != null && op.globalCurve !== 1.0) parts.push(`gc=${op.globalCurve.toFixed(2)}`);
if (op.smoothing != null && op.smoothing !== 0) parts.push(`sm=${op.smoothing.toFixed(2)}`);
if (op.slewRate != null && op.slewRate !== 1.0) parts.push(`sl=${op.slewRate.toFixed(3)}`);
if (parts.length > 0) params.set('op', parts.join(','));
}
// Key control surface overrides (compact: name=value pairs)
if (preset.controlSurface?.offsets) {
const offsets = preset.controlSurface.offsets;
const keys = Object.keys(offsets);
if (keys.length > 0 && keys.length <= 10) {
// Only encode up to 10 overrides to keep URL reasonable
const pairs = keys.slice(0, 10).map(k => {
const v = offsets[k];
return `${k}:${typeof v === 'number' ? v.toFixed(3) : v}`;
});
params.set('co', pairs.join(','));
}
}
return params.toString();
}
/**
* Decode session state from URL search params.
* Returns a partial preset object.
*/
function decodeFromURL(urlParams) {
const preset = {};
// Synth preset
const sp = urlParams.get('sp');
if (sp) preset.synthPresetId = sp;
// Compound axes
const cs = urlParams.get('cs');
if (cs) {
const [b, m, p] = cs.split(',').map(Number);
preset.controlSurface = {
axes: {
boldness: isNaN(b) ? 0.5 : b,
memory: isNaN(m) ? 0.5 : m,
precision: isNaN(p) ? 0.3 : p,
},
offsets: {},
};
}
// Control surface overrides
const co = urlParams.get('co');
if (co && preset.controlSurface) {
for (const pair of co.split(',')) {
const colonIdx = pair.indexOf(':');
if (colonIdx > 0) {
const key = pair.substring(0, colonIdx);
const valStr = pair.substring(colonIdx + 1);
const num = Number(valStr);
preset.controlSurface.offsets[key] = isNaN(num) ? valStr : num;
}
}
}
// Output pipeline
const op = urlParams.get('op');
if (op) {
const pipeline = {};
for (const part of op.split(',')) {
const [k, v] = part.split('=');
const num = Number(v);
if (!isNaN(num)) {
if (k === 'gc') pipeline.globalCurve = num;
else if (k === 'sm') pipeline.smoothing = num;
else if (k === 'sl') pipeline.slewRate = num;
}
}
preset.outputPipeline = pipeline;
}
return preset;
}
// ---------------------------------------------------------------------------
// SessionPresetManager
// ---------------------------------------------------------------------------
export class SessionPresetManager {
constructor() {
this._presets = this._loadFromStorage();
}
/**
* Capture a snapshot of the current full session state.
*
* @param {string} name user-facing name for this preset
* @param {object} state current state from various subsystems
* @param {object} state.controlSurface result of ControlSurface.getState()
* @param {string} state.synthPresetId active synth preset ID
* @param {Array} state.groupOverrides current group overrides
* @param {object} state.inputPipeline result of InputPipeline.getConfig()
* @param {object} state.outputPipeline result of OutputPipeline.getConfig()
* @returns {object} the captured preset
*/
capture(name, state) {
return {
name,
controlSurface: state.controlSurface || null,
synthPresetId: state.synthPresetId || null,
groupOverrides: state.groupOverrides || null,
inputPipeline: state.inputPipeline || null,
outputPipeline: state.outputPipeline || null,
timestamp: Date.now(),
};
}
/**
* Save current state as a named session preset to localStorage.
*
* @param {string} name
* @param {object} state same as capture() state param
*/
save(name, state) {
const preset = this.capture(name, state);
this._presets[name] = preset;
this._saveToStorage();
return preset;
}
/**
* Load a named session preset.
* @param {string} name
* @returns {object|null}
*/
load(name) {
return this._presets[name] || null;
}
/**
* List all saved session presets.
* @returns {Array<{name: string, timestamp: number}>}
*/
list() {
return Object.values(this._presets)
.map(p => ({ name: p.name, timestamp: p.timestamp }))
.sort((a, b) => b.timestamp - a.timestamp);
}
/**
* Delete a named session preset.
* @param {string} name
* @returns {boolean} true if deleted
*/
delete(name) {
if (name in this._presets) {
delete this._presets[name];
this._saveToStorage();
return true;
}
return false;
}
/**
* Encode a preset into URL search params for sharing.
* @param {object} preset
* @returns {string} URL search string (without leading ?)
*/
toURL(preset) {
return encodeToURL(preset);
}
/**
* Decode a preset from URL search params.
* @param {URLSearchParams} urlParams
* @returns {object} partial preset
*/
fromURL(urlParams) {
return decodeFromURL(urlParams);
}
// -----------------------------------------------------------------------
// Internal
// -----------------------------------------------------------------------
_loadFromStorage() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return {};
return JSON.parse(raw);
} catch (e) {
console.warn('[SessionPresets] Failed to load:', e);
return {};
}
}
_saveToStorage() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this._presets));
} catch (e) {
console.warn('[SessionPresets] Failed to save:', e);
}
}
}

View file

@ -0,0 +1,169 @@
// Snapshot Stack — Multi-level undo for weight states
//
// A ring buffer of weight snapshots tagged with context (e.g. "before thumbs-down",
// "before train"). Auto-snapshot triggers in the app push snapshots before destructive
// operations. Users can undo (pop), peek, list, or jump to any snapshot.
//
// Each snapshot stores:
// - weights: the full flat weight array (Array<number> or Float32Array)
// - noiseLevel: the RL noise level at time of snapshot
// - zoomLevel: the input pipeline zoom level (optional)
// - tag: human-readable label for UI display
// - timestamp: Date.now() when captured
//
// Usage:
// import { SnapshotStack } from './snapshot-stack.js';
// const stack = new SnapshotStack(20);
// stack.push('before thumbs-down', { weights, noiseLevel, zoomLevel });
// const prev = stack.pop(); // undo
export class SnapshotStack {
/**
* @param {number} maxSnapshots Ring buffer capacity. Oldest evicted when full.
*/
constructor(maxSnapshots = 20) {
this._max = maxSnapshots;
this._stack = []; // Array of { tag, timestamp, state: { weights, noiseLevel, zoomLevel } }
}
// ---- Core operations ----
/**
* Push a snapshot onto the stack.
* @param {string} tag Human-readable label (e.g. "before thumbs-down")
* @param {{ weights: Array|Float32Array, noiseLevel: number, zoomLevel?: number }} state
*/
push(tag, state) {
const snapshot = {
tag,
timestamp: Date.now(),
state: {
weights: Array.isArray(state.weights)
? [...state.weights]
: Array.from(state.weights),
noiseLevel: state.noiseLevel ?? 0,
zoomLevel: state.zoomLevel ?? 1.0,
},
};
this._stack.push(snapshot);
// Ring buffer eviction: drop oldest if over capacity
if (this._stack.length > this._max) {
this._stack.shift();
}
this._dispatch('snapshot:push', { tag, depth: this.depth });
}
/**
* Pop the most recent snapshot (undo).
* @returns {{ weights: Array, noiseLevel: number, zoomLevel: number }|null}
*/
pop() {
if (this._stack.length === 0) return null;
const snapshot = this._stack.pop();
this._dispatch('snapshot:pop', { tag: snapshot.tag, depth: this.depth });
return snapshot.state;
}
/**
* Peek at the most recent snapshot without removing it.
* @returns {{ weights: Array, noiseLevel: number, zoomLevel: number }|null}
*/
peek() {
if (this._stack.length === 0) return null;
return this._stack[this._stack.length - 1].state;
}
/**
* List all snapshots (newest last) for UI display.
* @returns {Array<{ tag: string, timestamp: number, index: number }>}
*/
list() {
return this._stack.map((snap, i) => ({
tag: snap.tag,
timestamp: snap.timestamp,
index: i,
}));
}
/**
* Jump to a specific snapshot by index. Removes everything above it.
* @param {number} index
* @returns {{ weights: Array, noiseLevel: number, zoomLevel: number }|null}
*/
jumpTo(index) {
if (index < 0 || index >= this._stack.length) return null;
const snapshot = this._stack[index];
// Truncate: keep entries 0..index (inclusive), remove the rest
this._stack = this._stack.slice(0, index);
this._dispatch('snapshot:jump', { tag: snapshot.tag, depth: this.depth });
return snapshot.state;
}
/**
* Current stack depth.
* @returns {number}
*/
get depth() {
return this._stack.length;
}
/**
* Clear all snapshots.
*/
clear() {
this._stack = [];
this._dispatch('snapshot:clear', { depth: 0 });
}
// ---- Serialization ----
/**
* Serialize for localStorage persistence.
*/
getState() {
return {
max: this._max,
snapshots: this._stack.map(s => ({
tag: s.tag,
timestamp: s.timestamp,
state: {
weights: s.state.weights,
noiseLevel: s.state.noiseLevel,
zoomLevel: s.state.zoomLevel,
},
})),
};
}
/**
* Restore from serialized state.
*/
setState(saved) {
if (!saved) return;
if (typeof saved.max === 'number') this._max = saved.max;
if (Array.isArray(saved.snapshots)) {
this._stack = saved.snapshots.map(s => ({
tag: s.tag || 'restored',
timestamp: s.timestamp || Date.now(),
state: {
weights: s.state?.weights || [],
noiseLevel: s.state?.noiseLevel ?? 0,
zoomLevel: s.state?.zoomLevel ?? 1.0,
},
}));
// Trim to capacity
while (this._stack.length > this._max) {
this._stack.shift();
}
}
}
// ---- Internal ----
_dispatch(type, detail) {
document.dispatchEvent(new CustomEvent(type, { detail }));
}
}

View file

@ -0,0 +1,218 @@
/**
* Weight Health Indicator ambient indicator showing network weight statistics.
*
* Analyzes a flat weight array and produces health status + compact visualization.
* Call update() periodically (not every frame) with the current flat weights.
*
* @module weight-health
*/
// ---------------------------------------------------------------------------
// Thresholds
// ---------------------------------------------------------------------------
/** Weight magnitude below this is considered "dead" */
const DEAD_THRESHOLD = 0.01;
/** Weight magnitude above this drives sigmoid >99% saturated */
const SATURATING_THRESHOLD = 3.0;
/** If more than this fraction of weights are dead, status = 'dead' */
const DEAD_FRACTION_LIMIT = 0.30;
/** If more than this fraction of weights are saturating, status = 'saturating' */
const SATURATED_FRACTION_LIMIT = 0.40;
/** Number of histogram bins for weight magnitude distribution */
const HISTOGRAM_BINS = 10;
/** Histogram bin range: [0, MAX_HISTOGRAM_MAG] */
const MAX_HISTOGRAM_MAG = 5.0;
// ---------------------------------------------------------------------------
// Colors
// ---------------------------------------------------------------------------
const COLOR_HEALTHY = { r: 100, g: 200, b: 120 }; // calm green
const COLOR_SATURATING = { r: 255, g: 120, b: 40 }; // hot orange
const COLOR_DEAD = { r: 120, g: 120, b: 120 }; // dim gray
// ---------------------------------------------------------------------------
// WeightHealthIndicator
// ---------------------------------------------------------------------------
export class WeightHealthIndicator {
constructor() {
this._status = null;
this._pulsePhase = 0;
}
/**
* Update from current weights. Call periodically (e.g. every 500ms).
*
* @param {number[]|Float32Array} weightsArray flat array of all network weights
*/
update(weightsArray) {
if (!weightsArray || weightsArray.length === 0) {
this._status = null;
return;
}
const n = weightsArray.length;
let sumMag = 0;
let maxMag = 0;
let deadCount = 0;
let saturatedCount = 0;
const histogram = new Array(HISTOGRAM_BINS).fill(0);
const binWidth = MAX_HISTOGRAM_MAG / HISTOGRAM_BINS;
for (let i = 0; i < n; i++) {
const mag = Math.abs(weightsArray[i]);
sumMag += mag;
if (mag > maxMag) maxMag = mag;
if (mag < DEAD_THRESHOLD) deadCount++;
if (mag > SATURATING_THRESHOLD) saturatedCount++;
const bin = Math.min(Math.floor(mag / binWidth), HISTOGRAM_BINS - 1);
histogram[bin]++;
}
const meanMagnitude = sumMag / n;
const deadFraction = deadCount / n;
const saturatedFraction = saturatedCount / n;
// Normalize histogram to fractions
for (let i = 0; i < HISTOGRAM_BINS; i++) {
histogram[i] /= n;
}
let health;
if (deadFraction > DEAD_FRACTION_LIMIT) {
health = 'dead';
} else if (saturatedFraction > SATURATED_FRACTION_LIMIT) {
health = 'saturating';
} else {
health = 'healthy';
}
this._status = {
health,
meanMagnitude,
maxMagnitude: maxMag,
deadFraction,
saturatedFraction,
histogram,
};
}
/**
* Get the current health status.
* @returns {object|null} Status object or null if never updated.
*/
getStatus() {
return this._status;
}
/**
* Draw a compact visual indicator (ambient glow bar).
*
* @param {CanvasRenderingContext2D} ctx
* @param {number} x left edge
* @param {number} y top edge
* @param {number} width total width
* @param {number} height total height
*/
draw(ctx, x, y, width, height) {
if (!this._status) {
// No data — draw a dim placeholder
ctx.fillStyle = 'rgba(60, 60, 60, 0.4)';
ctx.fillRect(x, y, width, height);
return;
}
const { health, deadFraction, saturatedFraction, histogram } = this._status;
// Advance pulse phase
this._pulsePhase += 0.04;
// Determine base color + glow intensity
let color;
let glowAlpha;
let pulseAmount = 0;
if (health === 'healthy') {
color = COLOR_HEALTHY;
glowAlpha = 0.6 + 0.15 * Math.sin(this._pulsePhase * 0.3); // gentle breathing
pulseAmount = 0;
} else if (health === 'saturating') {
color = COLOR_SATURATING;
glowAlpha = 0.7;
pulseAmount = 0.25 * (0.5 + 0.5 * Math.sin(this._pulsePhase * 1.5)); // pulsing
} else {
// dead
color = COLOR_DEAD;
glowAlpha = 0.3;
pulseAmount = 0;
}
const alpha = glowAlpha + pulseAmount;
// Background
ctx.fillStyle = 'rgba(20, 20, 20, 0.6)';
ctx.fillRect(x, y, width, height);
// Draw mini histogram bars
const barWidth = width / HISTOGRAM_BINS;
const maxBinVal = Math.max(...histogram, 0.01);
for (let i = 0; i < HISTOGRAM_BINS; i++) {
const barH = (histogram[i] / maxBinVal) * height;
const bx = x + i * barWidth;
const by = y + height - barH;
// Color gradient: first bins (low magnitude) lean toward dead color,
// last bins (high magnitude) lean toward saturating color
const t = i / (HISTOGRAM_BINS - 1);
let r, g, b;
if (t < 0.3) {
// Low magnitude — blend dead/healthy
const lt = t / 0.3;
r = COLOR_DEAD.r + (color.r - COLOR_DEAD.r) * lt;
g = COLOR_DEAD.g + (color.g - COLOR_DEAD.g) * lt;
b = COLOR_DEAD.b + (color.b - COLOR_DEAD.b) * lt;
} else if (t > 0.7) {
// High magnitude — blend toward saturating
const lt = (t - 0.7) / 0.3;
r = color.r + (COLOR_SATURATING.r - color.r) * lt;
g = color.g + (COLOR_SATURATING.g - color.g) * lt;
b = color.b + (COLOR_SATURATING.b - color.b) * lt;
} else {
r = color.r;
g = color.g;
b = color.b;
}
ctx.fillStyle = `rgba(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)}, ${alpha.toFixed(2)})`;
ctx.fillRect(bx, by, barWidth - 1, barH);
}
// Top glow line
ctx.fillStyle = `rgba(${color.r}, ${color.g}, ${color.b}, ${(alpha * 0.8).toFixed(2)})`;
ctx.fillRect(x, y, width, 1);
// Status label
ctx.font = '8px monospace';
ctx.fillStyle = `rgba(${color.r}, ${color.g}, ${color.b}, ${(alpha * 0.9).toFixed(2)})`;
ctx.textBaseline = 'top';
ctx.textAlign = 'left';
const label = health === 'healthy'
? `W: ok`
: health === 'saturating'
? `W: sat ${(saturatedFraction * 100).toFixed(0)}%`
: `W: dead ${(deadFraction * 100).toFixed(0)}%`;
ctx.fillText(label, x + 2, y + 1);
}
}