From add05ea55479350fe702cc9d72ec752a1d2acc66 Mon Sep 17 00:00:00 2001 From: w1n5t0n Date: Wed, 29 Apr 2026 15:38:05 +0300 Subject: [PATCH] feat(playground): solid stores + signal bus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- playground/src/stores/bus.ts | 129 ++++++++++ playground/src/stores/control-store.ts | 257 ++++++++++++++++++++ playground/src/stores/index.ts | 33 +++ playground/src/stores/input-store.ts | 186 +++++++++++++++ playground/src/stores/ml-store.ts | 116 +++++++++ playground/src/stores/mode-store.ts | 130 +++++++++++ playground/src/stores/output-store.ts | 103 ++++++++ playground/src/stores/persistence.ts | 62 +++++ playground/src/stores/session-store.ts | 311 +++++++++++++++++++++++++ 9 files changed, 1327 insertions(+) create mode 100644 playground/src/stores/bus.ts create mode 100644 playground/src/stores/control-store.ts create mode 100644 playground/src/stores/index.ts create mode 100644 playground/src/stores/input-store.ts create mode 100644 playground/src/stores/ml-store.ts create mode 100644 playground/src/stores/mode-store.ts create mode 100644 playground/src/stores/output-store.ts create mode 100644 playground/src/stores/persistence.ts create mode 100644 playground/src/stores/session-store.ts diff --git a/playground/src/stores/bus.ts b/playground/src/stores/bus.ts new file mode 100644 index 0000000..84007a5 --- /dev/null +++ b/playground/src/stores/bus.ts @@ -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(); + * bus.on('ml.trained', ({ loss }) => console.log(loss)); + * bus.emit('ml.trained', { loss: 0.42 }); + */ + +export type EventMap = Record; + +type Handler = (data: T, eventName: string) => void; + +export interface Bus { + emit(topic: K, data: E[K]): void; + on(topic: K, handler: Handler): () => void; + /** Subscribe to a prefix; receives any matching topic and its data. */ + onPrefix(prefix: string, handler: Handler): () => void; + /** Remove all handlers (test cleanup). */ + clear(): void; +} + +export function createBus(): Bus { + const handlers = new Map>>(); + const prefixHandlers: Array<{ prefix: string; handler: Handler }> = []; + + function emit(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(topic: K, handler: Handler): () => void { + let set = handlers.get(topic); + if (!set) { + set = new Set(); + handlers.set(topic, set); + } + set.add(handler as Handler); + return () => { + set!.delete(handler as Handler); + }; + } + + function onPrefix(prefix: string, handler: Handler): () => 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 = createBus(); diff --git a/playground/src/stores/control-store.ts b/playground/src/stores/control-store.ts new file mode 100644 index 0000000..bbf7607 --- /dev/null +++ b/playground/src/stores/control-store.ts @@ -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; +} + +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 = [ + { 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 { + 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 = {}; + 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; memory: Record; precision: Record }; + /** 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>(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(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 { + const b = interpolate(BOLDNESS_TABLE, state.boldness); + const m = interpolate(MEMORY_TABLE, state.memory); + const p = interpolate(PRECISION_TABLE, state.precision); + const all: Record = { ...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 { + return interpolate(TABLES[axis], value); +} diff --git a/playground/src/stores/index.ts b/playground/src/stores/index.ts new file mode 100644 index 0000000..5bbbfc8 --- /dev/null +++ b/playground/src/stores/index.ts @@ -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'; diff --git a/playground/src/stores/input-store.ts b/playground/src/stores/input-store.ts new file mode 100644 index 0000000..54adf95 --- /dev/null +++ b/playground/src/stores/input-store.ts @@ -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>(STORAGE_KEY, {}); + return { ...base, ...persisted }; +} + +const [config, setConfig] = createStore(loadInputConfig()); + +// Live state — NOT persisted; reset on reload. +const [liveState, setLiveState] = createStore(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; diff --git a/playground/src/stores/ml-store.ts b/playground/src/stores/ml-store.ts new file mode 100644 index 0000000..fc54f85 --- /dev/null +++ b/playground/src/stores/ml-store.ts @@ -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({ + exampleCount: 0, + lastLoss: null, + lossHistory: [], + inputSize: 2, + outputSize: 126, + training: false, + ready: false, +}); + +const [outputs, setOutputs] = createSignal(EMPTY_OUTPUTS, { + equals: false, // always notify even if reference reused +}); + +const [weights, setWeights] = createSignal(EMPTY_OUTPUTS, { + equals: false, +}); + +export const mlStore = { + // ---- read ---- + state, + outputs: outputs as Accessor, + weights: weights as Accessor, + + // ---- 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 { + return NOT_IMPLEMENTED('initialize'); + }, + setInput(_idx: number, _value: number): void { + NOT_IMPLEMENTED('setInput'); + }, + process(): void { + NOT_IMPLEMENTED('process'); + }, + addExample(_features: ReadonlyArray, _labels: ReadonlyArray): void { + NOT_IMPLEMENTED('addExample'); + }, + train(_lr?: number, _maxIter?: number): number { + return NOT_IMPLEMENTED('train'); + }, + trainAsync(_lr?: number, _maxIter?: number): Promise { + 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): 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; diff --git a/playground/src/stores/mode-store.ts b/playground/src/stores/mode-store.ts new file mode 100644 index 0000000..ac0cbdc --- /dev/null +++ b/playground/src/stores/mode-store.ts @@ -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>; +} + +interface PersistedMode { + activeModeId: string | null; + overrides: Record>; +} + +function loadInitial(): ModeStoreState { + const persisted = loadPersisted>(STORAGE_KEY, {}); + return { + activeModeId: persisted.activeModeId ?? null, + overrides: persisted.overrides ?? {}, + }; +} + +const [state, setState] = createStore(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; diff --git a/playground/src/stores/output-store.ts b/playground/src/stores/output-store.ts new file mode 100644 index 0000000..a5137cc --- /dev/null +++ b/playground/src/stores/output-store.ts @@ -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>(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(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; diff --git a/playground/src/stores/persistence.ts b/playground/src/stores/persistence.ts new file mode 100644 index 0000000..310054d --- /dev/null +++ b/playground/src/stores/persistence.ts @@ -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 unknown>(); +let timer: ReturnType | 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(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); +} diff --git a/playground/src/stores/session-store.ts b/playground/src/stores/session-store.ts new file mode 100644 index 0000000..90d8c01 --- /dev/null +++ b/playground/src/stores/session-store.ts @@ -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>(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(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 & { 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;