memlnaut-nisps/playground/js/ui/session-presets.js
w1n5t0n 1f21494dee 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.
2026-03-26 10:48:12 +02:00

243 lines
6.7 KiB
JavaScript

/**
* 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);
}
}
}