feat(playground): solid stores + signal bus

Seven Solid stores wired up around the architecture's reactivity model
(§7.1). Each store uses createStore for object state + createSignal for
Float32Arrays where appropriate; setters mutate the store and schedule a
debounced (200ms) localStorage write through src/stores/persistence.ts.

- bus.ts: typed signal bus with prefix wildcards (ml.*, ui.*, mode.*,
  pin.*, snap.*). Singleton coreBus for app-wide events.
- ml-store.ts: shape final, methods stub-throw "not implemented" until
  stream 7 wires WASM. Outputs and weights are separate Float32Array
  signals so the store proxy doesn't run on every audio-rate tick.
- input-store.ts: full input pipeline config (zoom, anchor, deadzone,
  curve, smoothing, momentum, invert) + persisted live state.
- output-store.ts: globalCurve, smoothing, slewRate, freezeOutput,
  freezeMask. Mask not persisted (engine-specific).
- mode-store.ts: activeModeId + per-mode { paramName → ParamOverride }.
- control-store.ts: Boldness/Memory/Precision compound axes with
  interpolation tables and offset-based overrides (trim-pot model).
  Tables and 6 built-in CONTROL_PRESETS mirror legacy
  js/ui/control-surface.js exactly. interpolateAxis() exposed for
  testing. Stream 10 wires resolveParams() into other stores.
- session-store.ts: ring-buffered snapshot stack (max 20), A/B
  capture/toggle/accept/revert, region pins (max 5), param pins
  (toggle + mask builder), named session presets.
- index.ts: public re-exports for components and modes.

Stream 8 of the rewrite (meml-911).
This commit is contained in:
w1n5t0n 2026-04-29 15:38:05 +03:00
parent 665e224122
commit add05ea554
9 changed files with 1327 additions and 0 deletions

View file

@ -0,0 +1,129 @@
/**
* Typed signal bus.
*
* Topics use dotted prefixes (`ml.*`, `ui.*`, `mode.*`, `pin.*`). Listeners
* may subscribe to a specific topic or to a prefix wildcard. The bus is
* simple synchronous pub/sub handlers run in order of registration.
*
* The bus is generic over an event-map type so callers get full type
* safety on emit/listen.
*
* Example:
*
* type Events = {
* 'ml.trained': { loss: number };
* 'ml.delta_update': void;
* 'ui.preset_load': { id: string };
* 'pin.create': { region: { x: number; y: number; w: number; h: number } };
* };
* const bus = createBus<Events>();
* bus.on('ml.trained', ({ loss }) => console.log(loss));
* bus.emit('ml.trained', { loss: 0.42 });
*/
export type EventMap = Record<string, unknown>;
type Handler<T> = (data: T, eventName: string) => void;
export interface Bus<E extends EventMap> {
emit<K extends keyof E & string>(topic: K, data: E[K]): void;
on<K extends keyof E & string>(topic: K, handler: Handler<E[K]>): () => void;
/** Subscribe to a prefix; receives any matching topic and its data. */
onPrefix(prefix: string, handler: Handler<unknown>): () => void;
/** Remove all handlers (test cleanup). */
clear(): void;
}
export function createBus<E extends EventMap>(): Bus<E> {
const handlers = new Map<string, Set<Handler<unknown>>>();
const prefixHandlers: Array<{ prefix: string; handler: Handler<unknown> }> = [];
function emit<K extends keyof E & string>(topic: K, data: E[K]): void {
const set = handlers.get(topic);
if (set) {
for (const h of set) {
try {
h(data as unknown, topic);
} catch (err) {
// Don't let one handler kill the rest.
// eslint-disable-next-line no-console
console.error(`[bus] handler for "${topic}" threw:`, err);
}
}
}
for (const { prefix, handler } of prefixHandlers) {
if (topic === prefix || topic.startsWith(prefix + '.')) {
try {
handler(data as unknown, topic);
} catch (err) {
// eslint-disable-next-line no-console
console.error(`[bus] prefix handler "${prefix}" threw on "${topic}":`, err);
}
}
}
}
function on<K extends keyof E & string>(topic: K, handler: Handler<E[K]>): () => void {
let set = handlers.get(topic);
if (!set) {
set = new Set();
handlers.set(topic, set);
}
set.add(handler as Handler<unknown>);
return () => {
set!.delete(handler as Handler<unknown>);
};
}
function onPrefix(prefix: string, handler: Handler<unknown>): () => void {
const entry = { prefix, handler };
prefixHandlers.push(entry);
return () => {
const idx = prefixHandlers.indexOf(entry);
if (idx >= 0) prefixHandlers.splice(idx, 1);
};
}
function clear(): void {
handlers.clear();
prefixHandlers.length = 0;
}
return { emit, on, onPrefix, clear };
}
/**
* Canonical event map used throughout the playground.
*
* Adding a new topic? Add it here AND ensure consumers update.
*/
export type CoreEvents = {
// ML
'ml.trained': { loss: number };
'ml.delta_update': { reason: 'thumbs_up' | 'thumbs_down' | 'randomize' | 'undo' };
'ml.example_added': { count: number };
'ml.examples_cleared': void;
// UI
'ui.mode_switch': { from: string | null; to: string };
'ui.preset_load': { kind: 'control' | 'synth' | 'session'; id: string };
'ui.drawer_toggle': { id: string; open: boolean };
// Mode
'mode.params_changed': { modeId: string };
// Pin
'pin.create': { kind: 'region' | 'param'; id: string };
'pin.remove': { kind: 'region' | 'param'; id: string };
// Snapshots
'snap.push': { tag: string };
'snap.pop': { tag: string | null };
'snap.cleared': void;
};
/**
* Singleton bus instance for the app. Tests can call `coreBus.clear()` to
* reset between test cases.
*/
export const coreBus: Bus<CoreEvents> = createBus<CoreEvents>();

View file

@ -0,0 +1,257 @@
/**
* Control store compound axes (Boldness, Memory, Precision) + offset overrides.
*
* Reference: `.local/playground-archive/SPEC-controls.md` and
* `.local/playground-archive/playground/js/ui/control-surface.js` (legacy
* implementation). The contract is:
*
* - Each axis has a [0,1] value.
* - Each axis derives a set of underlying parameters via interpolation
* tables. Adjacent rows are linearly interpolated; discrete params snap
* at 0.75 toward the higher row.
* - Per-parameter overrides are *offsets* added to the axis-derived value
* (trim-pot model). Double-tap an axis clears its offsets.
* - 6 built-in presets set axis triplets without touching offsets.
*
* Stream 10 wires the resolved params back into input/output/RL stores.
* For now this store exposes `resolveParams()` so primitives can render
* meaningful values and tests can verify interpolation.
*/
import { createStore, produce } from 'solid-js/store';
import { schedulePersist, loadPersisted } from './persistence';
import { clamp } from '../output/curves';
const STORAGE_KEY = 'nisps:control';
// ---------------------------------------------------------------------------
// Tables (mirrored from legacy control-surface.js — keep in lockstep)
// ---------------------------------------------------------------------------
type ParamValue = number | string | boolean;
interface TableRow {
axis: number;
values: Record<string, ParamValue>;
}
const BOLDNESS_TABLE: TableRow[] = [
{ axis: 0.0, values: { zoom: 0.1, noiseCap: 0.02, noiseGrowth: 1.1, learningRate: 0.1, weightDecay: 0.15, noiseDistribution: 'gaussian' } },
{ axis: 0.5, values: { zoom: 0.5, noiseCap: 0.12, noiseGrowth: 1.5, learningRate: 1.0, weightDecay: 0.06, noiseDistribution: 'gaussian' } },
{ axis: 1.0, values: { zoom: 1.0, noiseCap: 0.30, noiseGrowth: 2.5, learningRate: 3.0, weightDecay: 0.00, noiseDistribution: 'cauchy' } },
];
const MEMORY_TABLE: TableRow[] = [
{ axis: 0.0, values: { maxExamples: 5, exampleDecay: 0.3, memoryWeightDecay: 0.20, noiseDecay: 0.85, convergenceThreshold: 1e-3 } },
{ axis: 0.5, values: { maxExamples: 50, exampleDecay: 0.7, memoryWeightDecay: 0.06, noiseDecay: 0.97, convergenceThreshold: 1e-5 } },
{ axis: 1.0, values: { maxExamples: 500, exampleDecay: 1.0, memoryWeightDecay: 0.00, noiseDecay: 0.995, convergenceThreshold: 1e-8 } },
];
const PRECISION_TABLE: TableRow[] = [
{ axis: 0.0, values: { inputCurve: 1.0, deadzone: 0.0, smoothing: 0.0, slewRate: 1.0, momentumZoom: 'off' } },
{ axis: 0.5, values: { inputCurve: 1.5, deadzone: 0.05, smoothing: 0.15, slewRate: 0.3, momentumZoom: 'off' } },
{ axis: 1.0, values: { inputCurve: 3.0, deadzone: 0.15, smoothing: 0.40, slewRate: 0.1, momentumZoom: 'off' } },
];
const TABLES = {
boldness: BOLDNESS_TABLE,
memory: MEMORY_TABLE,
precision: PRECISION_TABLE,
} as const;
export type AxisName = keyof typeof TABLES;
export interface ControlPreset {
id: string;
label: string;
boldness: number;
memory: number;
precision: number;
}
export const CONTROL_PRESETS: ReadonlyArray<ControlPreset> = [
{ id: 'default', label: 'Default', boldness: 0.5, memory: 0.5, precision: 0.3 },
{ id: 'first-touch', label: 'First Touch', boldness: 0.2, memory: 0.7, precision: 0.6 },
{ id: 'jazz-hands', label: 'Jazz Hands', boldness: 0.8, memory: 0.2, precision: 0.0 },
{ id: 'sculptor', label: 'Sculptor', boldness: 0.3, memory: 0.9, precision: 0.8 },
{ id: 'improviser', label: 'Improviser', boldness: 0.6, memory: 0.3, precision: 0.2 },
{ id: 'microscope', label: 'Microscope', boldness: 0.1, memory: 1.0, precision: 1.0 },
];
// ---------------------------------------------------------------------------
// Interpolation
// ---------------------------------------------------------------------------
function interpolate(table: TableRow[], axisValue: number): Record<string, ParamValue> {
const v = clamp(axisValue, 0, 1);
// Find bracketing rows
let lo = table[0]!;
let hi = table[table.length - 1]!;
for (let i = 0; i < table.length - 1; i++) {
const a = table[i]!;
const b = table[i + 1]!;
if (v >= a.axis && v <= b.axis) {
lo = a;
hi = b;
break;
}
}
if (lo === hi) return { ...lo.values };
const t = (v - lo.axis) / (hi.axis - lo.axis);
const out: Record<string, ParamValue> = {};
for (const key of Object.keys(lo.values)) {
const a = lo.values[key]!;
const b = hi.values[key];
if (typeof a === 'number' && typeof b === 'number') {
out[key] = a + (b - a) * t;
} else {
// discrete: snap at 0.75
out[key] = t >= 0.75 ? (b ?? a) : a;
}
}
return out;
}
// ---------------------------------------------------------------------------
// Store shape
// ---------------------------------------------------------------------------
export interface ControlState {
boldness: number;
memory: number;
precision: number;
/** Per-axis offset map: { axisName: { paramName: offset } }. */
offsets: { boldness: Record<string, number>; memory: Record<string, number>; precision: Record<string, number> };
/** Last-loaded preset id, if any. */
presetId: string | null;
}
interface PersistedControl {
boldness: number;
memory: number;
precision: number;
offsets: ControlState['offsets'];
presetId: string | null;
}
function loadInitial(): ControlState {
const fallback: ControlState = {
boldness: 0.5,
memory: 0.5,
precision: 0.3,
offsets: { boldness: {}, memory: {}, precision: {} },
presetId: 'default',
};
const persisted = loadPersisted<Partial<PersistedControl>>(STORAGE_KEY, {});
return {
boldness: persisted.boldness ?? fallback.boldness,
memory: persisted.memory ?? fallback.memory,
precision: persisted.precision ?? fallback.precision,
offsets: persisted.offsets ?? fallback.offsets,
presetId: persisted.presetId ?? fallback.presetId,
};
}
const [state, setState] = createStore<ControlState>(loadInitial());
function persist(): void {
schedulePersist(STORAGE_KEY, () => ({
boldness: state.boldness,
memory: state.memory,
precision: state.precision,
offsets: state.offsets,
presetId: state.presetId,
}));
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export const controlStore = {
state,
setAxis(axis: AxisName, value: number): void {
const v = clamp(value, 0, 1);
setState(produce((s) => {
s[axis] = v;
// Manual axis movement clears the active preset id (can't be canonical anymore)
if (s.presetId !== null) s.presetId = null;
}));
persist();
},
/** Set a per-axis offset for a single parameter. Trim-pot model. */
setOffset(axis: AxisName, paramName: string, offset: number): void {
setState(produce((s) => {
s.offsets[axis][paramName] = offset;
}));
persist();
},
/** Clear all offsets on an axis (re-link). Equivalent to double-tap. */
clearOffsets(axis: AxisName): void {
setState(produce((s) => {
s.offsets[axis] = {};
}));
persist();
},
/** Apply a built-in preset (axis triplet only — preserves offsets). */
applyPreset(id: string): boolean {
const p = CONTROL_PRESETS.find((q) => q.id === id);
if (!p) return false;
setState(produce((s) => {
s.boldness = p.boldness;
s.memory = p.memory;
s.precision = p.precision;
s.presetId = p.id;
}));
persist();
return true;
},
/**
* Resolve every underlying parameter value, applying axis interpolation
* and per-axis offsets. Stream 10 fills in the routing into other stores;
* for now this is the contract.
*/
resolveParams(): Record<string, ParamValue> {
const b = interpolate(BOLDNESS_TABLE, state.boldness);
const m = interpolate(MEMORY_TABLE, state.memory);
const p = interpolate(PRECISION_TABLE, state.precision);
const all: Record<string, ParamValue> = { ...b, ...m, ...p };
// Apply offsets — only sensible for numeric params.
const applyOffsets = (axis: AxisName) => {
const offsets = state.offsets[axis];
for (const [k, off] of Object.entries(offsets)) {
const v = all[k];
if (typeof v === 'number') {
all[k] = v + off;
}
}
};
applyOffsets('boldness');
applyOffsets('memory');
applyOffsets('precision');
return all;
},
reset(): void {
setState({
boldness: 0.5,
memory: 0.5,
precision: 0.3,
offsets: { boldness: {}, memory: {}, precision: {} },
presetId: 'default',
});
persist();
},
};
export type ControlStore = typeof controlStore;
// Public helpers for tests / showcase
export function interpolateAxis(axis: AxisName, value: number): Record<string, ParamValue> {
return interpolate(TABLES[axis], value);
}

View file

@ -0,0 +1,33 @@
/**
* Public store entry-point.
*
* Re-exports the canonical singletons. Components and modes should import
* from this module rather than reaching into individual store files; that
* makes it easy to swap implementations later.
*/
export { coreBus, createBus, type CoreEvents, type Bus } from './bus';
export { mlStore, type MLStore, type MLStoreState } from './ml-store';
export { inputStore, type InputStore } from './input-store';
export { outputStore, type OutputStore } from './output-store';
export { modeStore, type ModeStore, type ModeStoreState, type ParamOverride, defaultParamOverride } from './mode-store';
export {
controlStore,
type ControlStore,
type ControlState,
type ControlPreset,
type AxisName,
CONTROL_PRESETS,
interpolateAxis,
} from './control-store';
export {
sessionStore,
type SessionStore,
type SessionState,
type Snapshot,
type RegionPin,
type ParamPin,
type ABState,
type SessionPreset,
} from './session-store';
export { schedulePersist, flushPersist, loadPersisted, clearPersisted } from './persistence';

View file

@ -0,0 +1,186 @@
/**
* Input store input pipeline configuration + last-processed coordinate.
*
* Setters mutate the underlying Solid store (so subscribers re-run) and
* schedule a debounced localStorage write.
*
* Architecture §7.1: zoom, anchor, anchor mode, deadzone, curve, smoothing,
* momentum, invert.
*/
import { createStore, produce } from 'solid-js/store';
import {
defaultInputConfig,
defaultInputState,
type AnchorMode,
type InputConfig,
type InputState,
type MomentumZoomMode,
ZOOM_MIN,
ZOOM_MAX,
DEADZONE_MAX,
INPUT_CURVE_MIN,
INPUT_CURVE_MAX,
SMOOTHING_MAX,
} from '../input/pipeline';
import { schedulePersist, loadPersisted } from './persistence';
import { clamp } from '../output/curves';
const STORAGE_KEY = 'nisps:input';
interface PersistedInput {
zoom: number;
zoomX: number | null;
zoomY: number | null;
anchorX: number;
anchorY: number;
anchorMode: AnchorMode;
deadzone: number;
inputCurve: number;
inputCurveX: number | null;
inputCurveY: number | null;
smoothing: number;
momentumZoom: MomentumZoomMode;
velocityWindow: number;
invertX: boolean;
invertY: boolean;
}
function loadInputConfig(): InputConfig {
const base = defaultInputConfig();
const persisted = loadPersisted<Partial<PersistedInput>>(STORAGE_KEY, {});
return { ...base, ...persisted };
}
const [config, setConfig] = createStore<InputConfig>(loadInputConfig());
// Live state — NOT persisted; reset on reload.
const [liveState, setLiveState] = createStore<InputState>(defaultInputState());
function persist(): void {
schedulePersist(STORAGE_KEY, () => ({ ...config }));
}
export const inputStore = {
config,
liveState,
setZoom(zoom: number): void {
const z = clamp(zoom, ZOOM_MIN, ZOOM_MAX);
setConfig(produce((c) => {
const prev = c.zoom;
c.zoom = z;
// Auto-anchor mode: snap anchor to current smoothed pos when zoom changes
if (c.anchorMode === 'auto' && prev !== z) {
c.anchorX = liveState.smoothedX;
c.anchorY = liveState.smoothedY;
}
}));
persist();
},
setZoomPerAxis(zoomX: number | null, zoomY: number | null): void {
setConfig(produce((c) => {
c.zoomX = zoomX !== null ? clamp(zoomX, ZOOM_MIN, ZOOM_MAX) : null;
c.zoomY = zoomY !== null ? clamp(zoomY, ZOOM_MIN, ZOOM_MAX) : null;
if (c.anchorMode === 'auto') {
c.anchorX = liveState.smoothedX;
c.anchorY = liveState.smoothedY;
}
}));
persist();
},
setAnchor(x: number, y: number): void {
setConfig(produce((c) => {
c.anchorX = clamp(x, 0, 1);
c.anchorY = clamp(y, 0, 1);
}));
persist();
},
setAnchorMode(mode: AnchorMode): void {
setConfig(produce((c) => {
c.anchorMode = mode;
if (mode === 'center') {
c.anchorX = 0.5;
c.anchorY = 0.5;
} else if (mode === 'auto') {
c.anchorX = liveState.smoothedX;
c.anchorY = liveState.smoothedY;
}
}));
persist();
},
setDeadzone(d: number): void {
setConfig(produce((c) => {
c.deadzone = clamp(d, 0, DEADZONE_MAX);
}));
persist();
},
setInputCurve(exp: number): void {
setConfig(produce((c) => {
c.inputCurve = clamp(exp, INPUT_CURVE_MIN, INPUT_CURVE_MAX);
}));
persist();
},
setInputCurvePerAxis(expX: number | null, expY: number | null): void {
setConfig(produce((c) => {
c.inputCurveX = expX !== null ? clamp(expX, INPUT_CURVE_MIN, INPUT_CURVE_MAX) : null;
c.inputCurveY = expY !== null ? clamp(expY, INPUT_CURVE_MIN, INPUT_CURVE_MAX) : null;
}));
persist();
},
setSmoothing(s: number): void {
setConfig(produce((c) => {
c.smoothing = clamp(s, 0, SMOOTHING_MAX);
}));
persist();
},
setMomentumZoom(mode: MomentumZoomMode): void {
setConfig(produce((c) => {
c.momentumZoom = mode;
}));
if (mode === 'off') {
// Reset live momentum state
setLiveState(produce((s) => {
s.velocityHistory = [];
s.momentumZoomMultiplier = 1;
}));
}
persist();
},
setInvert(invertX: boolean, invertY: boolean): void {
setConfig(produce((c) => {
c.invertX = invertX;
c.invertY = invertY;
}));
persist();
},
setVelocityWindow(ms: number): void {
setConfig(produce((c) => {
c.velocityWindow = clamp(ms, 50, 500);
}));
persist();
},
/** Update live state after running the pipeline. Not persisted. */
__setLiveState(next: InputState): void {
setLiveState(next);
},
reset(): void {
setConfig(defaultInputConfig());
setLiveState(defaultInputState());
persist();
},
};
export type InputStore = typeof inputStore;

View file

@ -0,0 +1,116 @@
/**
* ML store placeholder shape for the ML engine state.
*
* Stream 7 wires WASM under this. For now the methods that mutate the engine
* throw `not implemented`. The shape of the store and the signal types are
* final modes and primitives can read them.
*
* Why a Solid store + a separate Float32Array signal:
* - `createStore` is great for object-like state with fine reactivity.
* - Float32Array outputs are large and frequently updated; `createSignal`
* with explicit reference replacement is cheaper.
*/
import { createSignal, type Accessor } from 'solid-js';
import { createStore, produce } from 'solid-js/store';
import { coreBus } from './bus';
export interface MLStoreState {
exampleCount: number;
/** Last training loss (final loss of last train() call). null = none yet. */
lastLoss: number | null;
/** Last training run loss curve (per iteration). */
lossHistory: number[];
/** Current input vector size (matches active mode). */
inputSize: number;
/** Current output vector size (matches active mode). */
outputSize: number;
/** True while a synchronous or async training call is running. */
training: boolean;
/** True when WASM is fully initialised. */
ready: boolean;
}
const EMPTY_OUTPUTS = new Float32Array(0);
const NOT_IMPLEMENTED = (op: string): never => {
throw new Error(
`[ml-store] ${op} not implemented in stream-8 scaffold; awaits stream 7 (WASM bindings)`
);
};
const [state, setState] = createStore<MLStoreState>({
exampleCount: 0,
lastLoss: null,
lossHistory: [],
inputSize: 2,
outputSize: 126,
training: false,
ready: false,
});
const [outputs, setOutputs] = createSignal<Float32Array>(EMPTY_OUTPUTS, {
equals: false, // always notify even if reference reused
});
const [weights, setWeights] = createSignal<Float32Array>(EMPTY_OUTPUTS, {
equals: false,
});
export const mlStore = {
// ---- read ----
state,
outputs: outputs as Accessor<Float32Array>,
weights: weights as Accessor<Float32Array>,
// ---- internal setters (used by future WASM wiring; exposed for stub
// wiring during this stream so primitive demos can drive values) ----
__setOutputs: setOutputs,
__setState: setState,
__setWeights: setWeights,
// ---- ML lifecycle (stubbed) ----
initialize(_inputSize: number, _outputSize: number): Promise<void> {
return NOT_IMPLEMENTED('initialize');
},
setInput(_idx: number, _value: number): void {
NOT_IMPLEMENTED('setInput');
},
process(): void {
NOT_IMPLEMENTED('process');
},
addExample(_features: ReadonlyArray<number>, _labels: ReadonlyArray<number>): void {
NOT_IMPLEMENTED('addExample');
},
train(_lr?: number, _maxIter?: number): number {
return NOT_IMPLEMENTED('train');
},
trainAsync(_lr?: number, _maxIter?: number): Promise<number> {
return NOT_IMPLEMENTED('trainAsync');
},
drawWeights(_spread: number): void {
NOT_IMPLEMENTED('drawWeights');
},
moveWeights(_speed: number, _spread: number, _pinMask?: Uint8Array): void {
NOT_IMPLEMENTED('moveWeights');
},
evalLoss(): number | null {
return null;
},
inferBatch(_points: ReadonlyArray<readonly [number, number]>): Float32Array {
return NOT_IMPLEMENTED('inferBatch');
},
getLayerStats(): Float32Array {
return EMPTY_OUTPUTS;
},
reset(): void {
NOT_IMPLEMENTED('reset');
},
clearExamples(): void {
setState(produce((s) => {
s.exampleCount = 0;
}));
coreBus.emit('ml.examples_cleared', undefined);
},
};
export type MLStore = typeof mlStore;

View file

@ -0,0 +1,130 @@
/**
* Mode store active mode + per-mode parameter overrides.
*
* Stream 9 implements the actual modes; this store only holds the *id* and
* a per-mode dictionary of `ParamOverride` objects keyed by parameter name.
*
* Mode selection emits `ui.mode_switch` so audio/ML can react.
*/
import { createStore, produce } from 'solid-js/store';
import { coreBus } from './bus';
import { schedulePersist, loadPersisted } from './persistence';
import type { CurveName } from '../output/curves';
const STORAGE_KEY = 'nisps:mode';
/**
* Per-output override applied between MLP and engine.
*
* - `min`/`max`: range mapping (normalised input [0,1] [min,max])
* - `curve`: pre-mapping curve
* - `muted`: ignore network output, use `fixedValue`
* - `pinned`: skip during weight perturbation
* - `frozen`: hold last value (for output freeze mask)
*/
export interface ParamOverride {
min: number;
max: number;
curve: CurveName;
curveParam?: number;
muted: boolean;
pinned: boolean;
frozen: boolean;
fixedValue: number;
}
export function defaultParamOverride(): ParamOverride {
return {
min: 0,
max: 1,
curve: 'linear',
muted: false,
pinned: false,
frozen: false,
fixedValue: 0.5,
};
}
export interface ModeStoreState {
/** Active mode id (e.g. "paf_synth"). null = none selected yet. */
activeModeId: string | null;
/** Map of modeId → { paramName → ParamOverride }. */
overrides: Record<string, Record<string, ParamOverride>>;
}
interface PersistedMode {
activeModeId: string | null;
overrides: Record<string, Record<string, ParamOverride>>;
}
function loadInitial(): ModeStoreState {
const persisted = loadPersisted<Partial<PersistedMode>>(STORAGE_KEY, {});
return {
activeModeId: persisted.activeModeId ?? null,
overrides: persisted.overrides ?? {},
};
}
const [state, setState] = createStore<ModeStoreState>(loadInitial());
function persist(): void {
schedulePersist(STORAGE_KEY, () => ({
activeModeId: state.activeModeId,
overrides: state.overrides,
}));
}
export const modeStore = {
state,
switchMode(modeId: string): void {
const from = state.activeModeId;
if (from === modeId) return;
setState(produce((s) => {
s.activeModeId = modeId;
if (!s.overrides[modeId]) {
s.overrides[modeId] = {};
}
}));
persist();
coreBus.emit('ui.mode_switch', { from, to: modeId });
},
setOverride(modeId: string, paramName: string, override: ParamOverride): void {
setState(produce((s) => {
if (!s.overrides[modeId]) s.overrides[modeId] = {};
s.overrides[modeId]![paramName] = override;
}));
persist();
coreBus.emit('mode.params_changed', { modeId });
},
clearOverride(modeId: string, paramName: string): void {
setState(produce((s) => {
const map = s.overrides[modeId];
if (map) delete map[paramName];
}));
persist();
coreBus.emit('mode.params_changed', { modeId });
},
clearAllOverrides(modeId: string): void {
setState(produce((s) => {
s.overrides[modeId] = {};
}));
persist();
coreBus.emit('mode.params_changed', { modeId });
},
getOverride(modeId: string, paramName: string): ParamOverride | undefined {
return state.overrides[modeId]?.[paramName];
},
reset(): void {
setState({ activeModeId: null, overrides: {} });
persist();
},
};
export type ModeStore = typeof modeStore;

View file

@ -0,0 +1,103 @@
/**
* Output store output pipeline configuration.
*
* State held: globalCurve, smoothing, slewRate, freezeOutput, freezeMask.
*/
import { createStore, produce } from 'solid-js/store';
import {
defaultOutputConfig,
GLOBAL_CURVE_MIN,
GLOBAL_CURVE_MAX,
SMOOTHING_MAX,
SLEW_RATE_MIN,
type OutputConfig,
} from '../output/pipeline';
import { schedulePersist, loadPersisted } from './persistence';
import { clamp } from '../output/curves';
const STORAGE_KEY = 'nisps:output';
interface PersistedOutput {
globalCurve: number;
smoothing: number;
slewRate: number; // Infinity persists as null
freezeOutput: boolean;
reuseBuffer: boolean;
}
function loadOutputConfig(): OutputConfig {
const base = defaultOutputConfig();
const persisted = loadPersisted<Partial<PersistedOutput>>(STORAGE_KEY, {});
const slew = persisted.slewRate;
return {
...base,
globalCurve: persisted.globalCurve ?? base.globalCurve,
smoothing: persisted.smoothing ?? base.smoothing,
slewRate: slew === null || slew === undefined ? base.slewRate : slew,
freezeOutput: persisted.freezeOutput ?? base.freezeOutput,
reuseBuffer: persisted.reuseBuffer ?? base.reuseBuffer,
};
}
const [config, setConfig] = createStore<OutputConfig>(loadOutputConfig());
function persist(): void {
schedulePersist(STORAGE_KEY, () => ({
globalCurve: config.globalCurve,
smoothing: config.smoothing,
slewRate: isFinite(config.slewRate) ? config.slewRate : null,
freezeOutput: config.freezeOutput,
reuseBuffer: config.reuseBuffer,
}));
}
export const outputStore = {
config,
setGlobalCurve(exp: number): void {
setConfig(produce((c) => {
c.globalCurve = clamp(exp, GLOBAL_CURVE_MIN, GLOBAL_CURVE_MAX);
}));
persist();
},
setSmoothing(s: number): void {
setConfig(produce((c) => {
c.smoothing = clamp(s, 0, SMOOTHING_MAX);
}));
persist();
},
setSlewRate(maxChangePerSec: number): void {
setConfig(produce((c) => {
if (!isFinite(maxChangePerSec) || maxChangePerSec >= 1.0) {
c.slewRate = Infinity;
} else {
c.slewRate = Math.max(SLEW_RATE_MIN, maxChangePerSec);
}
}));
persist();
},
setFreezeOutput(frozen: boolean): void {
setConfig(produce((c) => {
c.freezeOutput = frozen;
}));
persist();
},
setFreezeMask(mask: Uint8Array | null): void {
setConfig(produce((c) => {
c.freezeMask = mask;
}));
// Mask is not persisted (engine-specific)
},
reset(): void {
setConfig(defaultOutputConfig());
persist();
},
};
export type OutputStore = typeof outputStore;

View file

@ -0,0 +1,62 @@
/**
* localStorage helpers with debounced writes.
*
* SolidJS stores want fine-grained reactivity, but localStorage I/O is slow
* and we don't want to write on every keystroke. Each store using
* persistence schedules a write via `schedulePersist(key, getValue)` and the
* helper coalesces calls within a 200 ms window.
*/
const FLUSH_INTERVAL_MS = 200;
const pending = new Map<string, () => unknown>();
let timer: ReturnType<typeof setTimeout> | null = null;
function flush(): void {
timer = null;
for (const [key, getValue] of pending) {
try {
const value = getValue();
if (value === undefined) {
localStorage.removeItem(key);
} else {
localStorage.setItem(key, JSON.stringify(value));
}
} catch (err) {
// eslint-disable-next-line no-console
console.warn(`[persistence] failed to write "${key}":`, err);
}
}
pending.clear();
}
export function schedulePersist(key: string, getValue: () => unknown): void {
if (typeof localStorage === 'undefined') return;
pending.set(key, getValue);
if (timer === null) {
timer = setTimeout(flush, FLUSH_INTERVAL_MS);
}
}
export function flushPersist(): void {
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
flush();
}
export function loadPersisted<T>(key: string, fallback: T): T {
if (typeof localStorage === 'undefined') return fallback;
try {
const raw = localStorage.getItem(key);
if (raw === null) return fallback;
return JSON.parse(raw) as T;
} catch {
return fallback;
}
}
export function clearPersisted(key: string): void {
if (typeof localStorage === 'undefined') return;
localStorage.removeItem(key);
}

View file

@ -0,0 +1,311 @@
/**
* Session store snapshot stack, A/B compare, region pins, named session presets.
*
* Stream 8 (this stream) provides the API and a working in-memory
* implementation with stub data shapes. Stream 10 wires the snapshots to
* real ML weights and surfaces the data through UI.
*/
import { createStore, produce } from 'solid-js/store';
import { coreBus } from './bus';
import { schedulePersist, loadPersisted } from './persistence';
const STORAGE_KEY = 'nisps:session';
const MAX_SNAPSHOTS = 20;
/** A single weights snapshot (placeholder until ML stream is live). */
export interface Snapshot {
id: string;
tag: string;
timestamp: number;
noiseLevel: number;
zoomLevel: number | null;
/** Stream 7+ stores actual weights here (Float32Array via ArrayBuffer in JSON). */
weightsRef: string | null;
}
export interface RegionPin {
id: string;
/** Region in input space [0,1]^2 */
x: number;
y: number;
width: number;
height: number;
/** Color slot 0..4 to map onto --pin-N tokens. */
colorSlot: number;
createdAt: number;
}
export interface ParamPin {
/** Mode-id-prefixed key, e.g. "paf_synth:osc1_freq". */
key: string;
/** Index within active mode's output vector. */
outputIndex: number;
}
export interface ABState {
/** Stored "A" snapshot (state captured by user). */
a: Snapshot | null;
/** Live "B" — derived from current ML state when toggle pressed. */
b: Snapshot | null;
/** Which side is currently live: A or B. */
live: 'A' | 'B';
}
export interface SessionPreset {
id: string;
name: string;
createdAt: number;
/** Opaque blob — stream 10 defines the schema. */
payload: unknown;
}
export interface SessionState {
snapshots: Snapshot[];
ab: ABState;
regionPins: RegionPin[];
paramPins: ParamPin[];
presets: SessionPreset[];
}
interface PersistedSession {
regionPins: RegionPin[];
paramPins: ParamPin[];
presets: SessionPreset[];
}
function loadInitial(): SessionState {
const persisted = loadPersisted<Partial<PersistedSession>>(STORAGE_KEY, {});
return {
snapshots: [], // not persisted (in-memory only for this stream)
ab: { a: null, b: null, live: 'B' },
regionPins: persisted.regionPins ?? [],
paramPins: persisted.paramPins ?? [],
presets: persisted.presets ?? [],
};
}
const [state, setState] = createStore<SessionState>(loadInitial());
function persist(): void {
schedulePersist(STORAGE_KEY, () => ({
regionPins: state.regionPins,
paramPins: state.paramPins,
presets: state.presets,
}));
}
let snapshotCounter = 0;
function nextSnapshotId(): string {
snapshotCounter += 1;
return `snap-${Date.now().toString(36)}-${snapshotCounter}`;
}
export const sessionStore = {
state,
// ----- Snapshots -----
pushSnapshot(tag: string, opts: { noiseLevel?: number; zoomLevel?: number | null; weightsRef?: string | null } = {}): Snapshot {
const snap: Snapshot = {
id: nextSnapshotId(),
tag,
timestamp: Date.now(),
noiseLevel: opts.noiseLevel ?? 0,
zoomLevel: opts.zoomLevel ?? null,
weightsRef: opts.weightsRef ?? null,
};
setState(produce((s) => {
s.snapshots.push(snap);
if (s.snapshots.length > MAX_SNAPSHOTS) {
s.snapshots.shift();
}
}));
coreBus.emit('snap.push', { tag });
return snap;
},
popSnapshot(): Snapshot | null {
let popped: Snapshot | null = null;
setState(produce((s) => {
const last = s.snapshots.pop();
popped = last ?? null;
}));
const tag = (popped as Snapshot | null)?.tag ?? null;
coreBus.emit('snap.pop', { tag });
return popped;
},
jumpToSnapshot(id: string): Snapshot | null {
const snap = state.snapshots.find((s) => s.id === id) ?? null;
if (!snap) return null;
setState(produce((s) => {
const idx = s.snapshots.findIndex((q) => q.id === id);
if (idx >= 0) {
s.snapshots = s.snapshots.slice(0, idx + 1);
}
}));
coreBus.emit('snap.pop', { tag: snap.tag });
return snap;
},
clearSnapshots(): void {
setState(produce((s) => {
s.snapshots = [];
}));
coreBus.emit('snap.cleared', undefined);
},
// ----- A/B compare -----
captureA(snap: Snapshot): void {
setState(produce((s) => {
s.ab.a = snap;
s.ab.live = 'B'; // user immediately keeps exploring as B
}));
},
captureB(snap: Snapshot): void {
setState(produce((s) => {
s.ab.b = snap;
}));
},
toggleAB(): 'A' | 'B' {
let next: 'A' | 'B' = 'B';
setState(produce((s) => {
next = s.ab.live === 'A' ? 'B' : 'A';
s.ab.live = next;
}));
return next;
},
acceptB(): void {
setState(produce((s) => {
s.ab.a = null;
s.ab.live = 'B';
}));
},
revertToA(): void {
setState(produce((s) => {
// A becomes live; B is discarded
s.ab.b = null;
s.ab.live = 'A';
}));
},
resetAB(): void {
setState(produce((s) => {
s.ab = { a: null, b: null, live: 'B' };
}));
},
// ----- Region pins -----
addRegionPin(pin: Omit<RegionPin, 'id' | 'createdAt' | 'colorSlot'> & { colorSlot?: number }): RegionPin {
const id = `pin-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6)}`;
const colorSlot = pin.colorSlot ?? state.regionPins.length % 5;
const created: RegionPin = { ...pin, colorSlot, id, createdAt: Date.now() };
setState(produce((s) => {
// Cap at 5 pins
if (s.regionPins.length >= 5) {
s.regionPins.shift();
}
s.regionPins.push(created);
}));
persist();
coreBus.emit('pin.create', { kind: 'region', id });
return created;
},
removeRegionPin(id: string): void {
setState(produce((s) => {
const idx = s.regionPins.findIndex((p) => p.id === id);
if (idx >= 0) s.regionPins.splice(idx, 1);
}));
persist();
coreBus.emit('pin.remove', { kind: 'region', id });
},
clearRegionPins(): void {
setState(produce((s) => {
s.regionPins = [];
}));
persist();
},
// ----- Param pins -----
toggleParamPin(key: string, outputIndex: number): boolean {
let pinned = false;
setState(produce((s) => {
const idx = s.paramPins.findIndex((p) => p.key === key);
if (idx >= 0) {
s.paramPins.splice(idx, 1);
pinned = false;
} else {
s.paramPins.push({ key, outputIndex });
pinned = true;
}
}));
persist();
if (pinned) coreBus.emit('pin.create', { kind: 'param', id: key });
else coreBus.emit('pin.remove', { kind: 'param', id: key });
return pinned;
},
isParamPinned(key: string): boolean {
return state.paramPins.some((p) => p.key === key);
},
clearParamPins(): void {
setState(produce((s) => {
s.paramPins = [];
}));
persist();
},
/** Build a Uint8Array pin mask for a given output size. */
paramPinMask(outputSize: number, modeId: string | null): Uint8Array {
const mask = new Uint8Array(outputSize);
for (const pin of state.paramPins) {
if (modeId && !pin.key.startsWith(modeId + ':')) continue;
if (pin.outputIndex >= 0 && pin.outputIndex < outputSize) {
mask[pin.outputIndex] = 1;
}
}
return mask;
},
// ----- Session presets -----
savePreset(name: string, payload: unknown): SessionPreset {
const preset: SessionPreset = {
id: `preset-${Date.now().toString(36)}`,
name,
createdAt: Date.now(),
payload,
};
setState(produce((s) => {
s.presets.push(preset);
}));
persist();
return preset;
},
removePreset(id: string): void {
setState(produce((s) => {
const idx = s.presets.findIndex((p) => p.id === id);
if (idx >= 0) s.presets.splice(idx, 1);
}));
persist();
},
reset(): void {
setState(loadInitial());
snapshotCounter = 0;
},
};
export type SessionStore = typeof sessionStore;