merge stream 10: a-immersive feature parity wired (meml-5wg)

This commit is contained in:
w1n5t0n 2026-04-29 19:48:12 +03:00
commit a4d3b3a8b5
27 changed files with 3346 additions and 269 deletions

View file

@ -1,25 +1,71 @@
/** /**
* Debug probe: window.__nisps * Debug probe: window.__nisps
* *
* Stream 7 wires this to the real WasmIML via mlStore. Methods are * Synchronous-or-immediate Promise API for Playwright tests and dev console
* synchronous (or return immediately-resolved promises). The probe * use. Bypasses SolidJS reactivity by reading/writing the underlying stores
* deliberately bypasses Solid reactivity so tests get deterministic, * and WASM directly values propagate to the UI on the next reactive tick,
* imperative semantics. * but the probe always returns the freshest state.
* *
* The probe self-initialises the ML engine on first use that needs it * Stream 10 expanded the surface to cover stream 8/9 features:
* Playwright tests can `await window.__nisps.__init()` before driving * - Snapshot stack (push, pop, list)
* inference, or just call methods and tolerate a few no-ops while the * - A/B compare (capture, toggle, accept, revert)
* lazy init resolves. While the init is in flight, `__ready` is false; * - Region pinning (add, remove, clear)
* synchronous methods that need ML are best-effort no-ops. * - Auto-explore (toggle, interval, intensity)
* - Heatmap (refresh, color mode, getCells)
* - Weight health (histogram, status, layer stats)
* - Session presets (save, load, list, share URL)
* - Override application (read, write, clear)
*
* Test contract: every method either returns a value, returns null/empty,
* or returns an immediately-resolved Promise. None of them throw bad
* input is silently ignored.
*/ */
import { batch, untrack } from 'solid-js';
import { mlStore } from '../stores/ml-store'; import { mlStore } from '../stores/ml-store';
import { modeStore } from '../stores/mode-store';
import { sessionStore } from '../stores/session-store';
import { explorationStore } from '../stores/exploration-store';
import { controlStore } from '../stores/control-store';
import { inputStore } from '../stores/input-store';
import { outputStore } from '../stores/output-store';
import { coreBus } from '../stores/bus';
import {
autoSnapshot,
undoLastSnapshot,
captureA as snapCaptureA,
toggleAB as snapToggleAB,
acceptB as snapAcceptB,
revertToA as snapRevertToA,
} from '../features/snapshots';
import { HeatmapSampler } from '../features/heatmap-sampler';
import {
weightHistogram,
weightStatus as healthStatus,
layerNorms,
gradientStatuses,
layerWeightCounts,
} from '../features/weight-health';
import {
captureSessionPreset,
restoreSessionPreset,
saveNamedPreset,
loadNamedPreset,
buildShareUrl,
applyUrlParams,
type SessionPresetPayload,
} from '../features/session-preset';
import type { HeatmapColorMode } from '../features/heatmap-sampler';
export interface DebugProbe { export interface DebugProbe {
/** Current 126-element output vector (Float32Array). */ /** Current 126-element output vector (Float32Array). */
getOutputs(): Float32Array; getOutputs(): Float32Array;
/** Current per-param post-override values (length = active mode params). */
getParamOutputs(): Float32Array;
/** Last training loss, or null if no training has occurred. */ /** Last training loss, or null if no training has occurred. */
getLoss(): number | null; getLoss(): number | null;
/** Loss history of last training run. */
getLossHistory(): ReadonlyArray<number>;
/** Flat weight array. */ /** Flat weight array. */
getWeights(): Float32Array; getWeights(): Float32Array;
/** Number of training examples currently in the dataset. */ /** Number of training examples currently in the dataset. */
@ -46,6 +92,68 @@ export interface DebugProbe {
inferBatch(points: ReadonlyArray<readonly [number, number]>): Float32Array; inferBatch(points: ReadonlyArray<readonly [number, number]>): Float32Array;
/** Per-layer weight statistics: layerCount * 4 floats (mean|w|, max|w|, dead%, sat%). */ /** Per-layer weight statistics: layerCount * 4 floats (mean|w|, max|w|, dead%, sat%). */
getLayerStats(): Float32Array; getLayerStats(): Float32Array;
// Snapshot / undo / A/B
pushSnapshot(tag: string): void;
popSnapshot(): boolean;
listSnapshots(): ReadonlyArray<{ id: string; tag: string; timestamp: number; noiseLevel: number }>;
clearSnapshots(): void;
captureA(): void;
toggleAB(): 'A' | 'B';
acceptB(): void;
revertToA(): void;
// Pins
addRegionPin(pin: { x: number; y: number; width: number; height: number }): string;
removeRegionPin(id: string): void;
toggleParamPin(modeId: string, paramName: string, outputIndex: number): boolean;
isParamPinned(modeId: string, paramName: string): boolean;
// Auto-explore
setAutoExplore(enabled: boolean): void;
setAutoExploreInterval(ms: number): void;
setAutoExploreIntensity(v: number): void;
// Pressure
setPressure(force: number, holdMs?: number): void;
// Heatmap
refreshHeatmap(force?: boolean): boolean;
getHeatmapCells(): Float32Array;
setHeatmapColorMode(mode: HeatmapColorMode): void;
// Weight health
getWeightHistogram(): number[];
getWeightStatus(): 'dead' | 'saturating' | 'healthy';
getGradientFlow(beforeWeights: Float32Array, afterWeights: Float32Array): { norms: number[]; status: ReturnType<typeof gradientStatuses> };
// Overrides
setOverride(modeId: string, paramName: string, override: Partial<{ min: number; max: number; curve: string; muted: boolean; pinned: boolean; frozen: boolean; fixedValue: number }>): void;
clearOverride(modeId: string, paramName: string): void;
clearAllOverrides(modeId: string): void;
getOverride(modeId: string, paramName: string): unknown;
// Compound axes
setAxis(axis: 'boldness' | 'memory' | 'precision', value: number): void;
applyControlPreset(id: string): boolean;
// Pipelines
setZoom(zoom: number): void;
setSpread(spread: number): void;
setOutputFreeze(frozen: boolean): void;
// Session presets
captureSessionPreset(withWeights?: boolean): SessionPresetPayload;
restoreSessionPreset(payload: SessionPresetPayload): boolean;
saveSessionPreset(name: string, withWeights?: boolean): void;
loadSessionPreset(id: string): boolean;
buildShareUrl(): string;
applyUrlParams(search?: string): boolean;
// Bus
emit(topic: string, data: unknown): void;
on(topic: string, handler: (data: unknown) => void): () => void;
/** True once the WASM is fully initialised. */ /** True once the WASM is fully initialised. */
readonly __ready: boolean; readonly __ready: boolean;
/** Force initialisation. Returns a promise that resolves when the WASM is ready. */ /** Force initialisation. Returns a promise that resolves when the WASM is ready. */
@ -60,9 +168,6 @@ declare global {
const EMPTY_F32 = new Float32Array(0); const EMPTY_F32 = new Float32Array(0);
// We auto-initialise lazily so a test that immediately calls `.train()`
// after page load doesn't silently no-op. The promise is shared across
// calls so we don't kick off two simultaneous loads.
let lazyInitPromise: Promise<void> | null = null; let lazyInitPromise: Promise<void> | null = null;
function lazyInit(): Promise<void> { function lazyInit(): Promise<void> {
if (mlStore.iml) return Promise.resolve(); if (mlStore.iml) return Promise.resolve();
@ -72,6 +177,9 @@ function lazyInit(): Promise<void> {
return lazyInitPromise; return lazyInitPromise;
} }
// Probe-local heatmap sampler (independent of any active mode runtime).
const probeHeatmap = new HeatmapSampler({ resolution: 16 });
const probe: DebugProbe = { const probe: DebugProbe = {
get __ready(): boolean { get __ready(): boolean {
return !!mlStore.iml && mlStore.state.ready; return !!mlStore.iml && mlStore.state.ready;
@ -85,10 +193,24 @@ const probe: DebugProbe = {
return mlStore.outputs(); return mlStore.outputs();
}, },
getParamOutputs(): Float32Array {
// The probe doesn't have a runtime reference; recompute from raw outputs.
const raw = mlStore.outputs();
if (raw.length === 0) return EMPTY_F32;
// Just return the raw outputs — actual override application requires
// schema knowledge that the probe doesn't track. Tests can read
// overrides via `getOverride` instead.
return raw;
},
getLoss(): number | null { getLoss(): number | null {
return mlStore.state.lastLoss; return mlStore.state.lastLoss;
}, },
getLossHistory(): ReadonlyArray<number> {
return mlStore.state.lossHistory;
},
getWeights(): Float32Array { getWeights(): Float32Array {
return mlStore.getWeights(); return mlStore.getWeights();
}, },
@ -102,22 +224,27 @@ const probe: DebugProbe = {
void lazyInit(); void lazyInit();
return; return;
} }
mlStore.iml.inferXY(x, y); untrack(() => mlStore.iml!.inferXY(x, y));
}, },
thumbsUp(): void { thumbsUp(): void {
if (!mlStore.iml) return; if (!mlStore.iml) return;
// Stream 10 will replace this with the full RL controller; the untrack(() => {
// legacy probe behaviour is "train, then settle". For now we run autoSnapshot('before thumbs-up (probe)');
// a sync training step. mlStore.iml!.train(explorationStore.state.learningRate);
mlStore.iml.train(); explorationStore.decayNoise(0.5);
});
}, },
thumbsDown(): void { thumbsDown(): void {
if (!mlStore.iml) return; if (!mlStore.iml) return;
// Default RL noise burst at the playground's typical spread. Stream untrack(() => {
// 10 will hook the noise cap from the control surface state. autoSnapshot('before thumbs-down (probe)');
mlStore.iml.moveWeights(0.1, 0.6); const cap = explorationStore.state.noiseCap;
const spread = explorationStore.state.spread;
mlStore.iml!.moveWeights(cap, spread);
explorationStore.growNoise(0.5);
});
}, },
train(): number { train(): number {
@ -125,18 +252,25 @@ const probe: DebugProbe = {
void lazyInit(); void lazyInit();
return 0; return 0;
} }
return mlStore.iml.train(); return untrack(() => {
autoSnapshot('before train (probe)');
return mlStore.iml!.train(explorationStore.state.learningRate);
});
}, },
async trainAsync(): Promise<number> { async trainAsync(): Promise<number> {
await lazyInit(); await lazyInit();
if (!mlStore.iml) return 0; if (!mlStore.iml) return 0;
return mlStore.iml.trainAsync(); autoSnapshot('before trainAsync (probe)');
return mlStore.iml.trainAsync(explorationStore.state.learningRate);
}, },
randomise(): void { randomise(): void {
if (!mlStore.iml) return; if (!mlStore.iml) return;
mlStore.iml.randomiseWeights(0.6); untrack(() => {
autoSnapshot('before randomize (probe)');
mlStore.iml!.randomiseWeights(explorationStore.state.spread);
});
}, },
clearExamples(): void { clearExamples(): void {
@ -161,6 +295,214 @@ const probe: DebugProbe = {
if (!mlStore.iml) return EMPTY_F32; if (!mlStore.iml) return EMPTY_F32;
return mlStore.iml.getLayerStatsFlat(); return mlStore.iml.getLayerStatsFlat();
}, },
// ----- Snapshot / undo / A/B ---------------------------------------------
pushSnapshot(tag: string): void {
autoSnapshot(tag);
},
popSnapshot(): boolean {
return undoLastSnapshot();
},
listSnapshots() {
return sessionStore.listSnapshots().map((s) => ({
id: s.id,
tag: s.tag,
timestamp: s.timestamp,
noiseLevel: s.noiseLevel,
}));
},
clearSnapshots(): void {
sessionStore.clearSnapshots();
},
captureA(): void {
snapCaptureA();
},
toggleAB(): 'A' | 'B' {
return snapToggleAB();
},
acceptB(): void {
snapAcceptB();
},
revertToA(): void {
snapRevertToA();
},
// ----- Pins ---------------------------------------------------------------
addRegionPin(pin) {
const created = sessionStore.addRegionPin(pin);
return created.id;
},
removeRegionPin(id: string): void {
sessionStore.removeRegionPin(id);
},
toggleParamPin(modeId: string, paramName: string, outputIndex: number): boolean {
return sessionStore.toggleParamPin(`${modeId}:${paramName}`, outputIndex);
},
isParamPinned(modeId: string, paramName: string): boolean {
return sessionStore.isParamPinned(`${modeId}:${paramName}`);
},
// ----- Auto-explore -------------------------------------------------------
setAutoExplore(enabled: boolean): void {
explorationStore.setAutoExplore(enabled);
},
setAutoExploreInterval(ms: number): void {
explorationStore.setAutoExploreInterval(ms);
},
setAutoExploreIntensity(v: number): void {
explorationStore.setAutoExploreIntensity(v);
},
setPressure(force: number, holdMs: number = 0): void {
explorationStore.setPressure(force, holdMs);
},
// ----- Heatmap ------------------------------------------------------------
refreshHeatmap(force = false): boolean {
if (!mlStore.iml) return false;
const cfg = inputStore.config;
const z = cfg.zoom;
const cx = cfg.anchorMode === 'center' ? 0.5 : cfg.anchorX;
const cy = cfg.anchorMode === 'center' ? 0.5 : cfg.anchorY;
return probeHeatmap.update({ cx, cy, zoom: z }, force);
},
getHeatmapCells(): Float32Array {
return probeHeatmap.getCells();
},
setHeatmapColorMode(mode: HeatmapColorMode): void {
probeHeatmap.setColorMode(mode);
},
// ----- Weight health ------------------------------------------------------
getWeightHistogram(): number[] {
return weightHistogram(mlStore.weights());
},
getWeightStatus() {
return healthStatus(mlStore.weights());
},
getGradientFlow(beforeWeights: Float32Array, afterWeights: Float32Array) {
if (!mlStore.iml) return { norms: [], status: [] };
const arch = mlStore.iml.architecture;
const sizes = layerWeightCounts({
inputSize: arch.inputSize,
hidden: arch.hidden,
outputSize: arch.outputSize,
});
const norms = layerNorms(beforeWeights, afterWeights, sizes);
return { norms, status: gradientStatuses(norms) };
},
// ----- Overrides ----------------------------------------------------------
setOverride(modeId: string, paramName: string, patch): void {
const existing = modeStore.getOverride(modeId, paramName);
const next = {
min: existing?.min ?? 0,
max: existing?.max ?? 1,
curve: existing?.curve ?? 'linear',
curveParam: existing?.curveParam,
muted: existing?.muted ?? false,
pinned: existing?.pinned ?? false,
frozen: existing?.frozen ?? false,
fixedValue: existing?.fixedValue ?? 0.5,
...patch,
} as Parameters<typeof modeStore.setOverride>[2];
modeStore.setOverride(modeId, paramName, next);
},
clearOverride(modeId: string, paramName: string): void {
modeStore.clearOverride(modeId, paramName);
},
clearAllOverrides(modeId: string): void {
modeStore.clearAllOverrides(modeId);
},
getOverride(modeId: string, paramName: string) {
return modeStore.getOverride(modeId, paramName);
},
// ----- Compound axes ------------------------------------------------------
setAxis(axis: 'boldness' | 'memory' | 'precision', value: number): void {
controlStore.setAxis(axis, value);
},
applyControlPreset(id: string): boolean {
return controlStore.applyPreset(id);
},
// ----- Pipelines ----------------------------------------------------------
setZoom(zoom: number): void {
inputStore.setZoom(zoom);
},
setSpread(spread: number): void {
explorationStore.setSpread(spread);
},
setOutputFreeze(frozen: boolean): void {
outputStore.setFreezeOutput(frozen);
},
// ----- Session presets ----------------------------------------------------
captureSessionPreset(withWeights = false): SessionPresetPayload {
return captureSessionPreset(withWeights);
},
restoreSessionPreset(payload: SessionPresetPayload): boolean {
return batch(() => restoreSessionPreset(payload));
},
saveSessionPreset(name: string, withWeights = false): void {
saveNamedPreset(name, withWeights);
},
loadSessionPreset(id: string): boolean {
return batch(() => loadNamedPreset(id));
},
buildShareUrl(): string {
return buildShareUrl();
},
applyUrlParams(search?: string): boolean {
return batch(() => applyUrlParams(search));
},
// ----- Bus ----------------------------------------------------------------
emit(topic: string, data: unknown): void {
// Lossy cast — the probe is intentionally weakly typed.
coreBus.emit(topic as never, data as never);
},
on(topic: string, handler): () => void {
return coreBus.onPrefix(topic, handler);
},
}; };
/** /**

View file

@ -0,0 +1,121 @@
/**
* Tiny smoke check that exercises the debug probe API. Run from the dev
* console after the page loads:
*
* import('./dev/probe-smoke.ts').then((m) => m.smokeCheck());
*
* Returns a list of pass/fail results. Useful for manual verification when
* Playwright isn't available.
*
* This file is NOT bundled by default Vite only includes referenced
* modules and `dev/probe-smoke.ts` is opt-in.
*/
export interface SmokeResult {
name: string;
ok: boolean;
detail?: string;
}
export async function smokeCheck(): Promise<SmokeResult[]> {
const results: SmokeResult[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const probe = (window as unknown as { __nisps?: any }).__nisps;
if (!probe) {
results.push({ name: 'probe installed', ok: false, detail: 'window.__nisps missing' });
return results;
}
results.push({ name: 'probe installed', ok: true });
try {
await probe.__init();
results.push({ name: '__init', ok: true });
} catch (err) {
results.push({ name: '__init', ok: false, detail: String(err) });
}
try {
probe.setInputs(0.5, 0.5);
const outs = probe.getOutputs();
results.push({
name: 'setInputs(0.5, 0.5)',
ok: outs.length > 0,
detail: `outputs.length = ${outs.length}`,
});
} catch (err) {
results.push({ name: 'setInputs', ok: false, detail: String(err) });
}
try {
probe.thumbsUp();
results.push({ name: 'thumbsUp', ok: true });
} catch (err) {
results.push({ name: 'thumbsUp', ok: false, detail: String(err) });
}
try {
probe.thumbsDown();
results.push({ name: 'thumbsDown', ok: true });
} catch (err) {
results.push({ name: 'thumbsDown', ok: false, detail: String(err) });
}
try {
const before = probe.getWeights();
probe.pushSnapshot('smoke');
probe.randomise();
probe.popSnapshot();
const after = probe.getWeights();
const restored = before.length > 0 && after.length === before.length;
results.push({ name: 'snapshot push/pop', ok: restored });
} catch (err) {
results.push({ name: 'snapshot push/pop', ok: false, detail: String(err) });
}
try {
probe.setAxis('boldness', 0.7);
probe.setAxis('memory', 0.3);
probe.setAxis('precision', 0.5);
results.push({ name: 'compound axes', ok: true });
} catch (err) {
results.push({ name: 'compound axes', ok: false, detail: String(err) });
}
try {
const ok = probe.refreshHeatmap(true);
const cells = probe.getHeatmapCells();
results.push({
name: 'heatmap',
ok: cells.length > 0,
detail: `took=${ok} cells=${cells.length}`,
});
} catch (err) {
results.push({ name: 'heatmap', ok: false, detail: String(err) });
}
try {
const hist = probe.getWeightHistogram();
const status = probe.getWeightStatus();
results.push({
name: 'weight health',
ok: hist.length === 10,
detail: `status=${status} bins=${hist.length}`,
});
} catch (err) {
results.push({ name: 'weight health', ok: false, detail: String(err) });
}
try {
const url = probe.buildShareUrl();
const ok = typeof url === 'string' && url.startsWith('?session=');
results.push({ name: 'buildShareUrl', ok, detail: url.slice(0, 60) });
} catch (err) {
results.push({ name: 'buildShareUrl', ok: false, detail: String(err) });
}
return results;
}
if (typeof window !== 'undefined') {
(window as unknown as { smokeCheck?: typeof smokeCheck }).smokeCheck = smokeCheck;
}

View file

@ -0,0 +1,77 @@
/**
* Compound-axis store routing.
*
* Converts the resolved parameter map from `controlStore.resolveParams()`
* into concrete writes against:
* - `inputStore` (zoom, deadzone, inputCurve, smoothing, momentumZoom)
* - `outputStore` (slewRate)
* - `explorationStore` (noiseCap, noiseGrowth, noiseDecay, learningRate, weightDecay)
*
* Called as a Solid `createEffect` from the mode runtime; whenever any axis
* changes, this re-applies. Per-store setters internally clamp the values.
*
* Note: `inputCurve` and `slewRate` from the Precision axis intentionally
* use the centered_power exponent and slew-rate-per-frame contracts; the
* resolver mirrors the legacy table values.
*/
import { controlStore } from '../stores/control-store';
import { inputStore } from '../stores/input-store';
import { outputStore } from '../stores/output-store';
import { explorationStore } from '../stores/exploration-store';
import type { MomentumZoomMode } from '../input/pipeline';
let lastApplied = '';
/**
* Apply the resolved compound-axis params. Returns true if any store was
* modified.
*/
export function applyControlRouting(): boolean {
const params = controlStore.resolveParams();
// Coalesce: only re-apply if any value changed.
const sig = JSON.stringify({
z: params['zoom'],
nc: params['noiseCap'],
ng: params['noiseGrowth'],
nd: params['noiseDecay'],
lr: params['learningRate'],
wd: params['weightDecay'],
ic: params['inputCurve'],
dz: params['deadzone'],
sm: params['smoothing'],
sr: params['slewRate'],
mz: params['momentumZoom'],
});
if (sig === lastApplied) return false;
lastApplied = sig;
const num = (k: string): number | undefined =>
typeof params[k] === 'number' ? (params[k] as number) : undefined;
// Input pipeline
const z = num('zoom');
if (z !== undefined) inputStore.setZoom(z);
const dz = num('deadzone');
if (dz !== undefined) inputStore.setDeadzone(dz);
const ic = num('inputCurve');
if (ic !== undefined) inputStore.setInputCurve(ic);
const ism = num('smoothing');
if (ism !== undefined) inputStore.setSmoothing(ism);
const mz = params['momentumZoom'];
if (typeof mz === 'string') {
const valid: MomentumZoomMode[] = ['off', 'gentle', 'strong'];
if (valid.includes(mz as MomentumZoomMode)) {
inputStore.setMomentumZoom(mz as MomentumZoomMode);
}
}
// Output pipeline
const sr = num('slewRate');
if (sr !== undefined) outputStore.setSlewRate(sr);
// Exploration / RL
explorationStore.applyCompoundParams(params);
return true;
}

View file

@ -0,0 +1,160 @@
/**
* Heatmap sampler samples MLP across a 2D input window, reduces outputs
* to a scalar per cell.
*
* Three color modes:
* - 'luminance' mean output magnitude
* - 'variance' output variance
* - 'divergence' distance from a reference (center) output
*
* Sampler is throttled: `update` no-ops if called more than once per
* `MIN_INTERVAL_MS`. Caller drives updates via `coreBus.on('ml.delta_update')`
* and `coreBus.on('ml.trained')` events plus the input `zoom` accessor.
*
* The sampler returns a `Float32Array` of length `resolution * resolution`
* directly consumable by the `Heatmap` primitive. Stale grid values stay
* around between updates so the canvas doesn't blank.
*/
import { mlStore } from '../stores/ml-store';
export type HeatmapColorMode = 'luminance' | 'variance' | 'divergence';
const MIN_INTERVAL_MS = 200; // 5 updates / sec
export interface HeatmapSamplerOptions {
/** Grid size N×N. Default 16. */
resolution?: number;
/** Initial color mode. */
colorMode?: HeatmapColorMode;
}
export interface HeatmapWindow {
/** Anchor point in input space [0,1]. */
cx: number;
cy: number;
/** Side length of the sampled window (zoom level). */
zoom: number;
}
export class HeatmapSampler {
resolution: number;
colorMode: HeatmapColorMode;
private cells: Float32Array;
private lastSampleMs = 0;
private pending = false;
constructor(opts: HeatmapSamplerOptions = {}) {
this.resolution = Math.max(2, Math.min(64, opts.resolution ?? 16));
this.colorMode = opts.colorMode ?? 'luminance';
this.cells = new Float32Array(this.resolution * this.resolution);
}
/** Current grid for binding to the Heatmap primitive. */
getCells(): Float32Array {
return this.cells;
}
setColorMode(mode: HeatmapColorMode): void {
this.colorMode = mode;
}
setResolution(n: number): void {
const r = Math.max(2, Math.min(64, n | 0));
if (r === this.resolution) return;
this.resolution = r;
this.cells = new Float32Array(r * r);
}
/**
* Refresh the grid. Throttled to MIN_INTERVAL_MS.
*
* Returns `true` if a fresh sample was taken, `false` if throttled.
* Reads MLP via `mlStore.inferBatch` (no-op if WASM not ready).
*/
update(window: HeatmapWindow, force: boolean = false): boolean {
const now = performance.now();
if (!force && now - this.lastSampleMs < MIN_INTERVAL_MS) {
this.pending = true;
return false;
}
this.lastSampleMs = now;
this.pending = false;
const N = this.resolution;
const total = N * N;
const points: Array<readonly [number, number]> = new Array(total);
const z = Math.max(0.01, Math.min(1, window.zoom));
const half = z * 0.5;
for (let y = 0; y < N; y++) {
for (let x = 0; x < N; x++) {
const u = N > 1 ? x / (N - 1) : 0.5;
const v = N > 1 ? y / (N - 1) : 0.5;
const px = window.cx + (u - 0.5) * z; // could go negative; the sampler clamps
const py = window.cy + (v - 0.5) * z;
points[y * N + x] = [
Math.max(0, Math.min(1, px)),
Math.max(0, Math.min(1, py)),
];
// Note: we'd love to compute half-based bounds but the existing input
// pipeline already clamps, so just clamp here.
void half;
}
}
const flat = mlStore.inferBatch(points);
if (flat.length === 0) {
// WASM not ready — cells remain at last value.
return false;
}
const outSize = mlStore.state.outputSize;
// Reduce per cell.
let referenceMean = 0;
if (this.colorMode === 'divergence') {
// Reference = center cell (closest to N/2, N/2).
const cy = Math.floor(N / 2);
const cx = Math.floor(N / 2);
const base = (cy * N + cx) * outSize;
let sum = 0;
for (let i = 0; i < outSize; i++) sum += flat[base + i] ?? 0;
referenceMean = sum / outSize;
}
for (let i = 0; i < total; i++) {
const base = i * outSize;
let sum = 0;
let sumSq = 0;
for (let j = 0; j < outSize; j++) {
const v = flat[base + j] ?? 0;
sum += v;
sumSq += v * v;
}
const mean = sum / outSize;
const variance = Math.max(0, sumSq / outSize - mean * mean);
switch (this.colorMode) {
case 'luminance':
this.cells[i] = mean;
break;
case 'variance':
this.cells[i] = variance;
break;
case 'divergence':
this.cells[i] = Math.abs(mean - referenceMean);
break;
}
}
return true;
}
/** True if a call is queued behind throttling. */
hasPendingUpdate(): boolean {
return this.pending;
}
reset(): void {
this.cells.fill(0);
this.lastSampleMs = 0;
}
}

View file

@ -0,0 +1,138 @@
/**
* Microphone input captures user audio and emits audio analysis features
* for modes that take audio_in as primary input (XIASRI, SoundAnalysisMIDI).
*
* Uses an `AudioWorkletNode` for the analysis. Keeps things simple by
* computing four lightweight features in the main thread via AnalyserNode:
*
* - energy: RMS amplitude (0..1)
* - brightness: spectral centroid normalised to 0..1
* - pitch: dominant peak frequency normalised log scale
* - aperiodicity: 1 - peak_strength (0..1)
*
* Stream 10 doesn't ship a fully featured DSP analyser this is a
* pragmatic pipeline that gets values into the MLP. Firmware-equivalent
* XIASRI analysis is a deeper port (out of scope here).
*
* Usage: call `start()` from a user gesture; subscribe via `getFeatures()`
* (Float32Array of length 4). `stop()` releases the mic.
*/
const MIN_FREQ = 80;
const MAX_FREQ = 1200;
const FFT_SIZE = 1024;
export interface MicFeatures {
energy: number;
brightness: number;
pitch: number;
aperiodicity: number;
}
export interface MicInputOptions {
/** Optional shared AudioContext. If absent, creates one. */
audioContext?: AudioContext;
}
export class MicInput {
private ctx: AudioContext | null = null;
private stream: MediaStream | null = null;
private analyser: AnalyserNode | null = null;
private freqBuf: Float32Array<ArrayBuffer> = new Float32Array(new ArrayBuffer(FFT_SIZE / 2 * 4));
private timeBuf: Float32Array<ArrayBuffer> = new Float32Array(new ArrayBuffer(FFT_SIZE * 4));
private features: MicFeatures = { energy: 0, brightness: 0, pitch: 0, aperiodicity: 1 };
private running = false;
constructor(private opts: MicInputOptions = {}) {}
/** Start mic capture. Resolves once audio is flowing. */
async start(): Promise<void> {
if (this.running) return;
if (typeof navigator === 'undefined' || !navigator.mediaDevices) {
throw new Error('[mic-input] navigator.mediaDevices unavailable');
}
this.ctx = this.opts.audioContext ?? new AudioContext();
if (this.ctx.state === 'suspended') await this.ctx.resume();
this.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const src = this.ctx.createMediaStreamSource(this.stream);
this.analyser = this.ctx.createAnalyser();
this.analyser.fftSize = FFT_SIZE;
this.analyser.smoothingTimeConstant = 0.6;
src.connect(this.analyser);
this.running = true;
}
async stop(): Promise<void> {
if (!this.running) return;
this.running = false;
if (this.stream) {
for (const t of this.stream.getTracks()) t.stop();
this.stream = null;
}
if (this.ctx && !this.opts.audioContext) {
try { await this.ctx.close(); } catch { /* ignore */ }
}
this.ctx = null;
this.analyser = null;
}
isRunning(): boolean {
return this.running;
}
/** Compute and return the latest feature vector. */
getFeatures(): MicFeatures {
if (!this.running || !this.analyser || !this.ctx) return this.features;
const sr = this.ctx.sampleRate;
const a = this.analyser;
a.getFloatFrequencyData(this.freqBuf);
a.getFloatTimeDomainData(this.timeBuf);
// RMS energy
let sumSq = 0;
for (let i = 0; i < this.timeBuf.length; ++i) {
const s = this.timeBuf[i]!;
sumSq += s * s;
}
const rms = Math.sqrt(sumSq / this.timeBuf.length);
const energy = Math.min(1, rms * 4);
// Convert dB → linear, find spectral centroid + peak
let sumMag = 0;
let sumWeighted = 0;
let peakBin = 0;
let peakMag = -Infinity;
const binHz = sr / FFT_SIZE;
const minBin = Math.floor(MIN_FREQ / binHz);
const maxBin = Math.min(this.freqBuf.length - 1, Math.ceil(MAX_FREQ / binHz));
for (let i = 0; i < this.freqBuf.length; ++i) {
const db = this.freqBuf[i]!;
const lin = Math.pow(10, db / 20);
sumMag += lin;
sumWeighted += i * binHz * lin;
if (i >= minBin && i <= maxBin && db > peakMag) {
peakMag = db;
peakBin = i;
}
}
const centroidHz = sumMag > 1e-12 ? sumWeighted / sumMag : 0;
const brightness = Math.min(1, centroidHz / (sr * 0.25));
// Pitch (log scale 801200 Hz)
const pitchHz = peakBin * binHz;
const pitchNorm = pitchHz <= MIN_FREQ
? 0
: pitchHz >= MAX_FREQ
? 1
: (Math.log(pitchHz / MIN_FREQ) / Math.log(MAX_FREQ / MIN_FREQ));
// Aperiodicity: 1 - relative peak height (vs spectral mean)
const meanLin = sumMag / this.freqBuf.length;
const peakLin = Math.pow(10, peakMag / 20);
const ratio = meanLin > 1e-12 ? peakLin / meanLin : 0;
const aperiodicity = Math.max(0, Math.min(1, 1 - Math.min(1, ratio / 30)));
this.features = { energy, brightness, pitch: pitchNorm, aperiodicity };
return this.features;
}
}

View file

@ -0,0 +1,131 @@
/**
* Override application apply per-parameter overrides between MLP outputs
* and the engine's parameter destination.
*
* The MLP emits values in [0, 1]. The mode schema specifies a per-parameter
* `min`, `max`, and `curve`. Modes can layer overrides on top via
* `modeStore.setOverride` to:
* - Re-curve the value (`curve` and `curveParam`).
* - Mute it (replace with `fixedValue` [hardMin, hardMax]).
* - Freeze it (hold last processed value).
* - Tighten the active range (`min`/`max` schema bounds).
*
* `applyOverrides` walks the schema params in order, looks up overrides for
* each, and writes results into a target Float32Array of the same length as
* the MLP slice. The function is pure caller manages buffer reuse.
*
* The output buffer values are NORMALISED to [0, 1] in their effective
* range so the mode's audio engine, MIDI router, or visualiser can scale
* uniformly. Engines/MIDI consumers that need actual min/max units can use
* `paramsToSliderValues` from `mode-helpers`.
*/
import type { ParamOverride } from '../stores/mode-store';
import type { Param } from '../modes/generated/types';
import { applyCurve, type CurveName, clamp01 } from '../output/curves';
/**
* Result of applying overrides:
* - `values` is a Float32Array of length `params.length`, each in [0, 1]
* representing the post-override normalised parameter value.
* - `freezeMask` (optional) marks per-output frozen flags. Only allocated
* when at least one param is frozen saves work on the typical case.
*/
export interface OverrideResult {
values: Float32Array;
freezeMask: Uint8Array | null;
/** Number of muted/frozen entries (for telemetry). */
affectedCount: number;
}
/**
* Apply per-param overrides to the slice of MLP outputs corresponding to
* the schema's params.
*
* @param raw processed (post output-pipeline) MLP outputs in [0,1]
* @param prev previous post-override outputs (for freeze hold)
* @param params mode schema params
* @param overrides per-name override map (sparse; missing keys fall through)
* @param dest optional destination Float32Array; allocated if absent
*/
export function applyOverrides(
raw: Float32Array,
prev: Float32Array | null,
params: ReadonlyArray<Param>,
overrides: Record<string, ParamOverride>,
dest?: Float32Array,
): OverrideResult {
const n = params.length;
const out = dest && dest.length === n ? dest : new Float32Array(n);
let affected = 0;
let freezeMask: Uint8Array | null = null;
for (let i = 0; i < n; ++i) {
const p = params[i]!;
const ov = overrides[p.name];
const r = clamp01(raw[i] ?? 0);
if (!ov) {
// No override → identity (raw normalised value).
out[i] = r;
continue;
}
if (ov.frozen) {
// Freeze: hold last processed value. Track in freezeMask so the
// output pipeline can also honor it next frame.
if (!freezeMask) freezeMask = new Uint8Array(n);
freezeMask[i] = 1;
out[i] = prev?.[i] ?? r;
affected++;
continue;
}
if (ov.muted) {
// Mute: replace with fixedValue. fixedValue is stored in schema units
// [min, max] — normalise back to [0, 1] in the override's effective range.
const range = ov.max - ov.min;
const norm = range > 0 ? (ov.fixedValue - ov.min) / range : 0.5;
out[i] = clamp01(norm);
affected++;
continue;
}
// Apply curve (if any), then clamp.
let v = r;
if (ov.curve && ov.curve !== 'linear') {
v = applyCurve(ov.curve as CurveName, r, ov.curveParam);
}
out[i] = clamp01(v);
}
return { values: out, freezeMask, affectedCount: affected };
}
/**
* Build a Uint8Array of length `outputSize` marking every pinned output
* with a 1. Used by `WasmIML.moveWeights` to skip pinned weights during RL
* perturbation. Combines:
* - explicit param-pins from sessionStore
* - per-param `pinned` flag in modeStore overrides
*/
export function buildPinMask(
outputSize: number,
modeId: string,
params: ReadonlyArray<Param>,
overrides: Record<string, ParamOverride>,
paramPins: ReadonlyArray<{ key: string; outputIndex: number }>,
): Uint8Array {
const mask = new Uint8Array(outputSize);
for (let i = 0; i < params.length && i < outputSize; ++i) {
const ov = overrides[params[i]!.name];
if (ov && ov.pinned) mask[i] = 1;
}
for (const pin of paramPins) {
if (!pin.key.startsWith(modeId + ':')) continue;
if (pin.outputIndex >= 0 && pin.outputIndex < outputSize) {
mask[pin.outputIndex] = 1;
}
}
return mask;
}

View file

@ -0,0 +1,288 @@
/**
* Session preset full app-state snapshot for save/load and URL sharing.
*
* A preset captures: control axes + offsets, exploration config, output
* pipeline settings, input pipeline settings, mode override map, the
* active mode id. ML weights are NOT included by default (large), but
* `serializeWithWeights` is provided for power users.
*
* URL sharing uses a compact encoding:
* ?session=<base64url(JSON)>
* ?boldness=...&memory=...&precision=...
* Either form rehydrates on next load.
*/
import { controlStore } from '../stores/control-store';
import { explorationStore } from '../stores/exploration-store';
import { modeStore } from '../stores/mode-store';
import { inputStore } from '../stores/input-store';
import { outputStore } from '../stores/output-store';
import { mlStore } from '../stores/ml-store';
import { sessionStore } from '../stores/session-store';
export interface SessionPresetPayload {
v: 1;
modeId: string | null;
control: {
boldness: number;
memory: number;
precision: number;
presetId: string | null;
offsets: typeof controlStore.state.offsets;
};
exploration: {
spread: number;
noiseFloor: number;
noiseCap: number;
noiseGrowth: number;
noiseDecay: number;
learningRate: number;
weightDecay: number;
};
output: {
globalCurve: number;
smoothing: number;
slewRate: number | null; // null encodes Infinity
freezeOutput: boolean;
};
input: {
zoom: number;
anchorMode: 'auto' | 'sticky' | 'center';
deadzone: number;
inputCurve: number;
smoothing: number;
momentumZoom: 'off' | 'gentle' | 'strong';
invertX: boolean;
invertY: boolean;
};
modeOverrides: typeof modeStore.state.overrides;
/** Base64-encoded weights, optional. */
weights?: string;
}
function encode(s: string): string {
// base64url encoding
const b64 = btoa(s);
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function decode(s: string): string | null {
try {
let b64 = s.replace(/-/g, '+').replace(/_/g, '/');
while (b64.length % 4 !== 0) b64 += '=';
return atob(b64);
} catch {
return null;
}
}
function f32ToBase64(arr: Float32Array): string {
const u8 = new Uint8Array(arr.buffer, arr.byteOffset, arr.byteLength);
let s = '';
for (let i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]!);
return encode(s);
}
function base64ToF32(s: string): Float32Array | null {
const raw = decode(s);
if (!raw) return null;
const u8 = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) u8[i] = raw.charCodeAt(i);
// Round to 4 bytes
const len = u8.byteLength - (u8.byteLength % 4);
return new Float32Array(u8.buffer, u8.byteOffset, len / 4).slice();
}
/**
* Read every store and assemble a SessionPresetPayload. If
* `withWeights = true`, also encodes the current MLP weight blob.
*/
export function captureSessionPreset(withWeights: boolean = false): SessionPresetPayload {
const c = controlStore.state;
const e = explorationStore.state;
const o = outputStore.config;
const i = inputStore.config;
const payload: SessionPresetPayload = {
v: 1,
modeId: modeStore.state.activeModeId,
control: {
boldness: c.boldness,
memory: c.memory,
precision: c.precision,
presetId: c.presetId,
offsets: JSON.parse(JSON.stringify(c.offsets)),
},
exploration: {
spread: e.spread,
noiseFloor: e.noiseFloor,
noiseCap: e.noiseCap,
noiseGrowth: e.noiseGrowth,
noiseDecay: e.noiseDecay,
learningRate: e.learningRate,
weightDecay: e.weightDecay,
},
output: {
globalCurve: o.globalCurve,
smoothing: o.smoothing,
slewRate: isFinite(o.slewRate) ? o.slewRate : null,
freezeOutput: o.freezeOutput,
},
input: {
zoom: i.zoom,
anchorMode: i.anchorMode,
deadzone: i.deadzone,
inputCurve: i.inputCurve,
smoothing: i.smoothing,
momentumZoom: i.momentumZoom,
invertX: i.invertX,
invertY: i.invertY,
},
modeOverrides: JSON.parse(JSON.stringify(modeStore.state.overrides)),
};
if (withWeights) {
const w = mlStore.getWeights();
if (w.length > 0) {
payload.weights = f32ToBase64(w);
}
}
return payload;
}
/**
* Restore a SessionPresetPayload into all stores. Best-effort: missing
* fields fall back to current values. Returns true if at least one store
* was modified.
*/
export function restoreSessionPreset(payload: SessionPresetPayload): boolean {
if (!payload || payload.v !== 1) return false;
// Mode first (so override map is keyed correctly).
if (payload.modeId && payload.modeId !== modeStore.state.activeModeId) {
modeStore.switchMode(payload.modeId);
}
// Control axes & offsets.
if (payload.control) {
controlStore.setAxis('boldness', payload.control.boldness);
controlStore.setAxis('memory', payload.control.memory);
controlStore.setAxis('precision', payload.control.precision);
if (payload.control.presetId) controlStore.applyPreset(payload.control.presetId);
// Manually rewrite offsets — the store doesn't have a public bulk setter
// (control-store doesn't expose it because it's a private field).
// Use clearOffsets + setOffset roundtrip.
for (const axis of ['boldness', 'memory', 'precision'] as const) {
controlStore.clearOffsets(axis);
const off = payload.control.offsets[axis] ?? {};
for (const [k, v] of Object.entries(off)) {
if (typeof v === 'number') controlStore.setOffset(axis, k, v);
}
}
}
if (payload.exploration) {
explorationStore.setSpread(payload.exploration.spread);
explorationStore.setNoiseFloor(payload.exploration.noiseFloor);
explorationStore.setNoiseCap(payload.exploration.noiseCap);
explorationStore.setNoiseGrowth(payload.exploration.noiseGrowth);
explorationStore.setNoiseDecay(payload.exploration.noiseDecay);
explorationStore.setLearningRate(payload.exploration.learningRate);
explorationStore.setWeightDecay(payload.exploration.weightDecay);
}
if (payload.output) {
outputStore.setGlobalCurve(payload.output.globalCurve);
outputStore.setSmoothing(payload.output.smoothing);
outputStore.setSlewRate(payload.output.slewRate ?? Infinity);
outputStore.setFreezeOutput(payload.output.freezeOutput);
}
if (payload.input) {
inputStore.setZoom(payload.input.zoom);
inputStore.setAnchorMode(payload.input.anchorMode);
inputStore.setDeadzone(payload.input.deadzone);
inputStore.setInputCurve(payload.input.inputCurve);
inputStore.setSmoothing(payload.input.smoothing);
inputStore.setMomentumZoom(payload.input.momentumZoom);
inputStore.setInvert(payload.input.invertX, payload.input.invertY);
}
if (payload.modeOverrides) {
for (const modeId of Object.keys(payload.modeOverrides)) {
const map = payload.modeOverrides[modeId] ?? {};
modeStore.clearAllOverrides(modeId);
for (const [name, ov] of Object.entries(map)) {
modeStore.setOverride(modeId, name, ov);
}
}
}
if (payload.weights) {
const w = base64ToF32(payload.weights);
if (w && mlStore.iml && w.length > 0) {
try { mlStore.setWeights(w); } catch { /* ignore size mismatch */ }
}
}
return true;
}
/** Save preset to sessionStore (named blob in localStorage). */
export function saveNamedPreset(name: string, withWeights: boolean = false): void {
const payload = captureSessionPreset(withWeights);
sessionStore.savePreset(name, payload);
}
/** Load a named preset by id from sessionStore. */
export function loadNamedPreset(id: string): boolean {
const preset = sessionStore.state.presets.find((p) => p.id === id);
if (!preset) return false;
return restoreSessionPreset(preset.payload as SessionPresetPayload);
}
// ---------------------------------------------------------------------------
// URL sharing
// ---------------------------------------------------------------------------
/** Build a query-string fragment from current state (without weights). */
export function buildShareUrl(): string {
const payload = captureSessionPreset(false);
const json = JSON.stringify(payload);
const enc = encode(json);
return `?session=${enc}`;
}
/** Parse `window.location.search` and apply a session if present. */
export function applyUrlParams(search: string = window.location.search): boolean {
const sp = new URLSearchParams(search);
// Compact ?boldness=...&memory=... form.
const cb = sp.get('boldness');
const cm = sp.get('memory');
const cp = sp.get('precision');
let applied = false;
if (cb !== null || cm !== null || cp !== null) {
if (cb !== null) controlStore.setAxis('boldness', clamp01(parseFloat(cb)));
if (cm !== null) controlStore.setAxis('memory', clamp01(parseFloat(cm)));
if (cp !== null) controlStore.setAxis('precision', clamp01(parseFloat(cp)));
applied = true;
}
const sess = sp.get('session');
if (sess) {
const json = decode(sess);
if (json) {
try {
const payload = JSON.parse(json) as SessionPresetPayload;
applied = restoreSessionPreset(payload) || applied;
} catch {
// Ignore malformed
}
}
}
return applied;
}
function clamp01(v: number): number {
if (!isFinite(v)) return 0;
return v < 0 ? 0 : v > 1 ? 1 : v;
}

View file

@ -0,0 +1,123 @@
/**
* Snapshot helpers bridge sessionStore.pushSnapshot/popSnapshot to the
* actual MLP weights via mlStore. Auto-snapshot before train/randomize/
* thumbs-down.
*/
import { mlStore } from '../stores/ml-store';
import { sessionStore } from '../stores/session-store';
import { explorationStore } from '../stores/exploration-store';
import { inputStore } from '../stores/input-store';
/** Capture current weights + noise level + zoom level. */
export function autoSnapshot(tag: string): void {
if (!mlStore.iml) return;
const w = mlStore.getWeights();
if (w.length === 0) return;
sessionStore.pushSnapshot(tag, {
noiseLevel: explorationStore.state.noiseLevel,
zoomLevel: inputStore.config.zoom,
weights: w,
});
}
/**
* Pop the most recent snapshot and restore weights. Returns true on success.
*/
export function undoLastSnapshot(): boolean {
const snap = sessionStore.popSnapshot();
if (!snap || !snap.weights) return false;
if (!mlStore.iml) return false;
try {
mlStore.setWeights(snap.weights);
explorationStore.setNoiseLevel(snap.noiseLevel);
return true;
} catch {
return false;
}
}
/** Restore a specific snapshot by id. */
export function restoreSnapshotById(id: string): boolean {
const snap = sessionStore.jumpToSnapshot(id);
if (!snap || !snap.weights) return false;
if (!mlStore.iml) return false;
try {
mlStore.setWeights(snap.weights);
explorationStore.setNoiseLevel(snap.noiseLevel);
return true;
} catch {
return false;
}
}
/**
* A/B compare helpers capture the current weights as A, the next state
* becomes B. Toggle swaps in O(1).
*/
export function captureA(): void {
if (!mlStore.iml) return;
const w = mlStore.getWeights();
if (w.length === 0) return;
sessionStore.captureA({
id: `a-${Date.now().toString(36)}`,
tag: 'A',
timestamp: Date.now(),
noiseLevel: explorationStore.state.noiseLevel,
zoomLevel: inputStore.config.zoom,
weights: w,
});
}
export function toggleAB(): 'A' | 'B' {
if (!mlStore.iml) return sessionStore.state.ab.live;
// Capture the current live state on first toggle.
const cur = mlStore.getWeights();
const live = sessionStore.state.ab.live;
if (live === 'B' && !sessionStore.state.ab.b) {
// First toggle from B → A: capture the current state as B.
sessionStore.captureB({
id: `b-${Date.now().toString(36)}`,
tag: 'B',
timestamp: Date.now(),
noiseLevel: explorationStore.state.noiseLevel,
zoomLevel: inputStore.config.zoom,
weights: cur,
});
} else if (live === 'A') {
// Going back from A → B: update B with the current edits before flipping.
sessionStore.captureB({
id: `b-${Date.now().toString(36)}`,
tag: 'B',
timestamp: Date.now(),
noiseLevel: explorationStore.state.noiseLevel,
zoomLevel: inputStore.config.zoom,
weights: cur,
});
}
const next = sessionStore.toggleAB();
// Apply the side we just switched to.
const target = next === 'A' ? sessionStore.state.ab.a : sessionStore.state.ab.b;
if (target?.weights) {
try { mlStore.setWeights(target.weights); } catch { /* ignore */ }
explorationStore.setNoiseLevel(target.noiseLevel);
}
return next;
}
export function acceptB(): void {
sessionStore.acceptB();
}
export function revertToA(): void {
if (!mlStore.iml) {
sessionStore.revertToA();
return;
}
const a = sessionStore.state.ab.a;
if (a?.weights) {
try { mlStore.setWeights(a.weights); } catch { /* ignore */ }
explorationStore.setNoiseLevel(a.noiseLevel);
}
sessionStore.revertToA();
}

View file

@ -0,0 +1,55 @@
/**
* Trail tracking fixed-size ring of recent input positions for the JoyMap
* vanishing trail and tap-to-return.
*
* Uses `performance.now()` for timestamps. The JoyMap primitive filters by
* age internally (5-second window), so the buffer can be larger than the
* displayed window.
*/
import { createSignal, type Accessor } from 'solid-js';
export interface TrailPoint {
x: number;
y: number;
t: number; // performance.now()
}
const MAX_POINTS = 300;
const MIN_DELTA_PX = 0.005; // skip near-duplicate points (in 0..1 space)
export interface TrailRing {
/** Reactive accessor for current points. */
points: Accessor<ReadonlyArray<TrailPoint>>;
push(x: number, y: number): void;
clear(): void;
}
export function createTrailRing(): TrailRing {
const [points, setPoints] = createSignal<ReadonlyArray<TrailPoint>>([], { equals: false });
let buf: TrailPoint[] = [];
return {
points,
push(x, y) {
const now = performance.now();
// Skip if too close to last point.
const last = buf.length > 0 ? buf[buf.length - 1] : null;
if (last) {
const dx = x - last.x;
const dy = y - last.y;
if (dx * dx + dy * dy < MIN_DELTA_PX * MIN_DELTA_PX && now - last.t < 50) {
return;
}
}
buf.push({ x, y, t: now });
if (buf.length > MAX_POINTS) buf.shift();
setPoints(buf.slice());
},
clear() {
buf = [];
setPoints([]);
},
};
}

View file

@ -0,0 +1,124 @@
/**
* Weight health + gradient flow analysis.
*
* Pure functions over Float32Array weight buffers and `LayerStats[]` from
* the WasmIML. Mode UI binds these to the WeightHealth and GradientFlow
* primitives.
*/
import type { LayerStats } from '../ml/types';
import type { WeightStatus } from '../primitives/WeightHealth';
import type { GradientStatus } from '../primitives/GradientFlow';
const HISTOGRAM_BINS = 10;
/** Magnitude bin edges: [0, 0.01, 0.05, 0.15, 0.4, 0.8, 1.5, 2.5, 4, 7, ∞] */
const BIN_EDGES = [0, 0.01, 0.05, 0.15, 0.4, 0.8, 1.5, 2.5, 4, 7];
/** Build a 10-bin histogram of weight magnitudes from a flat weight array. */
export function weightHistogram(weights: Float32Array): number[] {
const bins = new Array<number>(HISTOGRAM_BINS).fill(0);
if (weights.length === 0) return bins;
for (let i = 0; i < weights.length; ++i) {
const m = Math.abs(weights[i]!);
let bin = HISTOGRAM_BINS - 1;
for (let b = 0; b < HISTOGRAM_BINS - 1; ++b) {
if (m < BIN_EDGES[b + 1]!) {
bin = b;
break;
}
}
bins[bin]++;
}
return bins;
}
/** Determine overall network health status. */
export function weightStatus(weights: Float32Array): WeightStatus {
if (weights.length === 0) return 'healthy';
let dead = 0;
let saturating = 0;
for (let i = 0; i < weights.length; ++i) {
const m = Math.abs(weights[i]!);
if (m < 0.01) dead++;
else if (m > 3.0) saturating++;
}
const deadFrac = dead / weights.length;
const satFrac = saturating / weights.length;
if (satFrac > 0.4) return 'saturating';
if (deadFrac > 0.3) return 'dead';
return 'healthy';
}
/**
* Per-layer L2 norm of weight delta (after - before). Used to populate
* GradientFlow primitive.
*/
export function layerNorms(beforeWeights: Float32Array, afterWeights: Float32Array, layerSizes: ReadonlyArray<number>): number[] {
const out: number[] = [];
let offset = 0;
for (const sz of layerSizes) {
let sum = 0;
const end = Math.min(beforeWeights.length, afterWeights.length, offset + sz);
for (let i = offset; i < end; ++i) {
const d = (afterWeights[i] ?? 0) - (beforeWeights[i] ?? 0);
sum += d * d;
}
out.push(Math.sqrt(sum));
offset += sz;
}
return out;
}
/**
* Detect vanishing/exploding/converged gradient flow given per-layer norms.
*/
export function gradientStatuses(norms: ReadonlyArray<number>): GradientStatus[] {
if (norms.length === 0) return [];
// Converged check first: all near-zero.
const allTiny = norms.every((n) => n < 1e-6);
if (allTiny) return norms.map(() => 'converged' as GradientStatus);
const out: GradientStatus[] = [];
for (let i = 0; i < norms.length; ++i) {
if (i === 0) {
out.push('healthy');
continue;
}
const prev = norms[i - 1] ?? 0;
const curr = norms[i] ?? 0;
if (prev <= 1e-12) {
out.push('healthy');
} else if (curr < 0.5 * prev) {
out.push('vanishing');
} else if (curr > 2.0 * prev) {
out.push('exploding');
} else {
out.push('healthy');
}
}
return out;
}
/**
* Estimate per-layer weight count given an MLP architecture.
*
* MLP layout: layer i has weights = inputs * outputs + outputs (bias).
* `[inputSize, ...hidden, outputSize]` N+1 layer sizes, N layers.
*/
export function layerWeightCounts(arch: { inputSize: number; hidden: ReadonlyArray<number>; outputSize: number }): number[] {
const sizes = [arch.inputSize, ...arch.hidden, arch.outputSize];
const counts: number[] = [];
for (let i = 0; i < sizes.length - 1; ++i) {
const ins = sizes[i]!;
const outs = sizes[i + 1]!;
counts.push(ins * outs + outs);
}
return counts;
}
/** Map LayerStats to a healthy/dead/saturating per-layer status. */
export function layerStatsToStatus(stats: LayerStats): WeightStatus {
if (stats.saturatingFrac > 0.4) return 'saturating';
if (stats.deadFrac > 0.3) return 'dead';
return 'healthy';
}

View file

@ -3,15 +3,23 @@ import { render } from 'solid-js/web';
import App from './App'; import App from './App';
import './styles/tokens.css'; import './styles/tokens.css';
import { installDebugProbe } from './debug/probe'; import { installDebugProbe } from './debug/probe';
import { applyUrlParams } from './features/session-preset';
const root = document.getElementById('root'); const root = document.getElementById('root');
if (!root) { if (!root) {
throw new Error('Root element #root not found'); throw new Error('Root element #root not found');
} }
// Install debug probe early. It is a stub for now; stream 10 fills it in // Install debug probe early. Playwright tests use it to drive the ML
// once WASM ML is wired up. Keeping the install path stable from day one // engine programmatically.
// makes Playwright tests insensitive to ordering.
installDebugProbe(); installDebugProbe();
// Apply URL params (?session=..., ?boldness=..., etc) before mounting so
// stores read the resolved values on first render.
try {
applyUrlParams();
} catch (err) {
console.warn('[main] applyUrlParams failed:', err);
}
render(() => <App />, root); render(() => <App />, root);

View file

@ -7,32 +7,18 @@ import { ModeShell } from './ModeShell';
import { useModeRuntime } from './mode-runtime'; import { useModeRuntime } from './mode-runtime';
import { XYPad } from '../primitives/XYPad'; import { XYPad } from '../primitives/XYPad';
import { OutputDisplay } from '../primitives/OutputDisplay'; import { OutputDisplay } from '../primitives/OutputDisplay';
import { SliderBank } from '../primitives/SliderBank';
import { LossPlot } from '../primitives/LossPlot'; import { LossPlot } from '../primitives/LossPlot';
import { paramsToSliderConfig, outputsToSliderValues } from './mode-helpers';
import { BreakorSchema } from './generated/breakor_schema'; import { BreakorSchema } from './generated/breakor_schema';
export const BreakOrMode: Component = () => { export const BreakOrMode: Component = () => {
const schema = BreakorSchema; const schema = BreakorSchema;
const runtime = useModeRuntime(schema); const runtime = useModeRuntime(schema);
const sliderConfig = paramsToSliderConfig(schema.params);
const sliderValues = () => outputsToSliderValues(runtime.processedOutputs(), schema.params);
return ( return (
<ModeShell <ModeShell
schema={schema} schema={schema}
runtime={runtime} runtime={runtime}
drawerTitle="Breakor params" drawerTitle="Breakor settings"
drawerContent={() => (
<SliderBank
title="Live drum params"
sliders={sliderConfig}
values={sliderValues}
onChange={() => {
/* read-only */
}}
/>
)}
primaryInput={() => ( primaryInput={() => (
<> <>
<XYPad <XYPad
@ -49,7 +35,7 @@ export const BreakOrMode: Component = () => {
outputArea={() => ( outputArea={() => (
<> <>
<OutputDisplay <OutputDisplay
values={runtime.processedOutputs} values={runtime.paramOutputs}
width={360} width={360}
height={120} height={120}
color="var(--good)" color="var(--good)"

View file

@ -1,8 +1,8 @@
/** /**
* ChannelStripMode channel-strip processor controlled by joystick. * ChannelStripMode channel-strip processor controlled by joystick.
* *
* 24 outputs feed EQ / dynamics / gain. Schema has no voice spaces so the * 24 outputs feed EQ / dynamics / gain. Settings drawer is the default
* shell omits the selector. Drawer shows the live param sliders. * SettingsDrawer (input/training/exploration/output/overrides/advanced).
*/ */
import { Component } from 'solid-js'; import { Component } from 'solid-js';
@ -10,33 +10,18 @@ import { ModeShell } from './ModeShell';
import { useModeRuntime } from './mode-runtime'; import { useModeRuntime } from './mode-runtime';
import { VirtualJoystick } from '../primitives/VirtualJoystick'; import { VirtualJoystick } from '../primitives/VirtualJoystick';
import { OutputDisplay } from '../primitives/OutputDisplay'; import { OutputDisplay } from '../primitives/OutputDisplay';
import { SliderBank } from '../primitives/SliderBank';
import { LossPlot } from '../primitives/LossPlot'; import { LossPlot } from '../primitives/LossPlot';
import { paramsToSliderConfig, outputsToSliderValues } from './mode-helpers';
import { ChannelStripSchema } from './generated/channel_strip_schema'; import { ChannelStripSchema } from './generated/channel_strip_schema';
export const ChannelStripMode: Component = () => { export const ChannelStripMode: Component = () => {
const schema = ChannelStripSchema; const schema = ChannelStripSchema;
const runtime = useModeRuntime(schema); const runtime = useModeRuntime(schema);
const sliderConfig = paramsToSliderConfig(schema.params);
const sliderValues = () => outputsToSliderValues(runtime.processedOutputs(), schema.params);
return ( return (
<ModeShell <ModeShell
schema={schema} schema={schema}
runtime={runtime} runtime={runtime}
drawerTitle="Channel strip params" drawerTitle="Channel strip settings"
drawerContent={() => (
<SliderBank
title="Live parameter values"
sliders={sliderConfig}
values={sliderValues}
onChange={() => {
/* read-only for now */
}}
/>
)}
primaryInput={() => ( primaryInput={() => (
<> <>
<VirtualJoystick <VirtualJoystick
@ -53,7 +38,7 @@ export const ChannelStripMode: Component = () => {
outputArea={() => ( outputArea={() => (
<> <>
<OutputDisplay <OutputDisplay
values={runtime.processedOutputs} values={runtime.paramOutputs}
width={360} width={360}
height={120} height={120}
color="var(--accent-3)" color="var(--accent-3)"

View file

@ -7,32 +7,18 @@ import { ModeShell } from './ModeShell';
import { useModeRuntime } from './mode-runtime'; import { useModeRuntime } from './mode-runtime';
import { XYPad } from '../primitives/XYPad'; import { XYPad } from '../primitives/XYPad';
import { OutputDisplay } from '../primitives/OutputDisplay'; import { OutputDisplay } from '../primitives/OutputDisplay';
import { SliderBank } from '../primitives/SliderBank';
import { LossPlot } from '../primitives/LossPlot'; import { LossPlot } from '../primitives/LossPlot';
import { paramsToSliderConfig, outputsToSliderValues } from './mode-helpers';
import { ElysiamorfSchema } from './generated/elysiamorf_schema'; import { ElysiamorfSchema } from './generated/elysiamorf_schema';
export const ElysiamorfMode: Component = () => { export const ElysiamorfMode: Component = () => {
const schema = ElysiamorfSchema; const schema = ElysiamorfSchema;
const runtime = useModeRuntime(schema); const runtime = useModeRuntime(schema);
const sliderConfig = paramsToSliderConfig(schema.params);
const sliderValues = () => outputsToSliderValues(runtime.processedOutputs(), schema.params);
return ( return (
<ModeShell <ModeShell
schema={schema} schema={schema}
runtime={runtime} runtime={runtime}
drawerTitle="Elysiamorf params" drawerTitle="Elysiamorf settings"
drawerContent={() => (
<SliderBank
title="Live morph params"
sliders={sliderConfig}
values={sliderValues}
onChange={() => {
/* read-only */
}}
/>
)}
primaryInput={() => ( primaryInput={() => (
<> <>
<XYPad <XYPad
@ -49,7 +35,7 @@ export const ElysiamorfMode: Component = () => {
outputArea={() => ( outputArea={() => (
<> <>
<OutputDisplay <OutputDisplay
values={runtime.processedOutputs} values={runtime.paramOutputs}
width={360} width={360}
height={120} height={120}
color="#ffb060" color="#ffb060"

View file

@ -7,17 +7,13 @@ import { ModeShell } from './ModeShell';
import { useModeRuntime } from './mode-runtime'; import { useModeRuntime } from './mode-runtime';
import { XYPad } from '../primitives/XYPad'; import { XYPad } from '../primitives/XYPad';
import { OutputDisplay } from '../primitives/OutputDisplay'; import { OutputDisplay } from '../primitives/OutputDisplay';
import { SliderBank } from '../primitives/SliderBank';
import { LossPlot } from '../primitives/LossPlot'; import { LossPlot } from '../primitives/LossPlot';
import { paramsToSliderConfig, outputsToSliderValues } from './mode-helpers';
import { MemlceliumSchema } from './generated/memlcelium_schema'; import { MemlceliumSchema } from './generated/memlcelium_schema';
export const MEMLCeliumMode: Component = () => { export const MEMLCeliumMode: Component = () => {
const schema = MemlceliumSchema; const schema = MemlceliumSchema;
const runtime = useModeRuntime(schema); const runtime = useModeRuntime(schema);
const [voiceSpace, setVoiceSpace] = createSignal(0); const [voiceSpace, setVoiceSpace] = createSignal(0);
const sliderConfig = paramsToSliderConfig(schema.params);
const sliderValues = () => outputsToSliderValues(runtime.processedOutputs(), schema.params);
return ( return (
<ModeShell <ModeShell
@ -25,17 +21,7 @@ export const MEMLCeliumMode: Component = () => {
runtime={runtime} runtime={runtime}
activeVoiceSpace={voiceSpace} activeVoiceSpace={voiceSpace}
onVoiceSpaceChange={setVoiceSpace} onVoiceSpaceChange={setVoiceSpace}
drawerTitle="MEMLCelium voice + CV" drawerTitle="MEMLCelium settings"
drawerContent={() => (
<SliderBank
title="Live voice + CV params"
sliders={sliderConfig}
values={sliderValues}
onChange={() => {
/* read-only */
}}
/>
)}
primaryInput={() => ( primaryInput={() => (
<> <>
<XYPad <XYPad
@ -53,7 +39,7 @@ export const MEMLCeliumMode: Component = () => {
outputArea={() => ( outputArea={() => (
<> <>
<OutputDisplay <OutputDisplay
values={runtime.processedOutputs} values={runtime.paramOutputs}
width={360} width={360}
height={120} height={120}
color="#5b9eef" color="#5b9eef"

View file

@ -2,25 +2,30 @@
* ModeShell common scaffolding shared by every concrete mode. * ModeShell common scaffolding shared by every concrete mode.
* *
* Provides: * Provides:
* - Header (mode name, optional voice-space PillToggle, audio start/stop). * - Header (mode name, voice space pill, audio start/stop, drawer button).
* - Primary input area (joystick / xy-pad / audio analyser, mode-supplied). * - Primary input area (mode-supplied) + JoyMap navigator on its right.
* - Output area (sliders / output bars, mode-supplied). * - Output area (mode-supplied).
* - Control axes bar (Boldness/Memory/Precision wired to controlStore). * - Control axes bar (Boldness/Memory/Precision wired to controlStore).
* - Training controls (wired to the mode runtime). * - Training controls undo, A/B, region/param pin all wired through.
* - Optional right-side drawer for mode-specific settings. * - SettingsDrawer (collapsible sections, per-param overrides, advanced).
* *
* Modes only have to author the primary input + output JSX. Everything * Modes only have to author the primary input + output JSX. Everything
* else is owned here so behaviour stays consistent across modes. * else is owned here so behaviour stays consistent across modes.
*/ */
import { Component, createSignal, JSX, Show, For } from 'solid-js'; import { Component, createSignal, JSX, Show, For, createMemo } from 'solid-js';
import { TrainingControls } from '../primitives/TrainingControls'; import { TrainingControls } from '../primitives/TrainingControls';
import { ControlAxis } from '../primitives/ControlAxis'; import { ControlAxis } from '../primitives/ControlAxis';
import { PillToggle } from '../primitives/PillToggle'; import { PillToggle } from '../primitives/PillToggle';
import { Drawer } from '../primitives/Drawer'; import { Drawer } from '../primitives/Drawer';
import { JoyMap } from '../primitives/JoyMap';
import { controlStore } from '../stores/control-store'; import { controlStore } from '../stores/control-store';
import { inputStore } from '../stores/input-store';
import { sessionStore } from '../stores/session-store';
import { explorationStore } from '../stores/exploration-store';
import type { ModeRuntime } from './mode-runtime'; import type { ModeRuntime } from './mode-runtime';
import type { ModeSchema } from './generated'; import type { ModeSchema } from './generated';
import { SettingsDrawer } from './SettingsDrawer';
import styles from './ModeShell.module.css'; import styles from './ModeShell.module.css';
export interface ModeShellProps { export interface ModeShellProps {
@ -30,7 +35,7 @@ export interface ModeShellProps {
primaryInput: () => JSX.Element; primaryInput: () => JSX.Element;
/** Output / visualisation area. */ /** Output / visualisation area. */
outputArea: () => JSX.Element; outputArea: () => JSX.Element;
/** Optional drawer body for mode-specific settings. */ /** Optional drawer body for mode-specific settings (replaces default). */
drawerContent?: () => JSX.Element; drawerContent?: () => JSX.Element;
drawerTitle?: string; drawerTitle?: string;
/** Active voice space index (only used if schema.voice_spaces is non-empty). */ /** Active voice space index (only used if schema.voice_spaces is non-empty). */
@ -40,6 +45,7 @@ export interface ModeShellProps {
export const ModeShell: Component<ModeShellProps> = (props) => { export const ModeShell: Component<ModeShellProps> = (props) => {
const [drawerOpen, setDrawerOpen] = createSignal(false); const [drawerOpen, setDrawerOpen] = createSignal(false);
const [snapshotPopupOpen, setSnapshotPopupOpen] = createSignal(false);
const showVoiceSpaces = () => const showVoiceSpaces = () =>
props.schema.ui.show_voice_space_selector && props.schema.voice_spaces.length > 0; props.schema.ui.show_voice_space_selector && props.schema.voice_spaces.length > 0;
@ -49,6 +55,14 @@ export const ModeShell: Component<ModeShellProps> = (props) => {
label, label,
})); }));
const noiseRings = createMemo(() => {
// Outer = noiseCap, inner = noiseLevel — both as fractions of half-window.
const cap = explorationStore.state.noiseCap;
const cur = explorationStore.state.noiseLevel;
if (cap <= 0) return null;
return [cap * 0.4, cur * 0.4] as const;
});
return ( return (
<section class={styles.shell} aria-label={`${props.schema.mode_id} mode`}> <section class={styles.shell} aria-label={`${props.schema.mode_id} mode`}>
<header class={styles.header}> <header class={styles.header}>
@ -74,6 +88,28 @@ export const ModeShell: Component<ModeShellProps> = (props) => {
</Show> </Show>
<div class={styles.audioToggle}> <div class={styles.audioToggle}>
<Show
when={props.schema.ui.primary_input === 'audio_in'}
>
<Show
when={props.runtime.mic.started()}
fallback={
<button
type="button"
class={styles.audioBtn}
onClick={() => void props.runtime.mic.start()}
aria-label="Enable microphone"
>🎤 Mic</button>
}
>
<button
type="button"
class={`${styles.audioBtn} ${styles.on}`}
onClick={() => void props.runtime.mic.stop()}
aria-label="Disable microphone"
>🎤 Mic on</button>
</Show>
</Show>
<Show <Show
when={props.runtime.audio.started()} when={props.runtime.audio.started()}
fallback={ fallback={
@ -82,9 +118,7 @@ export const ModeShell: Component<ModeShellProps> = (props) => {
class={styles.audioBtn} class={styles.audioBtn}
onClick={() => void props.runtime.audio.start()} onClick={() => void props.runtime.audio.start()}
aria-label="Start audio engine" aria-label="Start audio engine"
> > Start audio</button>
Start audio
</button>
} }
> >
<button <button
@ -92,25 +126,37 @@ export const ModeShell: Component<ModeShellProps> = (props) => {
class={`${styles.audioBtn} ${styles.on}`} class={`${styles.audioBtn} ${styles.on}`}
onClick={() => void props.runtime.audio.stop()} onClick={() => void props.runtime.audio.stop()}
aria-label="Stop audio engine" aria-label="Stop audio engine"
> > Stop audio</button>
Stop audio
</button>
</Show>
<Show when={props.drawerContent}>
<button
type="button"
class={styles.drawerToggleBtn}
onClick={() => setDrawerOpen(true)}
aria-label="Open settings drawer"
>
</button>
</Show> </Show>
<button
type="button"
class={styles.drawerToggleBtn}
onClick={() => setDrawerOpen(true)}
aria-label="Open settings drawer"
></button>
</div> </div>
</header> </header>
<div class={styles.body}> <div class={styles.body}>
<div class={styles.primaryArea}>{props.primaryInput()}</div> <div class={styles.primaryArea}>
{props.primaryInput()}
<JoyMap
size={150}
zoom={() => inputStore.config.zoom}
anchor={() => [
inputStore.config.anchorMode === 'center' ? 0.5 : inputStore.config.anchorX,
inputStore.config.anchorMode === 'center' ? 0.5 : inputStore.config.anchorY,
]}
position={props.runtime.pipedInput}
trail={props.runtime.trail}
regionPins={() => sessionStore.state.regionPins}
noiseRings={noiseRings as () => readonly [number, number] | null}
frozen={props.runtime.frozen}
onTrailTap={(p) => props.runtime.snapToTrail(p)}
onLongPress={() => props.runtime.pinCurrentRegion()}
ariaLabel="Joy map navigator"
/>
</div>
<div class={styles.outputArea}>{props.outputArea()}</div> <div class={styles.outputArea}>{props.outputArea()}</div>
</div> </div>
@ -136,15 +182,24 @@ export const ModeShell: Component<ModeShellProps> = (props) => {
onThumbsUp={() => props.runtime.thumbsUp()} onThumbsUp={() => props.runtime.thumbsUp()}
onThumbsDown={() => props.runtime.thumbsDown()} onThumbsDown={() => props.runtime.thumbsDown()}
onUndo={() => { onUndo={() => {
// Stream 9 ships without undo wiring — no-op until session-store // Click = pop one snapshot. Long-press = open list popup.
// gets a snapshot/pop method exposed via the runtime. Stubbed props.runtime.undo();
// so the button still appears.
}} }}
exampleCount={() => props.runtime.training.examples()} exampleCount={() => props.runtime.training.examples()}
lastLoss={() => props.runtime.training.lastLoss()} lastLoss={() => props.runtime.training.lastLoss()}
busy={() => props.runtime.training.busy()} busy={() => props.runtime.training.busy()}
canUndo={() => false} canUndo={() => props.runtime.canUndo()}
/> />
<Show when={sessionStore.state.snapshots.length > 0}>
<button
type="button"
class={styles.drawerToggleBtn}
onClick={() => setSnapshotPopupOpen((b) => !b)}
aria-label="Show snapshot history"
title={`History (${sessionStore.state.snapshots.length})`}
style={{ 'margin-top': 'var(--sp-1)' }}
>📚 {sessionStore.state.snapshots.length}</button>
</Show>
</div> </div>
</div> </div>
@ -159,20 +214,72 @@ export const ModeShell: Component<ModeShellProps> = (props) => {
input ({props.runtime.pipedInput()[0].toFixed(2)}, input ({props.runtime.pipedInput()[0].toFixed(2)},
{' '} {' '}
{props.runtime.pipedInput()[1].toFixed(2)}) {props.runtime.pipedInput()[1].toFixed(2)})
{' · '}
noise {explorationStore.state.noiseLevel.toFixed(3)}
</span> </span>
</p> </p>
<Show when={props.drawerContent}> <Drawer
<Drawer open={drawerOpen()}
open={drawerOpen()} onClose={() => setDrawerOpen(false)}
onClose={() => setDrawerOpen(false)} side="right"
side="right" title={props.drawerTitle ?? 'Mode settings'}
title={props.drawerTitle ?? 'Mode settings'} width={460}
width={420} >
<Show
when={props.drawerContent}
fallback={
<SettingsDrawer schema={props.schema} runtime={props.runtime} />
}
> >
{props.drawerContent!()} {props.drawerContent!()}
</Drawer> </Show>
</Show> </Drawer>
{/* Snapshot list popup (long-press undo equivalent) */}
<Drawer
open={snapshotPopupOpen()}
onClose={() => setSnapshotPopupOpen(false)}
side="right"
title="Snapshot history"
width={320}
>
<ul style={{ 'list-style': 'none', padding: 0, margin: 0, display: 'flex', 'flex-direction': 'column', gap: 'var(--sp-1)' }}>
<For each={sessionStore.listSnapshots().slice().reverse()}>{(s) => (
<li>
<button
type="button"
onClick={() => {
const map = sessionStore.state.snapshots;
const idx = map.findIndex((m) => m.id === s.id);
if (idx < 0) return;
for (let i = map.length - 1; i > idx; i--) sessionStore.popSnapshot();
props.runtime.undo();
setSnapshotPopupOpen(false);
}}
style={{
display: 'flex',
'flex-direction': 'column',
width: '100%',
background: 'var(--bg-2)',
border: '1px solid var(--line)',
'border-radius': 'var(--r-1)',
padding: 'var(--sp-1) var(--sp-2)',
'text-align': 'left',
cursor: 'pointer',
color: 'var(--fg)',
}}
>
<span style={{ 'font-family': 'var(--font-mono)', 'font-size': 'var(--fs-sm)' }}>{s.tag}</span>
<span style={{ 'font-family': 'var(--font-mono)', 'font-size': 'var(--fs-xs)', color: 'var(--fg-mute)' }}>
noise {s.noiseLevel.toFixed(3)}
{s.zoomLevel !== null ? ` · zoom ${s.zoomLevel.toFixed(2)}` : ''}
</span>
</button>
</li>
)}</For>
</ul>
</Drawer>
</section> </section>
); );
}; };

View file

@ -11,9 +11,7 @@ import { ModeShell } from './ModeShell';
import { useModeRuntime } from './mode-runtime'; import { useModeRuntime } from './mode-runtime';
import { XYPad } from '../primitives/XYPad'; import { XYPad } from '../primitives/XYPad';
import { OutputDisplay } from '../primitives/OutputDisplay'; import { OutputDisplay } from '../primitives/OutputDisplay';
import { SliderBank } from '../primitives/SliderBank';
import { LossPlot } from '../primitives/LossPlot'; import { LossPlot } from '../primitives/LossPlot';
import { paramsToSliderConfig, outputsToSliderValues } from './mode-helpers';
import { PafSynthSchema } from './generated/paf_synth_schema'; import { PafSynthSchema } from './generated/paf_synth_schema';
export const PAFSynthMode: Component = () => { export const PAFSynthMode: Component = () => {
@ -21,8 +19,6 @@ export const PAFSynthMode: Component = () => {
const runtime = useModeRuntime(schema); const runtime = useModeRuntime(schema);
const [voiceSpace, setVoiceSpace] = createSignal(0); const [voiceSpace, setVoiceSpace] = createSignal(0);
const sliderConfig = paramsToSliderConfig(schema.params);
const sliderValues = () => outputsToSliderValues(runtime.processedOutputs(), schema.params);
return ( return (
<ModeShell <ModeShell
@ -30,18 +26,7 @@ export const PAFSynthMode: Component = () => {
runtime={runtime} runtime={runtime}
activeVoiceSpace={voiceSpace} activeVoiceSpace={voiceSpace}
onVoiceSpaceChange={setVoiceSpace} onVoiceSpaceChange={setVoiceSpace}
drawerTitle="PAF synth params" drawerTitle="PAF synth settings"
drawerContent={() => (
<SliderBank
title="Live parameter values"
sliders={sliderConfig}
values={sliderValues}
onChange={() => {
// Sliders are display-only here. Stream 10 wires the per-param
// override editor which writes through modeStore.setOverride.
}}
/>
)}
primaryInput={() => ( primaryInput={() => (
<> <>
<XYPad <XYPad
@ -59,7 +44,7 @@ export const PAFSynthMode: Component = () => {
outputArea={() => ( outputArea={() => (
<> <>
<OutputDisplay <OutputDisplay
values={runtime.processedOutputs} values={runtime.paramOutputs}
width={360} width={360}
height={120} height={120}
color="var(--accent)" color="var(--accent)"

View file

@ -0,0 +1,250 @@
.wrap {
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
.section {
border-top: 1px solid var(--line);
padding-top: var(--sp-2);
}
.section:first-of-type {
border-top: none;
padding-top: 0;
}
.sectionHeader {
display: flex;
align-items: center;
gap: var(--sp-2);
width: 100%;
background: transparent;
border: none;
padding: var(--sp-2) 0;
font-size: var(--fs-sm);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--fg-mute);
cursor: pointer;
text-align: left;
font-family: var(--font-mono);
}
.sectionHeader:hover {
color: var(--fg);
}
.caret {
color: var(--fg-dim);
font-size: var(--fs-xs);
width: 12px;
display: inline-block;
}
.badge {
margin-left: auto;
font-size: var(--fs-xs);
background: var(--bg-3);
color: var(--fg-mute);
padding: 1px 6px;
border-radius: var(--r-1);
font-family: var(--font-mono);
}
.body {
display: flex;
flex-direction: column;
gap: var(--sp-3);
padding: var(--sp-2) 0 var(--sp-3);
}
.row {
display: flex;
align-items: center;
gap: var(--sp-2);
flex-wrap: wrap;
}
.fieldLabel {
font-size: var(--fs-xs);
color: var(--fg-mute);
text-transform: uppercase;
letter-spacing: 0.06em;
min-width: 90px;
}
.checkbox {
display: flex;
align-items: center;
gap: var(--sp-1);
font-size: var(--fs-sm);
color: var(--fg);
cursor: pointer;
}
.subhead {
font-size: var(--fs-xs);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--fg-mute);
margin: var(--sp-2) 0 var(--sp-1);
}
.muted {
color: var(--fg-dim);
font-size: var(--fs-xs);
font-family: var(--font-mono);
}
.btnAlt {
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: var(--r-1);
padding: var(--sp-1) var(--sp-3);
font-size: var(--fs-sm);
color: var(--fg);
cursor: pointer;
}
.btnAlt:hover:not(:disabled) {
background: var(--bg-3);
}
.btnAlt:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btnTiny {
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: var(--r-1);
padding: 2px 8px;
font-size: var(--fs-xs);
color: var(--fg-mute);
cursor: pointer;
}
.btnTiny:hover {
color: var(--fg);
background: var(--bg-3);
}
.snapshotList,
.presetList {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 2px;
max-height: 220px;
overflow-y: auto;
}
.snapshotBtn {
display: flex;
flex-direction: column;
width: 100%;
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: var(--r-1);
padding: var(--sp-1) var(--sp-2);
text-align: left;
cursor: pointer;
color: var(--fg);
}
.snapshotBtn:hover {
background: var(--bg-3);
}
.snapshotTag {
font-size: var(--fs-sm);
font-family: var(--font-mono);
}
.snapshotMeta {
font-size: var(--fs-xs);
color: var(--fg-mute);
font-family: var(--font-mono);
}
.presetRow {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-1);
border-bottom: 1px dashed var(--line);
}
.presetRow > span {
flex: 1;
font-size: var(--fs-sm);
}
.paramList {
display: flex;
flex-direction: column;
gap: var(--sp-2);
max-height: 360px;
overflow-y: auto;
}
.layerStats {
display: flex;
flex-direction: column;
gap: var(--sp-1);
font-family: var(--font-mono);
font-size: var(--fs-xs);
}
.layerStatRow {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 2px var(--sp-2);
background: var(--bg-2);
border-radius: var(--r-1);
}
.layerStatLabel {
color: var(--accent);
font-weight: 600;
min-width: 24px;
}
.layerStatValue {
flex: 1;
color: var(--fg-mute);
}
.layerStatStatus {
font-size: 10px;
text-transform: uppercase;
padding: 1px 6px;
border-radius: var(--r-1);
background: var(--bg-3);
}
.layerStatStatus[data-status="dead"] { color: #999; }
.layerStatStatus[data-status="saturating"] { color: var(--warn); }
.layerStatStatus[data-status="healthy"] { color: var(--good); }
.textInput {
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: var(--r-1);
color: var(--fg);
font-family: var(--font-mono);
font-size: var(--fs-sm);
padding: var(--sp-1) var(--sp-2);
flex: 1;
}
.hr {
border: none;
border-top: 1px dashed var(--line);
margin: var(--sp-2) 0;
}

View file

@ -0,0 +1,656 @@
/**
* SettingsDrawer settings panel rendered inside ModeShell's drawer.
*
* Sections:
* - Input: deadzone, curve, smoothing, momentum, anchor mode, invert
* - Training: learning rate, weight decay
* - Exploration: spread, noise floor/cap/growth/decay,
* auto-explore (toggle, interval, intensity)
* - Output: global curve, smoothing, slew rate, freeze
* - Per-param overrides: ParamEditor list
* - Advanced: weight health histogram, gradient flow, session presets
*
* Sections are collapsible. Per-param-override editor renders inline (also
* accessible via double-tap on slider in the live readout wired in
* ModeShell). The drawer is a child of `ModeShell`'s `drawerContent` slot.
*/
import { Component, createSignal, For, Show, createMemo, createEffect } from 'solid-js';
import { ParamEditor, type ParamOverride as PrimitiveParamOverride } from '../primitives/ParamEditor';
import { Slider } from '../primitives/Slider';
import { PillToggle } from '../primitives/PillToggle';
import { WeightHealth } from '../primitives/WeightHealth';
import { GradientFlow } from '../primitives/GradientFlow';
import { Heatmap } from '../primitives/Heatmap';
import { ProgressRing } from '../primitives/ProgressRing';
import { LossPlot } from '../primitives/LossPlot';
import { inputStore } from '../stores/input-store';
import { outputStore } from '../stores/output-store';
import { explorationStore } from '../stores/exploration-store';
import { modeStore, defaultParamOverride, type ParamOverride } from '../stores/mode-store';
import { sessionStore } from '../stores/session-store';
import { mlStore } from '../stores/ml-store';
import { coreBus } from '../stores/bus';
import {
ZOOM_MIN,
ZOOM_MAX,
DEADZONE_MAX,
INPUT_CURVE_MIN,
INPUT_CURVE_MAX,
SMOOTHING_MAX,
} from '../input/pipeline';
import { GLOBAL_CURVE_MIN, GLOBAL_CURVE_MAX } from '../output/pipeline';
import {
layerNorms,
gradientStatuses,
weightHistogram,
weightStatus,
layerStatsToStatus,
layerWeightCounts,
} from '../features/weight-health';
import { saveNamedPreset, loadNamedPreset, buildShareUrl } from '../features/session-preset';
import { captureA, toggleAB, acceptB, revertToA } from '../features/snapshots';
import type { ModeRuntime } from './mode-runtime';
import type { ModeSchema } from './generated';
import type { Param } from './generated/types';
import type { CurveName } from '../output/curves';
import type { HeatmapColorMode } from '../features/heatmap-sampler';
import styles from './SettingsDrawer.module.css';
export interface SettingsDrawerProps {
schema: ModeSchema;
runtime: ModeRuntime;
/** Show advanced section (weight health, gradient flow, session presets). */
showAdvanced?: boolean;
}
interface Section {
id: string;
title: string;
defaultOpen?: boolean;
render: () => any;
}
export const SettingsDrawer: Component<SettingsDrawerProps> = (props) => {
const [open, setOpen] = createSignal<Record<string, boolean>>({
input: false,
training: false,
exploration: false,
output: false,
overrides: false,
advanced: false,
snapshots: false,
});
const toggle = (id: string) => setOpen((s) => ({ ...s, [id]: !s[id] }));
// ----- Per-param overrides UI ----------------------------------------
const overrides = () => modeStore.state.overrides[props.schema.mode_id] ?? {};
const getOverride = (param: Param): PrimitiveParamOverride => {
const cur = overrides()[param.name];
if (cur) {
return {
min: cur.min,
max: cur.max,
curve: cur.curve as CurveName,
curveParam: cur.curveParam,
muted: cur.muted,
pinned: cur.pinned,
fixedValue: cur.fixedValue,
};
}
return {
min: param.min,
max: param.max,
curve: param.curve as CurveName,
muted: false,
pinned: false,
fixedValue: param.default,
};
};
const writeOverride = (param: Param, next: PrimitiveParamOverride) => {
const stored: ParamOverride = {
min: next.min,
max: next.max,
curve: next.curve,
curveParam: next.curveParam,
muted: next.muted,
pinned: next.pinned,
fixedValue: next.fixedValue,
// Frozen flag is local to the runtime (set via output-store freezeMask)
// — keep it false in the persistent override unless the user explicitly
// toggles it.
frozen: overrides()[param.name]?.frozen ?? false,
};
modeStore.setOverride(props.schema.mode_id, param.name, stored);
};
// ----- Per-output health (advanced section) -----------------------------
const [advancedExpanded, setAdvancedExpanded] = createSignal(props.showAdvanced ?? false);
const histogram = createMemo<number[]>(() => {
const w = mlStore.weights();
return weightHistogram(w);
});
const status = createMemo(() => weightStatus(mlStore.weights()));
// Gradient flow: capture before/after weights on training events.
const [normHistory, setNormHistory] = createSignal<number[]>([]);
const [gradStatuses, setGradStatuses] = createSignal<ReturnType<typeof gradientStatuses>>([]);
let lastBeforeWeights: Float32Array | null = null;
const offTrainStart = coreBus.on('snap.push', (e) => {
if (e.tag === 'before train' && mlStore.iml) {
lastBeforeWeights = mlStore.getWeights();
}
});
const offTrainEnd = coreBus.on('ml.trained', () => {
if (lastBeforeWeights && mlStore.iml) {
const after = mlStore.getWeights();
const arch = mlStore.iml.architecture;
const sizes = layerWeightCounts({
inputSize: arch.inputSize,
hidden: arch.hidden,
outputSize: arch.outputSize,
});
const norms = layerNorms(lastBeforeWeights, after, sizes);
setNormHistory(norms);
setGradStatuses(gradientStatuses(norms));
lastBeforeWeights = null;
}
});
// Session-preset save / share UI state
const [presetName, setPresetName] = createSignal('');
const [shareUrl, setShareUrl] = createSignal('');
// Snapshot list (long-press-undo equivalent)
const snapshots = () => sessionStore.listSnapshots();
// Cleanup on unmount (Solid handles via owner; we only need to detach bus
// subs explicitly because they live across renders).
// We can't use onCleanup here because this component is recreated per-mode,
// but bus.on returns an unsubscribe. Defer cleanup to when the component
// unmounts via createEffect cleanup function:
createEffect(() => {
return () => { offTrainStart(); offTrainEnd(); };
});
// ----- A/B compare visibility ------------------------------------------
const ab = () => sessionStore.state.ab;
const live = () => ab().live;
// ---------------------------------------------------------------------
// Render helpers
// ---------------------------------------------------------------------
const SectionHeader = (h: { id: string; title: string; count?: number }) => (
<button
type="button"
class={styles.sectionHeader}
onClick={() => toggle(h.id)}
aria-expanded={!!open()[h.id]}
>
<span class={styles.caret}>{open()[h.id] ? '▾' : '▸'}</span>
<span>{h.title}</span>
<Show when={h.count !== undefined}><span class={styles.badge}>{h.count}</span></Show>
</button>
);
return (
<div class={styles.wrap}>
{/* Snapshots / undo / A/B */}
<section class={styles.section}>
<SectionHeader id="snapshots" title="History (snapshots, A/B)" count={snapshots().length} />
<Show when={open().snapshots}>
<div class={styles.body}>
<div class={styles.row}>
<button
type="button"
class={styles.btnAlt}
disabled={!props.runtime.canUndo()}
onClick={() => props.runtime.undo()}
>Undo last</button>
<button
type="button"
class={styles.btnAlt}
onClick={() => sessionStore.clearSnapshots()}
>Clear stack</button>
</div>
<Show when={snapshots().length > 0}>
<ul class={styles.snapshotList}>
<For each={snapshots().slice().reverse()}>{(s) => (
<li>
<button
type="button"
class={styles.snapshotBtn}
onClick={() => {
const ok = props.runtime.undo();
if (!ok) {
// Restore by id instead.
const map = sessionStore.state.snapshots;
const idx = map.findIndex((m) => m.id === s.id);
if (idx >= 0) {
// walk back N times to make it the top of the stack
for (let i = map.length - 1; i > idx; i--) sessionStore.popSnapshot();
props.runtime.undo();
}
}
}}
>
<span class={styles.snapshotTag}>{s.tag}</span>
<span class={styles.snapshotMeta}>
noise {s.noiseLevel.toFixed(3)}
{s.zoomLevel !== null ? ` · zoom ${s.zoomLevel.toFixed(2)}` : ''}
</span>
</button>
</li>
)}</For>
</ul>
</Show>
<hr class={styles.hr} />
<h4 class={styles.subhead}>A / B compare</h4>
<div class={styles.row}>
<Show when={!ab().a} fallback={
<span class={styles.muted}>active: <strong>{live()}</strong></span>
}>
<span class={styles.muted}>no capture yet</span>
</Show>
</div>
<div class={styles.row}>
<button
type="button"
class={styles.btnAlt}
onClick={() => captureA()}
>Capture A</button>
<button
type="button"
class={styles.btnAlt}
disabled={!ab().a}
onClick={() => toggleAB()}
>Toggle A/B</button>
<button
type="button"
class={styles.btnAlt}
disabled={!ab().a}
onClick={() => acceptB()}
>Accept B</button>
<button
type="button"
class={styles.btnAlt}
disabled={!ab().a}
onClick={() => revertToA()}
>Revert to A</button>
</div>
</div>
</Show>
</section>
{/* Input pipeline */}
<section class={styles.section}>
<SectionHeader id="input" title="Input pipeline" />
<Show when={open().input}>
<div class={styles.body}>
<Slider
label="Zoom" min={ZOOM_MIN} max={ZOOM_MAX}
value={inputStore.config.zoom}
onChange={(v) => inputStore.setZoom(v)}
/>
<Slider
label="Deadzone" min={0} max={DEADZONE_MAX}
value={inputStore.config.deadzone}
onChange={(v) => inputStore.setDeadzone(v)}
/>
<Slider
label="Input curve" min={INPUT_CURVE_MIN} max={INPUT_CURVE_MAX}
value={inputStore.config.inputCurve}
onChange={(v) => inputStore.setInputCurve(v)}
/>
<Slider
label="Smoothing" min={0} max={SMOOTHING_MAX}
value={inputStore.config.smoothing}
onChange={(v) => inputStore.setSmoothing(v)}
/>
<div class={styles.row}>
<label class={styles.fieldLabel}>Anchor mode</label>
<PillToggle
options={[
{ value: 'auto', label: 'Auto' },
{ value: 'sticky', label: 'Sticky' },
{ value: 'center', label: 'Center' },
]}
value={() => inputStore.config.anchorMode}
onChange={(v) => inputStore.setAnchorMode(v as 'auto' | 'sticky' | 'center')}
ariaLabel="Anchor mode"
/>
</div>
<div class={styles.row}>
<label class={styles.fieldLabel}>Momentum zoom</label>
<PillToggle
options={[
{ value: 'off', label: 'Off' },
{ value: 'gentle', label: 'Gentle' },
{ value: 'strong', label: 'Strong' },
]}
value={() => inputStore.config.momentumZoom}
onChange={(v) => inputStore.setMomentumZoom(v as 'off' | 'gentle' | 'strong')}
ariaLabel="Momentum zoom"
/>
</div>
<div class={styles.row}>
<label class={styles.checkbox}>
<input
type="checkbox"
checked={inputStore.config.invertX}
onChange={(e) => inputStore.setInvert(e.currentTarget.checked, inputStore.config.invertY)}
/>
Invert X
</label>
<label class={styles.checkbox}>
<input
type="checkbox"
checked={inputStore.config.invertY}
onChange={(e) => inputStore.setInvert(inputStore.config.invertX, e.currentTarget.checked)}
/>
Invert Y
</label>
</div>
</div>
</Show>
</section>
{/* Training */}
<section class={styles.section}>
<SectionHeader id="training" title="Training" />
<Show when={open().training}>
<div class={styles.body}>
<Slider
label="Learning rate" min={0.01} max={5}
value={explorationStore.state.learningRate}
onChange={(v) => explorationStore.setLearningRate(v)}
/>
<Slider
label="Weight decay" min={0} max={0.3}
value={explorationStore.state.weightDecay}
onChange={(v) => explorationStore.setWeightDecay(v)}
/>
<LossPlot history={() => mlStore.state.lossHistory} width={320} height={70} />
</div>
</Show>
</section>
{/* Exploration */}
<section class={styles.section}>
<SectionHeader id="exploration" title="Exploration / RL" />
<Show when={open().exploration}>
<div class={styles.body}>
<Slider
label="Spread" min={0} max={1}
value={explorationStore.state.spread}
onChange={(v) => explorationStore.setSpread(v)}
/>
<Slider
label="Noise floor" min={0} max={0.5}
value={explorationStore.state.noiseFloor}
onChange={(v) => explorationStore.setNoiseFloor(v)}
/>
<Slider
label="Noise cap" min={explorationStore.state.noiseFloor} max={1}
value={explorationStore.state.noiseCap}
onChange={(v) => explorationStore.setNoiseCap(v)}
/>
<Slider
label="Noise growth" min={1} max={3}
value={explorationStore.state.noiseGrowth}
onChange={(v) => explorationStore.setNoiseGrowth(v)}
/>
<Slider
label="Noise decay" min={0.7} max={1}
value={explorationStore.state.noiseDecay}
onChange={(v) => explorationStore.setNoiseDecay(v)}
/>
<hr class={styles.hr} />
<h4 class={styles.subhead}>Auto-explore</h4>
<div class={styles.row}>
<label class={styles.checkbox}>
<input
type="checkbox"
checked={explorationStore.state.autoExploreEnabled}
onChange={(e) => explorationStore.setAutoExplore(e.currentTarget.checked)}
/>
Enable auto-explore
</label>
<Show when={explorationStore.state.autoExploreEnabled}>
<ProgressRing
size={28}
showLabel={false}
progress={() => 1}
ariaLabel="Auto-explore active"
/>
</Show>
</div>
<Slider
label="Interval (ms)" min={500} max={10000} step={100}
value={explorationStore.state.autoExploreIntervalMs}
onChange={(v) => explorationStore.setAutoExploreInterval(v)}
/>
<Slider
label="Intensity" min={0.1} max={1}
value={explorationStore.state.autoExploreIntensity}
onChange={(v) => explorationStore.setAutoExploreIntensity(v)}
/>
</div>
</Show>
</section>
{/* Output */}
<section class={styles.section}>
<SectionHeader id="output" title="Output pipeline" />
<Show when={open().output}>
<div class={styles.body}>
<Slider
label="Global curve" min={GLOBAL_CURVE_MIN} max={GLOBAL_CURVE_MAX}
value={outputStore.config.globalCurve}
onChange={(v) => outputStore.setGlobalCurve(v)}
/>
<Slider
label="Output smoothing" min={0} max={0.95}
value={outputStore.config.smoothing}
onChange={(v) => outputStore.setSmoothing(v)}
/>
<Slider
label="Slew rate (per sec)" min={0.005} max={1} step={0.005}
value={isFinite(outputStore.config.slewRate) ? outputStore.config.slewRate : 1}
onChange={(v) => outputStore.setSlewRate(v)}
/>
<label class={styles.checkbox}>
<input
type="checkbox"
checked={outputStore.config.freezeOutput}
onChange={(e) => outputStore.setFreezeOutput(e.currentTarget.checked)}
/>
Freeze output
</label>
</div>
</Show>
</section>
{/* Per-param overrides */}
<section class={styles.section}>
<SectionHeader
id="overrides"
title="Parameter overrides"
count={Object.keys(overrides()).length}
/>
<Show when={open().overrides}>
<div class={styles.body}>
<div class={styles.row}>
<button
type="button"
class={styles.btnAlt}
onClick={() => modeStore.clearAllOverrides(props.schema.mode_id)}
>Clear all</button>
</div>
<div class={styles.paramList}>
<For each={props.schema.params}>{(p) => (
<ParamEditor
param={{
name: p.name,
label: p.label,
hardMin: p.min,
hardMax: p.max,
default: p.default,
curve: p.curve as CurveName,
group: p.group,
}}
override={() => getOverride(p)}
onChange={(next) => writeOverride(p, next)}
compact
/>
)}</For>
</div>
</div>
</Show>
</section>
{/* Advanced: weight health, gradient flow, heatmap */}
<section class={styles.section}>
<SectionHeader id="advanced" title="Advanced" />
<Show when={open().advanced}>
<div class={styles.body}>
<button
type="button"
class={styles.btnAlt}
onClick={() => setAdvancedExpanded((b) => !b)}
>{advancedExpanded() ? 'Hide diagnostics' : 'Show diagnostics'}</button>
<Show when={advancedExpanded()}>
<h4 class={styles.subhead}>Weight health</h4>
<WeightHealth
histogram={histogram}
status={status}
width={300}
height={60}
/>
<h4 class={styles.subhead}>Gradient flow (last train)</h4>
<Show
when={normHistory().length > 0}
fallback={<p class={styles.muted}>No training run yet.</p>}
>
<GradientFlow
layerNorms={normHistory}
status={gradStatuses}
width={300}
height={70}
/>
</Show>
<h4 class={styles.subhead}>Per-layer stats</h4>
<div class={styles.layerStats}>
<For each={mlStore.getLayerStatsRecords()}>{(stats, idx) => (
<div class={styles.layerStatRow}>
<span class={styles.layerStatLabel}>L{idx()}</span>
<span class={styles.layerStatValue}>
mean|w| {stats.meanAbs.toFixed(3)}
· max|w| {stats.maxAbs.toFixed(3)}
· {(stats.deadFrac * 100).toFixed(0)}% dead
· {(stats.saturatingFrac * 100).toFixed(0)}% sat
</span>
<span
class={styles.layerStatStatus}
data-status={layerStatsToStatus(stats)}
>{layerStatsToStatus(stats)}</span>
</div>
)}</For>
</div>
<h4 class={styles.subhead}>Input heatmap</h4>
<div class={styles.row}>
<label class={styles.fieldLabel}>Color</label>
<PillToggle
options={[
{ value: 'luminance', label: 'Lumi' },
{ value: 'variance', label: 'Var' },
{ value: 'divergence', label: 'Div' },
]}
value={() => props.runtime.heatmap.colorMode()}
onChange={(v) => props.runtime.heatmap.setColorMode(v as HeatmapColorMode)}
ariaLabel="Heatmap color mode"
/>
<button
type="button"
class={styles.btnAlt}
onClick={() => props.runtime.heatmap.refresh(true)}
>Refresh</button>
</div>
<Heatmap
samples={props.runtime.heatmap.cells}
resolution={props.runtime.heatmap.resolution()}
colorMode={props.runtime.heatmap.colorMode() as 'luminance' | 'variance' | 'divergence'}
size={240}
/>
</Show>
<hr class={styles.hr} />
<h4 class={styles.subhead}>Session preset</h4>
<div class={styles.row}>
<input
class={styles.textInput}
placeholder="preset name"
value={presetName()}
onInput={(e) => setPresetName(e.currentTarget.value)}
/>
<button
type="button"
class={styles.btnAlt}
disabled={!presetName().trim()}
onClick={() => {
const n = presetName().trim();
if (!n) return;
saveNamedPreset(n, false);
setPresetName('');
}}
>Save</button>
</div>
<Show when={sessionStore.state.presets.length > 0}>
<ul class={styles.presetList}>
<For each={sessionStore.state.presets}>{(p) => (
<li class={styles.presetRow}>
<span>{p.name}</span>
<button
type="button"
class={styles.btnTiny}
onClick={() => loadNamedPreset(p.id)}
>Load</button>
<button
type="button"
class={styles.btnTiny}
onClick={() => sessionStore.removePreset(p.id)}
>Del</button>
</li>
)}</For>
</ul>
</Show>
<div class={styles.row}>
<button
type="button"
class={styles.btnAlt}
onClick={() => {
setShareUrl(window.location.origin + window.location.pathname + buildShareUrl());
}}
>Build share URL</button>
<Show when={shareUrl()}>
<input
class={styles.textInput}
readOnly
value={shareUrl()}
onClick={(e) => e.currentTarget.select()}
/>
</Show>
</div>
</div>
</Show>
</section>
</div>
);
};
export default SettingsDrawer;

View file

@ -2,10 +2,15 @@
* SoundAnalysisMIDIMode sound analysis MIDI CC output (audio_in input). * SoundAnalysisMIDIMode sound analysis MIDI CC output (audio_in input).
* *
* Schema declares `primary_input: 'audio_in'` and `engine_id: 'thru'` (no * Schema declares `primary_input: 'audio_in'` and `engine_id: 'thru'` (no
* synthesis). The full firmware pipeline feeds audio analysis features * synthesis). Stream 10 wires the mic into channels 2..5 via ModeShell's
* (pitch / aperiodicity / energy / brightness / etc.) into the first 6 * Mic button + the runtime's MicInput. The joystick is used as a fallback
* input channels and joystick coords into the last 4. Stream 9 ships a * for channels 0..1 and during testing without mic permissions.
* scaffold UI; mic capture + analysis wiring is a stream-10 task. *
* MIDI output routing is read-only here the SettingsDrawer's per-param
* override editor lets users tighten the active range / mute params; the
* actual MIDI device wiring is left for the engine host to handle (or for
* a future stream that adds WebMIDI sender). For now CC values are
* visible in the live OutputDisplay.
*/ */
import { Component, Show } from 'solid-js'; import { Component, Show } from 'solid-js';
@ -13,44 +18,18 @@ import { ModeShell } from './ModeShell';
import { useModeRuntime } from './mode-runtime'; import { useModeRuntime } from './mode-runtime';
import { VirtualJoystick } from '../primitives/VirtualJoystick'; import { VirtualJoystick } from '../primitives/VirtualJoystick';
import { OutputDisplay } from '../primitives/OutputDisplay'; import { OutputDisplay } from '../primitives/OutputDisplay';
import { SliderBank } from '../primitives/SliderBank';
import { LossPlot } from '../primitives/LossPlot'; import { LossPlot } from '../primitives/LossPlot';
import { paramsToSliderConfig, outputsToSliderValues } from './mode-helpers';
import { SoundAnalysisMidiSchema } from './generated/sound_analysis_midi_schema'; import { SoundAnalysisMidiSchema } from './generated/sound_analysis_midi_schema';
export const SoundAnalysisMIDIMode: Component = () => { export const SoundAnalysisMIDIMode: Component = () => {
const schema = SoundAnalysisMidiSchema; const schema = SoundAnalysisMidiSchema;
const runtime = useModeRuntime(schema); const runtime = useModeRuntime(schema);
const sliderConfig = paramsToSliderConfig(schema.params);
const sliderValues = () => outputsToSliderValues(runtime.processedOutputs(), schema.params);
return ( return (
<ModeShell <ModeShell
schema={schema} schema={schema}
runtime={runtime} runtime={runtime}
drawerTitle="MIDI CC routing" drawerTitle="Sound analysis → MIDI settings"
drawerContent={() => (
<div>
<SliderBank
title="MIDI CC values"
sliders={sliderConfig}
values={sliderValues}
onChange={() => {
/* read-only */
}}
/>
<p
style={{
'font-size': 'var(--fs-xs)',
color: 'var(--fg-mute)',
'margin-top': 'var(--sp-3)',
}}
>
WebMIDI routing is wired up in stream 10. For now CC values are
visible in the live readout.
</p>
</div>
)}
primaryInput={() => ( primaryInput={() => (
<> <>
<Show <Show
@ -72,7 +51,7 @@ export const SoundAnalysisMIDIMode: Component = () => {
gap: 'var(--sp-3)', gap: 'var(--sp-3)',
padding: 'var(--sp-4)', padding: 'var(--sp-4)',
background: 'var(--bg-2)', background: 'var(--bg-2)',
border: '1px dashed var(--line-strong)', border: `1px ${runtime.mic.started() ? 'solid var(--accent-2)' : 'dashed var(--line-strong)'}`,
'border-radius': 'var(--r-2)', 'border-radius': 'var(--r-2)',
'min-width': '260px', 'min-width': '260px',
'min-height': '200px', 'min-height': '200px',
@ -81,11 +60,13 @@ export const SoundAnalysisMIDIMode: Component = () => {
'text-align': 'center', 'text-align': 'center',
}} }}
> >
<strong style={{ color: 'var(--accent-2)' }}>Mic input TODO</strong> <strong style={{ color: runtime.mic.started() ? 'var(--accent-2)' : 'var(--fg-mute)' }}>
{runtime.mic.started() ? '🎤 Mic active' : 'Mic input'}
</strong>
<span style={{ 'font-size': 'var(--fs-xs)' }}> <span style={{ 'font-size': 'var(--fs-xs)' }}>
Stream 10 will request `getUserMedia` and feed audio analysis {runtime.mic.started()
features into the MLP. For now you can still drive the model ? 'Audio features → channels 2..5. Joystick below drives channels 0..1.'
manually with the joystick below. : 'Tap "Mic" in the header to feed mic features into the model.'}
</span> </span>
<VirtualJoystick <VirtualJoystick
size={200} size={200}
@ -100,7 +81,7 @@ export const SoundAnalysisMIDIMode: Component = () => {
outputArea={() => ( outputArea={() => (
<> <>
<OutputDisplay <OutputDisplay
values={runtime.processedOutputs} values={runtime.paramOutputs}
width={360} width={360}
height={120} height={120}
color="var(--info)" color="var(--info)"

View file

@ -7,17 +7,13 @@ import { ModeShell } from './ModeShell';
import { useModeRuntime } from './mode-runtime'; import { useModeRuntime } from './mode-runtime';
import { VirtualJoystick } from '../primitives/VirtualJoystick'; import { VirtualJoystick } from '../primitives/VirtualJoystick';
import { OutputDisplay } from '../primitives/OutputDisplay'; import { OutputDisplay } from '../primitives/OutputDisplay';
import { SliderBank } from '../primitives/SliderBank';
import { LossPlot } from '../primitives/LossPlot'; import { LossPlot } from '../primitives/LossPlot';
import { paramsToSliderConfig, outputsToSliderValues } from './mode-helpers';
import { VerbFxSchema } from './generated/verb_fx_schema'; import { VerbFxSchema } from './generated/verb_fx_schema';
export const VerbFXMode: Component = () => { export const VerbFXMode: Component = () => {
const schema = VerbFxSchema; const schema = VerbFxSchema;
const runtime = useModeRuntime(schema); const runtime = useModeRuntime(schema);
const [voiceSpace, setVoiceSpace] = createSignal(0); const [voiceSpace, setVoiceSpace] = createSignal(0);
const sliderConfig = paramsToSliderConfig(schema.params);
const sliderValues = () => outputsToSliderValues(runtime.processedOutputs(), schema.params);
return ( return (
<ModeShell <ModeShell
@ -25,17 +21,7 @@ export const VerbFXMode: Component = () => {
runtime={runtime} runtime={runtime}
activeVoiceSpace={voiceSpace} activeVoiceSpace={voiceSpace}
onVoiceSpaceChange={setVoiceSpace} onVoiceSpaceChange={setVoiceSpace}
drawerTitle="Verb / FX params" drawerTitle="Verb / FX settings"
drawerContent={() => (
<SliderBank
title="Live FX params"
sliders={sliderConfig}
values={sliderValues}
onChange={() => {
/* read-only */
}}
/>
)}
primaryInput={() => ( primaryInput={() => (
<> <>
<VirtualJoystick <VirtualJoystick
@ -53,7 +39,7 @@ export const VerbFXMode: Component = () => {
outputArea={() => ( outputArea={() => (
<> <>
<OutputDisplay <OutputDisplay
values={runtime.processedOutputs} values={runtime.paramOutputs}
width={360} width={360}
height={120} height={120}
color="#b464ff" color="#b464ff"

View file

@ -1,10 +1,9 @@
/** /**
* XIASRIMode audio-reactive verb / pitch effects driven by joystick. * XIASRIMode audio-reactive verb / pitch effects.
* *
* Although the firmware variant historically used audio analysis as input, * Schema declares `primary_input: 'joystick'` and feeds the MLP from
* the playground schema declares `primary_input: 'joystick'` and feeds the * joy_x/joy_y plus optional mic features. ModeShell exposes a Mic toggle
* MLP from joy_x/joy_y/joy_z/joy_w. The audio-reactive flavour is left to * that opt-in feeds analysis features into channels 2..5.
* stream 10 (mic input wiring).
*/ */
import { Component } from 'solid-js'; import { Component } from 'solid-js';
@ -12,32 +11,18 @@ import { ModeShell } from './ModeShell';
import { useModeRuntime } from './mode-runtime'; import { useModeRuntime } from './mode-runtime';
import { VirtualJoystick } from '../primitives/VirtualJoystick'; import { VirtualJoystick } from '../primitives/VirtualJoystick';
import { OutputDisplay } from '../primitives/OutputDisplay'; import { OutputDisplay } from '../primitives/OutputDisplay';
import { SliderBank } from '../primitives/SliderBank';
import { LossPlot } from '../primitives/LossPlot'; import { LossPlot } from '../primitives/LossPlot';
import { paramsToSliderConfig, outputsToSliderValues } from './mode-helpers';
import { XiasriSchema } from './generated/xiasri_schema'; import { XiasriSchema } from './generated/xiasri_schema';
export const XIASRIMode: Component = () => { export const XIASRIMode: Component = () => {
const schema = XiasriSchema; const schema = XiasriSchema;
const runtime = useModeRuntime(schema); const runtime = useModeRuntime(schema);
const sliderConfig = paramsToSliderConfig(schema.params);
const sliderValues = () => outputsToSliderValues(runtime.processedOutputs(), schema.params);
return ( return (
<ModeShell <ModeShell
schema={schema} schema={schema}
runtime={runtime} runtime={runtime}
drawerTitle="XIASRI params" drawerTitle="XIASRI settings"
drawerContent={() => (
<SliderBank
title="Live verb / pitch params"
sliders={sliderConfig}
values={sliderValues}
onChange={() => {
/* read-only */
}}
/>
)}
primaryInput={() => ( primaryInput={() => (
<> <>
<VirtualJoystick <VirtualJoystick
@ -47,14 +32,14 @@ export const XIASRIMode: Component = () => {
position={runtime.pipedInput} position={runtime.pipedInput}
/> />
<span style={{ 'font-size': 'var(--fs-xs)', color: 'var(--fg-mute)' }}> <span style={{ 'font-size': 'var(--fs-xs)', color: 'var(--fg-mute)' }}>
Joystick verb / pitch space. Mic input wiring is a stream-10 task. Joystick verb / pitch space. Enable mic for audio-reactive features.
</span> </span>
</> </>
)} )}
outputArea={() => ( outputArea={() => (
<> <>
<OutputDisplay <OutputDisplay
values={runtime.processedOutputs} values={runtime.paramOutputs}
width={360} width={360}
height={120} height={120}
color="var(--accent-2)" color="var(--accent-2)"

View file

@ -31,16 +31,28 @@ export function paramsToSliderConfig(params: ReadonlyArray<Param>): SliderConfig
/** /**
* Map a Float32Array (length = N) of normalized values [0,1] to a flat * Map a Float32Array (length = N) of normalized values [0,1] to a flat
* array sized to match the slider config (already in min/max range). * array sized to match the slider config (already in min/max range).
*
* If `overrides` is supplied, per-param min/max overrides are honoured
* instead of the schema defaults this is what the mode UI sliders
* display when the user has tightened the active range.
*/ */
export function outputsToSliderValues( export function outputsToSliderValues(
outputs: Float32Array, outputs: Float32Array,
params: ReadonlyArray<Param>, params: ReadonlyArray<Param>,
overrides?: Record<string, { min: number; max: number; muted?: boolean; fixedValue?: number }>,
): number[] { ): number[] {
const out: number[] = []; const out: number[] = [];
for (let i = 0; i < params.length; ++i) { for (let i = 0; i < params.length; ++i) {
const v = outputs[i] ?? 0; const v = outputs[i] ?? 0;
const p = params[i]!; const p = params[i]!;
out.push(p.min + v * (p.max - p.min)); const ov = overrides?.[p.name];
if (ov && ov.muted && ov.fixedValue !== undefined) {
out.push(ov.fixedValue);
continue;
}
const min = ov?.min ?? p.min;
const max = ov?.max ?? p.max;
out.push(min + v * (max - min));
} }
return out; return out;
} }

View file

@ -6,23 +6,30 @@
* 1. Hold a primary 2D input position (joystick / xy-pad / external feed). * 1. Hold a primary 2D input position (joystick / xy-pad / external feed).
* 2. Push it through the input pipeline. * 2. Push it through the input pipeline.
* 3. Forward the processed (x, y) to the WASM MLP as input channels [0..N]. * 3. Forward the processed (x, y) to the WASM MLP as input channels [0..N].
* Modes with input_size > 2 zero-pad the unused channels. * Modes with input_size > 2 zero-pad the unused channels (or the mic
* analyser fills 0..3 if active).
* 4. Pull the WASM outputs (Float32Array of 126), slice to the schema's * 4. Pull the WASM outputs (Float32Array of 126), slice to the schema's
* `output_size`, and run them through the output pipeline. * `output_size`, and run them through the output pipeline.
* 5. Throttle + ship the processed slice to the AudioWorklet engine. * 5. Apply per-param overrides (modeStore.activeOverrides).
* 6. Throttle + ship the processed slice to the AudioWorklet engine.
* *
* To keep mode TSX files small and consistent, this module exposes a hook * Stream 10 wires:
* `useModeRuntime(schema)` that owns the lifecycle and exposes reactive * - Compound axes underlying stores (input/output/exploration).
* accessors plus the `setInput(x, y)` driver. Modes only have to render a * - Per-param overrides between MLP outputs and engine.
* primary input that calls `runtime.setInput(x, y)` and the runtime takes * - Snapshot + undo + A/B compare on RL events.
* care of everything downstream. * - Pin mask passed to moveWeights.
* - Auto-explore interval timer.
* - Trail tracking for JoyMap.
* - Heatmap sampler invalidated on weight-change events.
* - Mic features pushed into MLP for audio-input modes.
*/ */
import { createEffect, createSignal, onCleanup, onMount } from 'solid-js'; import { batch, createEffect, createMemo, createSignal, onCleanup, onMount, untrack } from 'solid-js';
import { mlStore, modeStore, controlStore } from '../stores'; import { mlStore, modeStore, controlStore, sessionStore, explorationStore } from '../stores';
import { inputStore } from '../stores/input-store'; import { inputStore } from '../stores/input-store';
import { outputStore } from '../stores/output-store'; import { outputStore } from '../stores/output-store';
import { coreBus } from '../stores/bus';
import { processInput, defaultInputState, type InputState } from '../input/pipeline'; import { processInput, defaultInputState, type InputState } from '../input/pipeline';
import { import {
processOutput, processOutput,
@ -33,6 +40,13 @@ import { EngineHost } from '../audio/engine-host';
import type { EngineId } from '../ml/types'; import type { EngineId } from '../ml/types';
import type { ModeSchema } from './generated'; import type { ModeSchema } from './generated';
import { applyOverrides, buildPinMask } from '../features/overrides';
import { applyControlRouting } from '../features/control-routing';
import { createTrailRing, type TrailPoint } from '../features/trail';
import { autoSnapshot, undoLastSnapshot } from '../features/snapshots';
import { HeatmapSampler, type HeatmapColorMode } from '../features/heatmap-sampler';
import { MicInput } from '../features/mic-input';
/** /**
* Throttle interval for engine param updates (ms). 50ms 20fps which * Throttle interval for engine param updates (ms). 50ms 20fps which
* matches the legacy playground's C15 update cadence. * matches the legacy playground's C15 update cadence.
@ -62,6 +76,12 @@ export interface ModeRuntime {
/** Output-sliced + pipeline-processed vector (length = schema.output_size). */ /** Output-sliced + pipeline-processed vector (length = schema.output_size). */
processedOutputs: () => Float32Array; processedOutputs: () => Float32Array;
/**
* Final per-param values after override application (length = params.length).
* These are what get shipped to the engine and visualised in sliders.
*/
paramOutputs: () => Float32Array;
/** True iff WASM has loaded and the MLP is ready. */ /** True iff WASM has loaded and the MLP is ready. */
ready: () => boolean; ready: () => boolean;
@ -73,6 +93,13 @@ export interface ModeRuntime {
setMuted: (muted: boolean) => void; setMuted: (muted: boolean) => void;
}; };
/** Mic input control (for audio_in modes; safe to call on others — no-op). */
mic: {
started: () => boolean;
start: () => Promise<void>;
stop: () => Promise<void>;
};
/** Loss / training plumbing surfaced from mlStore. */ /** Loss / training plumbing surfaced from mlStore. */
training: { training: {
busy: () => boolean; busy: () => boolean;
@ -88,6 +115,28 @@ export interface ModeRuntime {
thumbsUp: () => void; thumbsUp: () => void;
thumbsDown: () => void; thumbsDown: () => void;
randomize: () => void; randomize: () => void;
/** Pop snapshot stack and restore weights. Returns true on success. */
undo: () => boolean;
/** Whether the snapshot stack has anything to undo. */
canUndo: () => boolean;
/** Trail ring for JoyMap binding. */
trail: () => ReadonlyArray<TrailPoint>;
/** Snap input back to a trail point. */
snapToTrail: (p: { x: number; y: number }) => void;
/** Heatmap sampler. Returns the underlying cells; modes pass to <Heatmap>. */
heatmap: {
cells: () => Float32Array;
setColorMode: (m: HeatmapColorMode) => void;
colorMode: () => HeatmapColorMode;
refresh: (force?: boolean) => void;
resolution: () => number;
};
/** Region pin: pin the current zoom window (long-press handler). */
pinCurrentRegion: () => void;
} }
interface RuntimeOptions { interface RuntimeOptions {
@ -124,12 +173,33 @@ export function useModeRuntime(
modeStore.switchMode(schema.mode_id); modeStore.switchMode(schema.mode_id);
} }
// ----- Compound axis routing -------------------------------------------
// Whenever any axis or offset changes, re-derive the underlying store
// values. Track axis reads explicitly; do the writes inside untrack so
// we don't pick up stale dependencies on write-targets (input/output/
// exploration stores).
createEffect(() => {
void controlStore.state.boldness;
void controlStore.state.memory;
void controlStore.state.precision;
void controlStore.state.offsets.boldness;
void controlStore.state.offsets.memory;
void controlStore.state.offsets.precision;
untrack(() => applyControlRouting());
});
// ----- Input pipeline state -------------------------------------------- // ----- Input pipeline state --------------------------------------------
const [pipedInput, setPipedInput] = createSignal<readonly [number, number]>([0.5, 0.5]); const [pipedInput, setPipedInput] = createSignal<readonly [number, number]>([0.5, 0.5]);
const [frozen, setFrozen] = createSignal(false); const [frozen, setFrozen] = createSignal(false);
let inputState: InputState = defaultInputState(); let inputState: InputState = defaultInputState();
let lastFrameMs = performance.now(); let lastFrameMs = performance.now();
// Trail ring
const trailRing = createTrailRing();
// Pressure tracking (set externally via setPressure on touch).
let pressDownAt: number | null = null;
const setInput = (rawX: number, rawY: number): void => { const setInput = (rawX: number, rawY: number): void => {
const now = performance.now(); const now = performance.now();
const dt = Math.max(0.001, (now - lastFrameMs) / 1000); const dt = Math.max(0.001, (now - lastFrameMs) / 1000);
@ -140,24 +210,47 @@ export function useModeRuntime(
inputStore.__setLiveState(result.state); inputStore.__setLiveState(result.state);
setPipedInput([result.x, result.y]); setPipedInput([result.x, result.y]);
setFrozen(result.frozen); setFrozen(result.frozen);
trailRing.push(result.x, result.y);
if (!ready()) return; if (!ready()) return;
// Push input to the MLP. Channels beyond [x,y] are zeroed out — modes // Push input to the MLP. Channels beyond [x,y] are zeroed out (or
// with input_size > 2 currently aren't fed extra inputs (audio analysis // overridden with mic features in audio-input modes).
// wiring is a stream-10 task).
const inSz = schema.ml.input_size; const inSz = schema.ml.input_size;
mlStore.setInput(0, result.x); mlStore.setInput(0, result.x);
if (inSz > 1) mlStore.setInput(1, result.y); if (inSz > 1) mlStore.setInput(1, result.y);
for (let i = 2; i < inSz; ++i) mlStore.setInput(i, 0); // Mic features (if active) take channels 2..5.
if (mic.isRunning() && inSz > 2) {
const f = mic.getFeatures();
const fv = [f.energy, f.brightness, f.pitch, f.aperiodicity];
for (let i = 2; i < inSz; ++i) {
mlStore.setInput(i, fv[i - 2] ?? 0);
}
} else {
for (let i = 2; i < inSz; ++i) mlStore.setInput(i, 0);
}
mlStore.process(); mlStore.process();
// Update pressure-feedback hold timer.
if (pressDownAt !== null) {
explorationStore.setPressure(
explorationStore.state.pressureForce,
now - pressDownAt,
);
}
}; };
// ----- Output pipeline state ------------------------------------------- // ----- Output pipeline + override application --------------------------
let outputState: OutputState = defaultOutputState(); let outputState: OutputState = defaultOutputState();
const sliceLen = schema.ml.output_size; const sliceLen = schema.ml.output_size;
const [processedOutputs, setProcessedOutputs] = createSignal<Float32Array>( const [processedOutputs, setProcessedOutputs] = createSignal<Float32Array>(
new Float32Array(sliceLen), new Float32Array(sliceLen),
{ equals: false }, // always notify even when buffer is reused in-place { equals: false },
);
const paramCount = schema.params.length;
let prevParamOutputs: Float32Array | null = null;
const [paramOutputs, setParamOutputs] = createSignal<Float32Array>(
new Float32Array(paramCount),
{ equals: false },
); );
// Run the output pipeline whenever raw outputs change. // Run the output pipeline whenever raw outputs change.
@ -175,12 +268,52 @@ export function useModeRuntime(
const result = processOutput(slice as Float32Array, outputStore.config, outputState, dtMs); const result = processOutput(slice as Float32Array, outputStore.config, outputState, dtMs);
outputState = result.state; outputState = result.state;
setProcessedOutputs(result.processed); setProcessedOutputs(result.processed);
// Apply per-param overrides for the per-param consumer (engine, sliders).
// Only the first `params.length` outputs are user-visible parameters; the
// remainder is reserved for engine internals (none right now).
const paramSlice = result.processed.length === paramCount
? result.processed
: result.processed.subarray(0, paramCount);
const overrides = modeStore.state.overrides[schema.mode_id] ?? {};
const applied = applyOverrides(
paramSlice as Float32Array,
prevParamOutputs,
schema.params,
overrides,
);
prevParamOutputs = applied.values;
setParamOutputs(applied.values);
}; };
// Trigger recompute on any raw-output change. // Trigger recompute on any raw-output change. Wrap in untrack so reading
// outputStore.config inside processOutput doesn't add a tracked dep here.
createEffect(() => { createEffect(() => {
rawOutputsAccessor(); rawOutputsAccessor();
recomputeOutputs(); untrack(recomputeOutputs);
});
// Re-run override application when overrides change (no new ML output).
createEffect(() => {
void modeStore.state.overrides[schema.mode_id];
untrack(() => {
const raw = rawOutputsAccessor();
if (raw.length > 0) recomputeOutputs();
});
});
// Push the freeze mask into outputStore whenever overrides change.
createEffect(() => {
const ovs = modeStore.state.overrides[schema.mode_id] ?? {};
let mask: Uint8Array | null = null;
for (let i = 0; i < schema.params.length; ++i) {
const ov = ovs[schema.params[i]!.name];
if (ov && ov.frozen) {
if (!mask) mask = new Uint8Array(schema.params.length);
mask[i] = 1;
}
}
untrack(() => outputStore.setFreezeMask(mask));
}); });
// ----- Engine wiring (audio) ------------------------------------------- // ----- Engine wiring (audio) -------------------------------------------
@ -195,7 +328,6 @@ export function useModeRuntime(
pendingParams = null; pendingParams = null;
return; return;
} }
// Copy because EngineHost transfers the buffer.
const copy = new Float32Array(pendingParams); const copy = new Float32Array(pendingParams);
pendingParams = null; pendingParams = null;
try { try {
@ -213,9 +345,9 @@ export function useModeRuntime(
} }
}; };
// Pipe processedOutputs into the engine host whenever they change. // Pipe paramOutputs into the engine host whenever they change.
createEffect(() => { createEffect(() => {
const out = processedOutputs(); const out = paramOutputs();
if (out.length === 0) return; if (out.length === 0) return;
if (!host.isStarted) return; if (!host.isStarted) return;
scheduleParamFlush(out); scheduleParamFlush(out);
@ -228,8 +360,7 @@ export function useModeRuntime(
try { try {
await host.start(engineId); await host.start(engineId);
setAudioStarted(true); setAudioStarted(true);
// Push the current outputs immediately on start. const out = paramOutputs();
const out = processedOutputs();
if (out.length > 0) host.setParams(new Float32Array(out)); if (out.length > 0) host.setParams(new Float32Array(out));
} catch (err) { } catch (err) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
@ -258,56 +389,228 @@ export function useModeRuntime(
pendingParams = null; pendingParams = null;
}); });
// ----- Mic input -------------------------------------------------------
const mic = new MicInput();
const [micStarted, setMicStarted] = createSignal(false);
const startMic = async () => {
try {
await mic.start();
setMicStarted(true);
} catch (err) {
// eslint-disable-next-line no-console
console.error('[mode-runtime] mic start failed:', err);
}
};
const stopMic = async () => {
try {
await mic.stop();
} finally {
setMicStarted(false);
}
};
onCleanup(() => { void stopMic(); });
// ----- Snapshot stack helpers ------------------------------------------
const canUndoMemo = createMemo(() => sessionStore.state.snapshots.length > 0);
// ----- Heatmap sampler -------------------------------------------------
const sampler = new HeatmapSampler({ resolution: 16 });
const [heatmapColorMode, setHeatmapColorMode] = createSignal<HeatmapColorMode>('luminance');
const [heatmapCells, setHeatmapCells] = createSignal<Float32Array>(sampler.getCells(), { equals: false });
const refreshHeatmap = (force = false): void => {
untrack(() => {
if (!ready()) return;
const cfg = inputStore.config;
const z = cfg.zoom;
const cx = cfg.anchorMode === 'center' ? 0.5 : cfg.anchorX;
const cy = cfg.anchorMode === 'center' ? 0.5 : cfg.anchorY;
const took = sampler.update({ cx, cy, zoom: z }, force);
if (took) {
setHeatmapCells(new Float32Array(sampler.getCells()));
}
});
};
// Refresh heatmap on weight changes (training, RL feedback, randomize).
const offTrained = coreBus.on('ml.trained', () => refreshHeatmap());
const offDelta = coreBus.on('ml.delta_update', () => refreshHeatmap());
onCleanup(() => { offTrained(); offDelta(); });
// Initial heatmap refresh once WASM is ready.
createEffect(() => {
if (ready()) refreshHeatmap(true);
});
// ----- Auto-explore loop -----------------------------------------------
let autoExploreTimer: number | null = null;
const tickAutoExplore = () => {
untrack(() => {
if (!ready()) return;
if (!explorationStore.state.autoExploreEnabled) return;
const intensity = explorationStore.state.autoExploreIntensity;
// Zoom-scaled intensity: smaller zoom = gentler.
const zoom = inputStore.config.zoom;
const scaledIntensity = intensity * (0.3 + 0.7 * zoom);
const cap = explorationStore.state.noiseCap * scaledIntensity;
autoSnapshot('before auto-explore');
const spread = explorationStore.state.spread;
const overrides = modeStore.state.overrides[schema.mode_id] ?? {};
const pinMask = buildPinMask(
mlStore.state.outputSize,
schema.mode_id,
schema.params,
overrides,
sessionStore.state.paramPins,
);
mlStore.moveWeights(cap, spread, pinMask);
explorationStore.growNoise(0.5);
// Re-run inference to update the heatmap and visuals.
const [x, y] = pipedInput();
setInput(x, y);
});
};
createEffect(() => {
const enabled = explorationStore.state.autoExploreEnabled;
const interval = explorationStore.state.autoExploreIntervalMs;
if (autoExploreTimer !== null) {
window.clearInterval(autoExploreTimer);
autoExploreTimer = null;
}
if (enabled) {
autoExploreTimer = window.setInterval(tickAutoExplore, interval);
}
});
onCleanup(() => {
if (autoExploreTimer !== null) {
window.clearInterval(autoExploreTimer);
autoExploreTimer = null;
}
});
// ----- Touch / pressure feedback ---------------------------------------
const onPointerDown = (e: PointerEvent) => {
pressDownAt = performance.now();
explorationStore.setPressure(
// Force is 0..1 on supported devices; default 0.5 elsewhere.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(e as any).pressure ?? 0.5,
0,
);
};
const onPointerUp = () => {
pressDownAt = null;
explorationStore.setPressure(0, 0);
};
if (typeof window !== 'undefined') {
window.addEventListener('pointerdown', onPointerDown, { passive: true });
window.addEventListener('pointerup', onPointerUp);
window.addEventListener('pointercancel', onPointerUp);
onCleanup(() => {
window.removeEventListener('pointerdown', onPointerDown);
window.removeEventListener('pointerup', onPointerUp);
window.removeEventListener('pointercancel', onPointerUp);
});
}
// ----- Training helpers ------------------------------------------------- // ----- Training helpers -------------------------------------------------
const trainOnCurrent = () => { const trainOnCurrent = () => {
if (!ready()) return; if (!ready()) return;
const lr = controlStore.resolveParams()['learningRate']; autoSnapshot('before train');
const lrNum = typeof lr === 'number' ? lr : schema.ml.default_learning_rate; const lr = explorationStore.state.learningRate;
mlStore.train(lrNum, schema.ml.default_max_iterations, 0.001); mlStore.train(lr, schema.ml.default_max_iterations, 0.001);
}; };
const thumbsUp = () => { const thumbsUp = () => {
if (!ready()) return; if (!ready()) return;
// Push a label = current pipeline-processed slice as the target at the
// current input. This matches the legacy "thumbs up = remember the
// current sound at this position" semantics.
const [x, y] = pipedInput(); const [x, y] = pipedInput();
const out = processedOutputs(); const out = paramOutputs();
if (out.length === 0) return; if (out.length === 0) return;
const features = new Array(schema.ml.input_size).fill(0); const features = new Array(schema.ml.input_size).fill(0);
features[0] = x; features[0] = x;
if (features.length > 1) features[1] = y; if (features.length > 1) features[1] = y;
const labels = Array.from(out); // Mic features into the example too, if active.
if (mic.isRunning() && schema.ml.input_size > 2) {
const f = mic.getFeatures();
const fv = [f.energy, f.brightness, f.pitch, f.aperiodicity];
for (let i = 2; i < schema.ml.input_size; ++i) {
features[i] = fv[i - 2] ?? 0;
}
}
// Labels: the raw 126-vector targets (not the override-applied; the MLP
// doesn't know about overrides).
const labels = Array.from(processedOutputs());
autoSnapshot('before thumbs-up');
mlStore.addExample(features, labels); mlStore.addExample(features, labels);
explorationStore.decayNoise(explorationStore.state.pressureForce);
trainOnCurrent(); trainOnCurrent();
}; };
const thumbsDown = () => { const thumbsDown = () => {
if (!ready()) return; if (!ready()) return;
const params = controlStore.resolveParams(); autoSnapshot('before thumbs-down');
const cap = typeof params['noiseCap'] === 'number' const cap = explorationStore.state.noiseCap;
? (params['noiseCap'] as number) const spread = explorationStore.state.spread;
: 0.12; const overrides = modeStore.state.overrides[schema.mode_id] ?? {};
const spread = schema.ml.default_spread; const pinMask = buildPinMask(
mlStore.moveWeights(cap, spread); mlStore.state.outputSize,
// Re-run inference at current input so the visual updates. schema.mode_id,
schema.params,
overrides,
sessionStore.state.paramPins,
);
mlStore.moveWeights(cap, spread, pinMask);
explorationStore.growNoise(explorationStore.state.pressureForce);
const [x, y] = pipedInput(); const [x, y] = pipedInput();
setInput(x, y); setInput(x, y);
}; };
const randomize = () => { const randomize = () => {
if (!ready()) return; if (!ready()) return;
mlStore.drawWeights(schema.ml.default_spread); autoSnapshot('before randomize');
mlStore.drawWeights(explorationStore.state.spread);
const [x, y] = pipedInput(); const [x, y] = pipedInput();
setInput(x, y); setInput(x, y);
}; };
const undo = (): boolean => {
const ok = undoLastSnapshot();
if (ok) {
const [x, y] = pipedInput();
setInput(x, y);
}
return ok;
};
// ----- Region pins ------------------------------------------------------
const pinCurrentRegion = () => {
const cfg = inputStore.config;
const z = cfg.zoom;
const cx = cfg.anchorMode === 'center' ? 0.5 : cfg.anchorX;
const cy = cfg.anchorMode === 'center' ? 0.5 : cfg.anchorY;
const halfZ = z * 0.5;
sessionStore.addRegionPin({
x: Math.max(0, cx - halfZ),
y: Math.max(0, cy - halfZ),
width: Math.min(z, 1),
height: Math.min(z, 1),
});
autoSnapshot('pinned baseline');
};
// Snap-to-trail
const snapToTrail = (p: { x: number; y: number }) => {
batch(() => {
setInput(p.x, p.y);
});
};
return { return {
setInput, setInput,
pipedInput, pipedInput,
frozen, frozen,
rawOutputs: rawOutputsAccessor, rawOutputs: rawOutputsAccessor,
processedOutputs, processedOutputs,
paramOutputs,
ready, ready,
audio: { audio: {
started: audioStarted, started: audioStarted,
@ -315,6 +618,11 @@ export function useModeRuntime(
stop: stopAudio, stop: stopAudio,
setMuted: (muted) => host.setMuted(muted), setMuted: (muted) => host.setMuted(muted),
}, },
mic: {
started: micStarted,
start: startMic,
stop: stopMic,
},
training: { training: {
busy: () => mlStore.state.training, busy: () => mlStore.state.training,
examples: () => mlStore.state.exampleCount, examples: () => mlStore.state.exampleCount,
@ -325,6 +633,22 @@ export function useModeRuntime(
thumbsUp, thumbsUp,
thumbsDown, thumbsDown,
randomize, randomize,
undo,
canUndo: () => canUndoMemo(),
trail: trailRing.points,
snapToTrail,
heatmap: {
cells: heatmapCells,
setColorMode: (m) => {
sampler.setColorMode(m);
setHeatmapColorMode(m);
refreshHeatmap(true);
},
colorMode: heatmapColorMode,
refresh: refreshHeatmap,
resolution: () => sampler.resolution,
},
pinCurrentRegion,
}; };
} }

View file

@ -0,0 +1,254 @@
/**
* Exploration store RL feedback state and auto-explore configuration.
*
* Holds:
* - `noiseLevel` current RL exploration noise (mutated by thumbs-up/down).
* - `noiseFloor`/`noiseCap` bounds; cap maps from compound-axis Boldness.
* - `noiseGrowth`/`noiseDecay` multiplicative factors per RL feedback step.
* - `spread` sigmoid-saturation regime [0,1].
* - `autoExplore` interval, intensity, last-tick.
* - `pressure` last touch force/hold sample for feedback scaling.
*
* Stream 10 owns this store; the runtime reads from it on every thumbs-down
* and the auto-explore interval timer. Mutations via store actions; reads via
* signals.
*/
import { createStore, produce } from 'solid-js/store';
import { schedulePersist, loadPersisted } from './persistence';
import { clamp } from '../output/curves';
const STORAGE_KEY = 'nisps:exploration';
export interface ExplorationState {
/** Live RL noise level (always in [floor, cap]). */
noiseLevel: number;
/** Lower bound. Compound axes can move it. */
noiseFloor: number;
/** Upper bound. Mapped from Boldness. */
noiseCap: number;
/** Per-thumbs-down growth factor (>1). */
noiseGrowth: number;
/** Per-thumbs-up decay factor (<1). */
noiseDecay: number;
/** Master sigmoid-saturation regime [0,1]. */
spread: number;
/** Learning rate for trainings (compound axes can override). */
learningRate: number;
/** Weight decay applied during moveWeights (Boldness/Memory). */
weightDecay: number;
/** Auto-explore: enabled flag. */
autoExploreEnabled: boolean;
/** Auto-explore tick interval, ms. [500, 10000]. */
autoExploreIntervalMs: number;
/** Intensity scaling for auto-explore noise [0.1, 1.0]. */
autoExploreIntensity: number;
/** Last touch pressure-sensitive sample (0..1). */
pressureForce: number;
/** Hold duration of current touch (ms). 0 if no touch. */
holdMs: number;
}
interface PersistedExploration {
noiseFloor: number;
noiseCap: number;
noiseGrowth: number;
noiseDecay: number;
spread: number;
learningRate: number;
weightDecay: number;
autoExploreEnabled: boolean;
autoExploreIntervalMs: number;
autoExploreIntensity: number;
}
function defaults(): ExplorationState {
return {
noiseLevel: 0.05,
noiseFloor: 0.005,
noiseCap: 0.12,
noiseGrowth: 1.5,
noiseDecay: 0.97,
spread: 0.6,
learningRate: 1.0,
weightDecay: 0.06,
autoExploreEnabled: false,
autoExploreIntervalMs: 2000,
autoExploreIntensity: 0.5,
pressureForce: 0,
holdMs: 0,
};
}
function loadInitial(): ExplorationState {
const base = defaults();
const persisted = loadPersisted<Partial<PersistedExploration>>(STORAGE_KEY, {});
return {
...base,
noiseFloor: persisted.noiseFloor ?? base.noiseFloor,
noiseCap: persisted.noiseCap ?? base.noiseCap,
noiseGrowth: persisted.noiseGrowth ?? base.noiseGrowth,
noiseDecay: persisted.noiseDecay ?? base.noiseDecay,
spread: persisted.spread ?? base.spread,
learningRate: persisted.learningRate ?? base.learningRate,
weightDecay: persisted.weightDecay ?? base.weightDecay,
autoExploreEnabled: persisted.autoExploreEnabled ?? base.autoExploreEnabled,
autoExploreIntervalMs: persisted.autoExploreIntervalMs ?? base.autoExploreIntervalMs,
autoExploreIntensity: persisted.autoExploreIntensity ?? base.autoExploreIntensity,
};
}
const [state, setState] = createStore<ExplorationState>(loadInitial());
function persist(): void {
schedulePersist(STORAGE_KEY, () => ({
noiseFloor: state.noiseFloor,
noiseCap: state.noiseCap,
noiseGrowth: state.noiseGrowth,
noiseDecay: state.noiseDecay,
spread: state.spread,
learningRate: state.learningRate,
weightDecay: state.weightDecay,
autoExploreEnabled: state.autoExploreEnabled,
autoExploreIntervalMs: state.autoExploreIntervalMs,
autoExploreIntensity: state.autoExploreIntensity,
}));
}
export const explorationStore = {
state,
/** Bulk update from compound-axis resolution. */
applyCompoundParams(params: Record<string, unknown>): void {
setState(produce((s) => {
const get = (k: string): number | undefined => {
const v = params[k];
return typeof v === 'number' ? v : undefined;
};
const cap = get('noiseCap');
if (cap !== undefined) s.noiseCap = clamp(cap, 0.005, 1);
const growth = get('noiseGrowth');
if (growth !== undefined) s.noiseGrowth = clamp(growth, 1, 4);
const decay = get('noiseDecay');
if (decay !== undefined) s.noiseDecay = clamp(decay, 0, 1);
const lr = get('learningRate');
if (lr !== undefined) s.learningRate = clamp(lr, 0.01, 10);
const wd = get('weightDecay');
if (wd !== undefined) s.weightDecay = clamp(wd, 0, 0.5);
// Keep noiseLevel in [floor, cap]
if (s.noiseLevel > s.noiseCap) s.noiseLevel = s.noiseCap;
if (s.noiseLevel < s.noiseFloor) s.noiseLevel = s.noiseFloor;
}));
persist();
},
setNoiseLevel(v: number): void {
setState(produce((s) => {
s.noiseLevel = clamp(v, s.noiseFloor, s.noiseCap);
}));
},
setSpread(v: number): void {
setState(produce((s) => {
s.spread = clamp(v, 0, 1);
}));
persist();
},
setNoiseFloor(v: number): void {
setState(produce((s) => {
s.noiseFloor = clamp(v, 0, s.noiseCap);
if (s.noiseLevel < s.noiseFloor) s.noiseLevel = s.noiseFloor;
}));
persist();
},
setNoiseCap(v: number): void {
setState(produce((s) => {
s.noiseCap = clamp(v, s.noiseFloor, 1);
if (s.noiseLevel > s.noiseCap) s.noiseLevel = s.noiseCap;
}));
persist();
},
setNoiseGrowth(v: number): void {
setState(produce((s) => { s.noiseGrowth = clamp(v, 1, 4); }));
persist();
},
setNoiseDecay(v: number): void {
setState(produce((s) => { s.noiseDecay = clamp(v, 0, 1); }));
persist();
},
setLearningRate(v: number): void {
setState(produce((s) => { s.learningRate = clamp(v, 0.001, 10); }));
persist();
},
setWeightDecay(v: number): void {
setState(produce((s) => { s.weightDecay = clamp(v, 0, 0.5); }));
persist();
},
/**
* Mutate noise after a thumbs-down/up. Pressure [0,1] modulates the
* effective growth/decay; >0.5 = harder, <0.5 = softer.
*/
growNoise(pressure: number = 0.5): number {
let next = state.noiseLevel;
setState(produce((s) => {
const p = clamp(pressure, 0, 1);
// Pressure 0 = baseline, 1 = stronger. Reduces effect of growth.
const factor = 1 + (s.noiseGrowth - 1) * (0.5 + p);
next = clamp(s.noiseLevel * factor, s.noiseFloor, s.noiseCap);
s.noiseLevel = next;
}));
return next;
},
decayNoise(pressure: number = 0.5): number {
let next = state.noiseLevel;
setState(produce((s) => {
const p = clamp(pressure, 0, 1);
// Stronger pressure → faster decay.
const factor = s.noiseDecay - (1 - s.noiseDecay) * (p - 0.5) * 0.4;
next = clamp(s.noiseLevel * clamp(factor, 0, 1), s.noiseFloor, s.noiseCap);
s.noiseLevel = next;
}));
return next;
},
setAutoExplore(enabled: boolean): void {
setState(produce((s) => { s.autoExploreEnabled = enabled; }));
persist();
},
setAutoExploreInterval(ms: number): void {
setState(produce((s) => { s.autoExploreIntervalMs = clamp(ms, 500, 10000); }));
persist();
},
setAutoExploreIntensity(v: number): void {
setState(produce((s) => { s.autoExploreIntensity = clamp(v, 0.1, 1); }));
persist();
},
setPressure(force: number, holdMs: number): void {
setState(produce((s) => {
s.pressureForce = clamp(force, 0, 1);
s.holdMs = Math.max(0, holdMs);
}));
},
reset(): void {
setState(defaults());
persist();
},
};
export type ExplorationStore = typeof explorationStore;

View file

@ -30,4 +30,9 @@ export {
type ABState, type ABState,
type SessionPreset, type SessionPreset,
} from './session-store'; } from './session-store';
export {
explorationStore,
type ExplorationStore,
type ExplorationState,
} from './exploration-store';
export { schedulePersist, flushPersist, loadPersisted, clearPersisted } from './persistence'; export { schedulePersist, flushPersist, loadPersisted, clearPersisted } from './persistence';

View file

@ -1,9 +1,16 @@
/** /**
* Session store snapshot stack, A/B compare, region pins, named session presets. * Session store snapshot stack, A/B compare, region pins, named session presets.
* *
* Stream 8 (this stream) provides the API and a working in-memory * Stream 10 wires real ML weights into the snapshot stack and A/B state.
* implementation with stub data shapes. Stream 10 wires the snapshots to * Snapshots store a Float32Array copy of the weights at the moment they
* real ML weights and surfaces the data through UI. * were pushed; restoring pops + writes back via mlStore.setWeights.
*
* Snapshot stack capacity is bounded (MAX_SNAPSHOTS); the oldest is dropped
* when full. The stack is in-memory only losing it across reloads is
* acceptable (matches the legacy playground).
*
* Region pins, param pins, and named session presets ARE persisted because
* they're explicit user intent.
*/ */
import { createStore, produce } from 'solid-js/store'; import { createStore, produce } from 'solid-js/store';
@ -13,15 +20,15 @@ import { schedulePersist, loadPersisted } from './persistence';
const STORAGE_KEY = 'nisps:session'; const STORAGE_KEY = 'nisps:session';
const MAX_SNAPSHOTS = 20; const MAX_SNAPSHOTS = 20;
/** A single weights snapshot (placeholder until ML stream is live). */ /** A single weights snapshot. */
export interface Snapshot { export interface Snapshot {
id: string; id: string;
tag: string; tag: string;
timestamp: number; timestamp: number;
noiseLevel: number; noiseLevel: number;
zoomLevel: number | null; zoomLevel: number | null;
/** Stream 7+ stores actual weights here (Float32Array via ArrayBuffer in JSON). */ /** Float32Array weight copy. Null if not captured (e.g. before WASM ready). */
weightsRef: string | null; weights: Float32Array | null;
} }
export interface RegionPin { export interface RegionPin {
@ -106,14 +113,17 @@ export const sessionStore = {
// ----- Snapshots ----- // ----- Snapshots -----
pushSnapshot(tag: string, opts: { noiseLevel?: number; zoomLevel?: number | null; weightsRef?: string | null } = {}): Snapshot { pushSnapshot(
tag: string,
opts: { noiseLevel?: number; zoomLevel?: number | null; weights?: Float32Array | null } = {},
): Snapshot {
const snap: Snapshot = { const snap: Snapshot = {
id: nextSnapshotId(), id: nextSnapshotId(),
tag, tag,
timestamp: Date.now(), timestamp: Date.now(),
noiseLevel: opts.noiseLevel ?? 0, noiseLevel: opts.noiseLevel ?? 0,
zoomLevel: opts.zoomLevel ?? null, zoomLevel: opts.zoomLevel ?? null,
weightsRef: opts.weightsRef ?? null, weights: opts.weights ? new Float32Array(opts.weights) : null,
}; };
setState(produce((s) => { setState(produce((s) => {
s.snapshots.push(snap); s.snapshots.push(snap);
@ -125,6 +135,18 @@ export const sessionStore = {
return snap; return snap;
}, },
/** Convenience peek at the most recent snapshot. */
peekSnapshot(): Snapshot | null {
return state.snapshots.length > 0
? state.snapshots[state.snapshots.length - 1]!
: null;
},
/** Get a copy of all snapshots (for UI rendering). */
listSnapshots(): ReadonlyArray<Snapshot> {
return state.snapshots;
},
popSnapshot(): Snapshot | null { popSnapshot(): Snapshot | null {
let popped: Snapshot | null = null; let popped: Snapshot | null = null;
setState(produce((s) => { setState(produce((s) => {