Builds the SolidJS-side feature parity with the legacy a-immersive
playground. Every Phase 1–4 feature called out in
.local/recon/04-playground.md now flows through the runtime → stores →
primitives pipeline.
Phase 1 (compound axes / zoom / drawer / trail):
- control-routing.ts: resolves Boldness/Memory/Precision into
input/output/exploration store writes via createEffect.
- SettingsDrawer (new): tabbed sections for Input / Training /
Exploration / Output / Per-param overrides / Advanced. Per-param
ParamEditor list wires through modeStore.setOverride.
- JoyMap navigator added to ModeShell; tap-to-return + long-press
region pin handlers wired into the runtime.
- Anchor mode toggle (auto/sticky/center) exposed via drawer.
Phase 2 (pinning / history / A/B):
- features/snapshots.ts: bridge sessionStore push/pop to actual
Float32Array weights. Auto-snapshot before train/randomize/thumbs-down.
- Undo button + long-press snapshot list popup wired in ModeShell.
- A/B capture/toggle/accept/revert in SettingsDrawer.
- features/overrides.ts buildPinMask() composes override `pinned`
flags + sessionStore param pins; passed to mlStore.moveWeights.
- Region pinning long-press → addRegionPin + auto-snapshot tag
"pinned baseline".
Phase 3 (refinement / exploration):
- features/heatmap-sampler.ts: 16x16 grid via mlStore.inferBatch,
three colour modes, throttled 5/sec, refreshed on ml.trained /
ml.delta_update bus events.
- exploration-store.ts (new): RL noise level, floor/cap/growth/decay,
spread, learning rate, weight decay, auto-explore, pressure.
- Auto-explore timer in mode-runtime drives moveWeights at user
interval, zoom-scaled intensity.
- Pressure feedback: window pointerdown/up timestamps mapped to
explorationStore.setPressure; feeds growNoise/decayNoise.
Phase 4 (output / persistence / polish):
- features/weight-health.ts: histogram, status, per-layer L2 deltas,
vanishing/exploding/converged classification.
- features/session-preset.ts: full state capture/restore +
base64url URL sharing; main.tsx applies on load.
- SettingsDrawer Advanced tab renders WeightHealth, GradientFlow,
LayerStats, Heatmap, session preset save/load/share.
Override application:
- features/overrides.ts applies per-param mute/freeze/curve/range
between MLP outputs and the engine. Mode runtime exposes
`paramOutputs` (length = schema.params) which all modes now bind to
OutputDisplay so ranges and mutes are visible.
- Freeze flags push into outputStore.freezeMask reactively.
Mic input:
- features/mic-input.ts: getUserMedia + AnalyserNode-derived
{energy, brightness, pitch, aperiodicity}. Mode runtime feeds
them into channels 2..(input_size-1) when active. ModeShell shows
a Mic toggle for audio_in modes.
Debug probe (probe.ts):
- Synchronous bypass for snapshot, A/B, pins, overrides, axes,
spread, output freeze, heatmap, weight health, session presets,
URL params, bus emit/on. Untracked reads/writes throughout to
avoid SolidJS reactivity surprises in tests.
Modes:
- All 8 firmware mode TSX files now use the default SettingsDrawer
(no per-mode SliderBank scaffolding). They bind OutputDisplay to
runtime.paramOutputs (post-override) instead of processedOutputs.
Build status:
- bun run typecheck: clean
- bun run build: clean
- dev server smoke: index, /modes, mode-runtime, SettingsDrawer,
features/* all serve.
Note: bd close meml-5wg failed because Dolt server unreachable from
this worktree. Issue should be closed manually by orchestrator.
288 lines
9.2 KiB
TypeScript
288 lines
9.2 KiB
TypeScript
/**
|
|
* Session preset — full app-state snapshot for save/load and URL sharing.
|
|
*
|
|
* A preset captures: control axes + offsets, exploration config, output
|
|
* pipeline settings, input pipeline settings, mode override map, the
|
|
* active mode id. ML weights are NOT included by default (large), but
|
|
* `serializeWithWeights` is provided for power users.
|
|
*
|
|
* URL sharing uses a compact encoding:
|
|
* ?session=<base64url(JSON)>
|
|
* ?boldness=...&memory=...&precision=...
|
|
* Either form rehydrates on next load.
|
|
*/
|
|
|
|
import { controlStore } from '../stores/control-store';
|
|
import { explorationStore } from '../stores/exploration-store';
|
|
import { modeStore } from '../stores/mode-store';
|
|
import { inputStore } from '../stores/input-store';
|
|
import { outputStore } from '../stores/output-store';
|
|
import { mlStore } from '../stores/ml-store';
|
|
import { sessionStore } from '../stores/session-store';
|
|
|
|
export interface SessionPresetPayload {
|
|
v: 1;
|
|
modeId: string | null;
|
|
control: {
|
|
boldness: number;
|
|
memory: number;
|
|
precision: number;
|
|
presetId: string | null;
|
|
offsets: typeof controlStore.state.offsets;
|
|
};
|
|
exploration: {
|
|
spread: number;
|
|
noiseFloor: number;
|
|
noiseCap: number;
|
|
noiseGrowth: number;
|
|
noiseDecay: number;
|
|
learningRate: number;
|
|
weightDecay: number;
|
|
};
|
|
output: {
|
|
globalCurve: number;
|
|
smoothing: number;
|
|
slewRate: number | null; // null encodes Infinity
|
|
freezeOutput: boolean;
|
|
};
|
|
input: {
|
|
zoom: number;
|
|
anchorMode: 'auto' | 'sticky' | 'center';
|
|
deadzone: number;
|
|
inputCurve: number;
|
|
smoothing: number;
|
|
momentumZoom: 'off' | 'gentle' | 'strong';
|
|
invertX: boolean;
|
|
invertY: boolean;
|
|
};
|
|
modeOverrides: typeof modeStore.state.overrides;
|
|
/** Base64-encoded weights, optional. */
|
|
weights?: string;
|
|
}
|
|
|
|
function encode(s: string): string {
|
|
// base64url encoding
|
|
const b64 = btoa(s);
|
|
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
}
|
|
|
|
function decode(s: string): string | null {
|
|
try {
|
|
let b64 = s.replace(/-/g, '+').replace(/_/g, '/');
|
|
while (b64.length % 4 !== 0) b64 += '=';
|
|
return atob(b64);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function f32ToBase64(arr: Float32Array): string {
|
|
const u8 = new Uint8Array(arr.buffer, arr.byteOffset, arr.byteLength);
|
|
let s = '';
|
|
for (let i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]!);
|
|
return encode(s);
|
|
}
|
|
|
|
function base64ToF32(s: string): Float32Array | null {
|
|
const raw = decode(s);
|
|
if (!raw) return null;
|
|
const u8 = new Uint8Array(raw.length);
|
|
for (let i = 0; i < raw.length; i++) u8[i] = raw.charCodeAt(i);
|
|
// Round to 4 bytes
|
|
const len = u8.byteLength - (u8.byteLength % 4);
|
|
return new Float32Array(u8.buffer, u8.byteOffset, len / 4).slice();
|
|
}
|
|
|
|
/**
|
|
* Read every store and assemble a SessionPresetPayload. If
|
|
* `withWeights = true`, also encodes the current MLP weight blob.
|
|
*/
|
|
export function captureSessionPreset(withWeights: boolean = false): SessionPresetPayload {
|
|
const c = controlStore.state;
|
|
const e = explorationStore.state;
|
|
const o = outputStore.config;
|
|
const i = inputStore.config;
|
|
|
|
const payload: SessionPresetPayload = {
|
|
v: 1,
|
|
modeId: modeStore.state.activeModeId,
|
|
control: {
|
|
boldness: c.boldness,
|
|
memory: c.memory,
|
|
precision: c.precision,
|
|
presetId: c.presetId,
|
|
offsets: JSON.parse(JSON.stringify(c.offsets)),
|
|
},
|
|
exploration: {
|
|
spread: e.spread,
|
|
noiseFloor: e.noiseFloor,
|
|
noiseCap: e.noiseCap,
|
|
noiseGrowth: e.noiseGrowth,
|
|
noiseDecay: e.noiseDecay,
|
|
learningRate: e.learningRate,
|
|
weightDecay: e.weightDecay,
|
|
},
|
|
output: {
|
|
globalCurve: o.globalCurve,
|
|
smoothing: o.smoothing,
|
|
slewRate: isFinite(o.slewRate) ? o.slewRate : null,
|
|
freezeOutput: o.freezeOutput,
|
|
},
|
|
input: {
|
|
zoom: i.zoom,
|
|
anchorMode: i.anchorMode,
|
|
deadzone: i.deadzone,
|
|
inputCurve: i.inputCurve,
|
|
smoothing: i.smoothing,
|
|
momentumZoom: i.momentumZoom,
|
|
invertX: i.invertX,
|
|
invertY: i.invertY,
|
|
},
|
|
modeOverrides: JSON.parse(JSON.stringify(modeStore.state.overrides)),
|
|
};
|
|
|
|
if (withWeights) {
|
|
const w = mlStore.getWeights();
|
|
if (w.length > 0) {
|
|
payload.weights = f32ToBase64(w);
|
|
}
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
/**
|
|
* Restore a SessionPresetPayload into all stores. Best-effort: missing
|
|
* fields fall back to current values. Returns true if at least one store
|
|
* was modified.
|
|
*/
|
|
export function restoreSessionPreset(payload: SessionPresetPayload): boolean {
|
|
if (!payload || payload.v !== 1) return false;
|
|
|
|
// Mode first (so override map is keyed correctly).
|
|
if (payload.modeId && payload.modeId !== modeStore.state.activeModeId) {
|
|
modeStore.switchMode(payload.modeId);
|
|
}
|
|
|
|
// Control axes & offsets.
|
|
if (payload.control) {
|
|
controlStore.setAxis('boldness', payload.control.boldness);
|
|
controlStore.setAxis('memory', payload.control.memory);
|
|
controlStore.setAxis('precision', payload.control.precision);
|
|
if (payload.control.presetId) controlStore.applyPreset(payload.control.presetId);
|
|
// Manually rewrite offsets — the store doesn't have a public bulk setter
|
|
// (control-store doesn't expose it because it's a private field).
|
|
// Use clearOffsets + setOffset roundtrip.
|
|
for (const axis of ['boldness', 'memory', 'precision'] as const) {
|
|
controlStore.clearOffsets(axis);
|
|
const off = payload.control.offsets[axis] ?? {};
|
|
for (const [k, v] of Object.entries(off)) {
|
|
if (typeof v === 'number') controlStore.setOffset(axis, k, v);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (payload.exploration) {
|
|
explorationStore.setSpread(payload.exploration.spread);
|
|
explorationStore.setNoiseFloor(payload.exploration.noiseFloor);
|
|
explorationStore.setNoiseCap(payload.exploration.noiseCap);
|
|
explorationStore.setNoiseGrowth(payload.exploration.noiseGrowth);
|
|
explorationStore.setNoiseDecay(payload.exploration.noiseDecay);
|
|
explorationStore.setLearningRate(payload.exploration.learningRate);
|
|
explorationStore.setWeightDecay(payload.exploration.weightDecay);
|
|
}
|
|
|
|
if (payload.output) {
|
|
outputStore.setGlobalCurve(payload.output.globalCurve);
|
|
outputStore.setSmoothing(payload.output.smoothing);
|
|
outputStore.setSlewRate(payload.output.slewRate ?? Infinity);
|
|
outputStore.setFreezeOutput(payload.output.freezeOutput);
|
|
}
|
|
|
|
if (payload.input) {
|
|
inputStore.setZoom(payload.input.zoom);
|
|
inputStore.setAnchorMode(payload.input.anchorMode);
|
|
inputStore.setDeadzone(payload.input.deadzone);
|
|
inputStore.setInputCurve(payload.input.inputCurve);
|
|
inputStore.setSmoothing(payload.input.smoothing);
|
|
inputStore.setMomentumZoom(payload.input.momentumZoom);
|
|
inputStore.setInvert(payload.input.invertX, payload.input.invertY);
|
|
}
|
|
|
|
if (payload.modeOverrides) {
|
|
for (const modeId of Object.keys(payload.modeOverrides)) {
|
|
const map = payload.modeOverrides[modeId] ?? {};
|
|
modeStore.clearAllOverrides(modeId);
|
|
for (const [name, ov] of Object.entries(map)) {
|
|
modeStore.setOverride(modeId, name, ov);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (payload.weights) {
|
|
const w = base64ToF32(payload.weights);
|
|
if (w && mlStore.iml && w.length > 0) {
|
|
try { mlStore.setWeights(w); } catch { /* ignore size mismatch */ }
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/** Save preset to sessionStore (named blob in localStorage). */
|
|
export function saveNamedPreset(name: string, withWeights: boolean = false): void {
|
|
const payload = captureSessionPreset(withWeights);
|
|
sessionStore.savePreset(name, payload);
|
|
}
|
|
|
|
/** Load a named preset by id from sessionStore. */
|
|
export function loadNamedPreset(id: string): boolean {
|
|
const preset = sessionStore.state.presets.find((p) => p.id === id);
|
|
if (!preset) return false;
|
|
return restoreSessionPreset(preset.payload as SessionPresetPayload);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// URL sharing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Build a query-string fragment from current state (without weights). */
|
|
export function buildShareUrl(): string {
|
|
const payload = captureSessionPreset(false);
|
|
const json = JSON.stringify(payload);
|
|
const enc = encode(json);
|
|
return `?session=${enc}`;
|
|
}
|
|
|
|
/** Parse `window.location.search` and apply a session if present. */
|
|
export function applyUrlParams(search: string = window.location.search): boolean {
|
|
const sp = new URLSearchParams(search);
|
|
// Compact ?boldness=...&memory=... form.
|
|
const cb = sp.get('boldness');
|
|
const cm = sp.get('memory');
|
|
const cp = sp.get('precision');
|
|
let applied = false;
|
|
if (cb !== null || cm !== null || cp !== null) {
|
|
if (cb !== null) controlStore.setAxis('boldness', clamp01(parseFloat(cb)));
|
|
if (cm !== null) controlStore.setAxis('memory', clamp01(parseFloat(cm)));
|
|
if (cp !== null) controlStore.setAxis('precision', clamp01(parseFloat(cp)));
|
|
applied = true;
|
|
}
|
|
const sess = sp.get('session');
|
|
if (sess) {
|
|
const json = decode(sess);
|
|
if (json) {
|
|
try {
|
|
const payload = JSON.parse(json) as SessionPresetPayload;
|
|
applied = restoreSessionPreset(payload) || applied;
|
|
} catch {
|
|
// Ignore malformed
|
|
}
|
|
}
|
|
}
|
|
return applied;
|
|
}
|
|
|
|
function clamp01(v: number): number {
|
|
if (!isFinite(v)) return 0;
|
|
return v < 0 ? 0 : v > 1 ? 1 : v;
|
|
}
|