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:
parent
665e224122
commit
add05ea554
9 changed files with 1327 additions and 0 deletions
129
playground/src/stores/bus.ts
Normal file
129
playground/src/stores/bus.ts
Normal 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>();
|
||||||
257
playground/src/stores/control-store.ts
Normal file
257
playground/src/stores/control-store.ts
Normal 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);
|
||||||
|
}
|
||||||
33
playground/src/stores/index.ts
Normal file
33
playground/src/stores/index.ts
Normal 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';
|
||||||
186
playground/src/stores/input-store.ts
Normal file
186
playground/src/stores/input-store.ts
Normal 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;
|
||||||
116
playground/src/stores/ml-store.ts
Normal file
116
playground/src/stores/ml-store.ts
Normal 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;
|
||||||
130
playground/src/stores/mode-store.ts
Normal file
130
playground/src/stores/mode-store.ts
Normal 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;
|
||||||
103
playground/src/stores/output-store.ts
Normal file
103
playground/src/stores/output-store.ts
Normal 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;
|
||||||
62
playground/src/stores/persistence.ts
Normal file
62
playground/src/stores/persistence.ts
Normal 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);
|
||||||
|
}
|
||||||
311
playground/src/stores/session-store.ts
Normal file
311
playground/src/stores/session-store.ts
Normal 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;
|
||||||
Loading…
Reference in a new issue