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