merge stream 10: a-immersive feature parity wired (meml-5wg)
This commit is contained in:
commit
a4d3b3a8b5
27 changed files with 3346 additions and 269 deletions
|
|
@ -1,25 +1,71 @@
|
|||
/**
|
||||
* Debug probe: window.__nisps
|
||||
*
|
||||
* Stream 7 wires this to the real WasmIML via mlStore. Methods are
|
||||
* synchronous (or return immediately-resolved promises). The probe
|
||||
* deliberately bypasses Solid reactivity so tests get deterministic,
|
||||
* imperative semantics.
|
||||
* Synchronous-or-immediate Promise API for Playwright tests and dev console
|
||||
* use. Bypasses SolidJS reactivity by reading/writing the underlying stores
|
||||
* and WASM directly — values propagate to the UI on the next reactive tick,
|
||||
* but the probe always returns the freshest state.
|
||||
*
|
||||
* The probe self-initialises the ML engine on first use that needs it
|
||||
* — Playwright tests can `await window.__nisps.__init()` before driving
|
||||
* inference, or just call methods and tolerate a few no-ops while the
|
||||
* lazy init resolves. While the init is in flight, `__ready` is false;
|
||||
* synchronous methods that need ML are best-effort no-ops.
|
||||
* Stream 10 expanded the surface to cover stream 8/9 features:
|
||||
* - Snapshot stack (push, pop, list)
|
||||
* - A/B compare (capture, toggle, accept, revert)
|
||||
* - Region pinning (add, remove, clear)
|
||||
* - 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 { 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 {
|
||||
/** Current 126-element output vector (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. */
|
||||
getLoss(): number | null;
|
||||
/** Loss history of last training run. */
|
||||
getLossHistory(): ReadonlyArray<number>;
|
||||
/** Flat weight array. */
|
||||
getWeights(): Float32Array;
|
||||
/** Number of training examples currently in the dataset. */
|
||||
|
|
@ -46,6 +92,68 @@ export interface DebugProbe {
|
|||
inferBatch(points: ReadonlyArray<readonly [number, number]>): Float32Array;
|
||||
/** Per-layer weight statistics: layerCount * 4 floats (mean|w|, max|w|, dead%, sat%). */
|
||||
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. */
|
||||
readonly __ready: boolean;
|
||||
/** Force initialisation. Returns a promise that resolves when the WASM is ready. */
|
||||
|
|
@ -60,9 +168,6 @@ declare global {
|
|||
|
||||
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;
|
||||
function lazyInit(): Promise<void> {
|
||||
if (mlStore.iml) return Promise.resolve();
|
||||
|
|
@ -72,6 +177,9 @@ function lazyInit(): Promise<void> {
|
|||
return lazyInitPromise;
|
||||
}
|
||||
|
||||
// Probe-local heatmap sampler (independent of any active mode runtime).
|
||||
const probeHeatmap = new HeatmapSampler({ resolution: 16 });
|
||||
|
||||
const probe: DebugProbe = {
|
||||
get __ready(): boolean {
|
||||
return !!mlStore.iml && mlStore.state.ready;
|
||||
|
|
@ -85,10 +193,24 @@ const probe: DebugProbe = {
|
|||
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 {
|
||||
return mlStore.state.lastLoss;
|
||||
},
|
||||
|
||||
getLossHistory(): ReadonlyArray<number> {
|
||||
return mlStore.state.lossHistory;
|
||||
},
|
||||
|
||||
getWeights(): Float32Array {
|
||||
return mlStore.getWeights();
|
||||
},
|
||||
|
|
@ -102,22 +224,27 @@ const probe: DebugProbe = {
|
|||
void lazyInit();
|
||||
return;
|
||||
}
|
||||
mlStore.iml.inferXY(x, y);
|
||||
untrack(() => mlStore.iml!.inferXY(x, y));
|
||||
},
|
||||
|
||||
thumbsUp(): void {
|
||||
if (!mlStore.iml) return;
|
||||
// Stream 10 will replace this with the full RL controller; the
|
||||
// legacy probe behaviour is "train, then settle". For now we run
|
||||
// a sync training step.
|
||||
mlStore.iml.train();
|
||||
untrack(() => {
|
||||
autoSnapshot('before thumbs-up (probe)');
|
||||
mlStore.iml!.train(explorationStore.state.learningRate);
|
||||
explorationStore.decayNoise(0.5);
|
||||
});
|
||||
},
|
||||
|
||||
thumbsDown(): void {
|
||||
if (!mlStore.iml) return;
|
||||
// Default RL noise burst at the playground's typical spread. Stream
|
||||
// 10 will hook the noise cap from the control surface state.
|
||||
mlStore.iml.moveWeights(0.1, 0.6);
|
||||
untrack(() => {
|
||||
autoSnapshot('before thumbs-down (probe)');
|
||||
const cap = explorationStore.state.noiseCap;
|
||||
const spread = explorationStore.state.spread;
|
||||
mlStore.iml!.moveWeights(cap, spread);
|
||||
explorationStore.growNoise(0.5);
|
||||
});
|
||||
},
|
||||
|
||||
train(): number {
|
||||
|
|
@ -125,18 +252,25 @@ const probe: DebugProbe = {
|
|||
void lazyInit();
|
||||
return 0;
|
||||
}
|
||||
return mlStore.iml.train();
|
||||
return untrack(() => {
|
||||
autoSnapshot('before train (probe)');
|
||||
return mlStore.iml!.train(explorationStore.state.learningRate);
|
||||
});
|
||||
},
|
||||
|
||||
async trainAsync(): Promise<number> {
|
||||
await lazyInit();
|
||||
if (!mlStore.iml) return 0;
|
||||
return mlStore.iml.trainAsync();
|
||||
autoSnapshot('before trainAsync (probe)');
|
||||
return mlStore.iml.trainAsync(explorationStore.state.learningRate);
|
||||
},
|
||||
|
||||
randomise(): void {
|
||||
if (!mlStore.iml) return;
|
||||
mlStore.iml.randomiseWeights(0.6);
|
||||
untrack(() => {
|
||||
autoSnapshot('before randomize (probe)');
|
||||
mlStore.iml!.randomiseWeights(explorationStore.state.spread);
|
||||
});
|
||||
},
|
||||
|
||||
clearExamples(): void {
|
||||
|
|
@ -161,6 +295,214 @@ const probe: DebugProbe = {
|
|||
if (!mlStore.iml) return EMPTY_F32;
|
||||
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);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
121
playground/src/dev/probe-smoke.ts
Normal file
121
playground/src/dev/probe-smoke.ts
Normal 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;
|
||||
}
|
||||
77
playground/src/features/control-routing.ts
Normal file
77
playground/src/features/control-routing.ts
Normal 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;
|
||||
}
|
||||
160
playground/src/features/heatmap-sampler.ts
Normal file
160
playground/src/features/heatmap-sampler.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
138
playground/src/features/mic-input.ts
Normal file
138
playground/src/features/mic-input.ts
Normal 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 80–1200 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;
|
||||
}
|
||||
}
|
||||
131
playground/src/features/overrides.ts
Normal file
131
playground/src/features/overrides.ts
Normal 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;
|
||||
}
|
||||
288
playground/src/features/session-preset.ts
Normal file
288
playground/src/features/session-preset.ts
Normal 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;
|
||||
}
|
||||
123
playground/src/features/snapshots.ts
Normal file
123
playground/src/features/snapshots.ts
Normal 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();
|
||||
}
|
||||
55
playground/src/features/trail.ts
Normal file
55
playground/src/features/trail.ts
Normal 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([]);
|
||||
},
|
||||
};
|
||||
}
|
||||
124
playground/src/features/weight-health.ts
Normal file
124
playground/src/features/weight-health.ts
Normal 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';
|
||||
}
|
||||
|
|
@ -3,15 +3,23 @@ import { render } from 'solid-js/web';
|
|||
import App from './App';
|
||||
import './styles/tokens.css';
|
||||
import { installDebugProbe } from './debug/probe';
|
||||
import { applyUrlParams } from './features/session-preset';
|
||||
|
||||
const root = document.getElementById('root');
|
||||
if (!root) {
|
||||
throw new Error('Root element #root not found');
|
||||
}
|
||||
|
||||
// Install debug probe early. It is a stub for now; stream 10 fills it in
|
||||
// once WASM ML is wired up. Keeping the install path stable from day one
|
||||
// makes Playwright tests insensitive to ordering.
|
||||
// Install debug probe early. Playwright tests use it to drive the ML
|
||||
// engine programmatically.
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -7,32 +7,18 @@ import { ModeShell } from './ModeShell';
|
|||
import { useModeRuntime } from './mode-runtime';
|
||||
import { XYPad } from '../primitives/XYPad';
|
||||
import { OutputDisplay } from '../primitives/OutputDisplay';
|
||||
import { SliderBank } from '../primitives/SliderBank';
|
||||
import { LossPlot } from '../primitives/LossPlot';
|
||||
import { paramsToSliderConfig, outputsToSliderValues } from './mode-helpers';
|
||||
import { BreakorSchema } from './generated/breakor_schema';
|
||||
|
||||
export const BreakOrMode: Component = () => {
|
||||
const schema = BreakorSchema;
|
||||
const runtime = useModeRuntime(schema);
|
||||
const sliderConfig = paramsToSliderConfig(schema.params);
|
||||
const sliderValues = () => outputsToSliderValues(runtime.processedOutputs(), schema.params);
|
||||
|
||||
return (
|
||||
<ModeShell
|
||||
schema={schema}
|
||||
runtime={runtime}
|
||||
drawerTitle="Breakor params"
|
||||
drawerContent={() => (
|
||||
<SliderBank
|
||||
title="Live drum params"
|
||||
sliders={sliderConfig}
|
||||
values={sliderValues}
|
||||
onChange={() => {
|
||||
/* read-only */
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
drawerTitle="Breakor settings"
|
||||
primaryInput={() => (
|
||||
<>
|
||||
<XYPad
|
||||
|
|
@ -49,7 +35,7 @@ export const BreakOrMode: Component = () => {
|
|||
outputArea={() => (
|
||||
<>
|
||||
<OutputDisplay
|
||||
values={runtime.processedOutputs}
|
||||
values={runtime.paramOutputs}
|
||||
width={360}
|
||||
height={120}
|
||||
color="var(--good)"
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
/**
|
||||
* ChannelStripMode — channel-strip processor controlled by joystick.
|
||||
*
|
||||
* 24 outputs feed EQ / dynamics / gain. Schema has no voice spaces so the
|
||||
* shell omits the selector. Drawer shows the live param sliders.
|
||||
* 24 outputs feed EQ / dynamics / gain. Settings drawer is the default
|
||||
* SettingsDrawer (input/training/exploration/output/overrides/advanced).
|
||||
*/
|
||||
|
||||
import { Component } from 'solid-js';
|
||||
|
|
@ -10,33 +10,18 @@ import { ModeShell } from './ModeShell';
|
|||
import { useModeRuntime } from './mode-runtime';
|
||||
import { VirtualJoystick } from '../primitives/VirtualJoystick';
|
||||
import { OutputDisplay } from '../primitives/OutputDisplay';
|
||||
import { SliderBank } from '../primitives/SliderBank';
|
||||
import { LossPlot } from '../primitives/LossPlot';
|
||||
import { paramsToSliderConfig, outputsToSliderValues } from './mode-helpers';
|
||||
import { ChannelStripSchema } from './generated/channel_strip_schema';
|
||||
|
||||
export const ChannelStripMode: Component = () => {
|
||||
const schema = ChannelStripSchema;
|
||||
const runtime = useModeRuntime(schema);
|
||||
|
||||
const sliderConfig = paramsToSliderConfig(schema.params);
|
||||
const sliderValues = () => outputsToSliderValues(runtime.processedOutputs(), schema.params);
|
||||
|
||||
return (
|
||||
<ModeShell
|
||||
schema={schema}
|
||||
runtime={runtime}
|
||||
drawerTitle="Channel strip params"
|
||||
drawerContent={() => (
|
||||
<SliderBank
|
||||
title="Live parameter values"
|
||||
sliders={sliderConfig}
|
||||
values={sliderValues}
|
||||
onChange={() => {
|
||||
/* read-only for now */
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
drawerTitle="Channel strip settings"
|
||||
primaryInput={() => (
|
||||
<>
|
||||
<VirtualJoystick
|
||||
|
|
@ -53,7 +38,7 @@ export const ChannelStripMode: Component = () => {
|
|||
outputArea={() => (
|
||||
<>
|
||||
<OutputDisplay
|
||||
values={runtime.processedOutputs}
|
||||
values={runtime.paramOutputs}
|
||||
width={360}
|
||||
height={120}
|
||||
color="var(--accent-3)"
|
||||
|
|
|
|||
|
|
@ -7,32 +7,18 @@ import { ModeShell } from './ModeShell';
|
|||
import { useModeRuntime } from './mode-runtime';
|
||||
import { XYPad } from '../primitives/XYPad';
|
||||
import { OutputDisplay } from '../primitives/OutputDisplay';
|
||||
import { SliderBank } from '../primitives/SliderBank';
|
||||
import { LossPlot } from '../primitives/LossPlot';
|
||||
import { paramsToSliderConfig, outputsToSliderValues } from './mode-helpers';
|
||||
import { ElysiamorfSchema } from './generated/elysiamorf_schema';
|
||||
|
||||
export const ElysiamorfMode: Component = () => {
|
||||
const schema = ElysiamorfSchema;
|
||||
const runtime = useModeRuntime(schema);
|
||||
const sliderConfig = paramsToSliderConfig(schema.params);
|
||||
const sliderValues = () => outputsToSliderValues(runtime.processedOutputs(), schema.params);
|
||||
|
||||
return (
|
||||
<ModeShell
|
||||
schema={schema}
|
||||
runtime={runtime}
|
||||
drawerTitle="Elysiamorf params"
|
||||
drawerContent={() => (
|
||||
<SliderBank
|
||||
title="Live morph params"
|
||||
sliders={sliderConfig}
|
||||
values={sliderValues}
|
||||
onChange={() => {
|
||||
/* read-only */
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
drawerTitle="Elysiamorf settings"
|
||||
primaryInput={() => (
|
||||
<>
|
||||
<XYPad
|
||||
|
|
@ -49,7 +35,7 @@ export const ElysiamorfMode: Component = () => {
|
|||
outputArea={() => (
|
||||
<>
|
||||
<OutputDisplay
|
||||
values={runtime.processedOutputs}
|
||||
values={runtime.paramOutputs}
|
||||
width={360}
|
||||
height={120}
|
||||
color="#ffb060"
|
||||
|
|
|
|||
|
|
@ -7,17 +7,13 @@ import { ModeShell } from './ModeShell';
|
|||
import { useModeRuntime } from './mode-runtime';
|
||||
import { XYPad } from '../primitives/XYPad';
|
||||
import { OutputDisplay } from '../primitives/OutputDisplay';
|
||||
import { SliderBank } from '../primitives/SliderBank';
|
||||
import { LossPlot } from '../primitives/LossPlot';
|
||||
import { paramsToSliderConfig, outputsToSliderValues } from './mode-helpers';
|
||||
import { MemlceliumSchema } from './generated/memlcelium_schema';
|
||||
|
||||
export const MEMLCeliumMode: Component = () => {
|
||||
const schema = MemlceliumSchema;
|
||||
const runtime = useModeRuntime(schema);
|
||||
const [voiceSpace, setVoiceSpace] = createSignal(0);
|
||||
const sliderConfig = paramsToSliderConfig(schema.params);
|
||||
const sliderValues = () => outputsToSliderValues(runtime.processedOutputs(), schema.params);
|
||||
|
||||
return (
|
||||
<ModeShell
|
||||
|
|
@ -25,17 +21,7 @@ export const MEMLCeliumMode: Component = () => {
|
|||
runtime={runtime}
|
||||
activeVoiceSpace={voiceSpace}
|
||||
onVoiceSpaceChange={setVoiceSpace}
|
||||
drawerTitle="MEMLCelium voice + CV"
|
||||
drawerContent={() => (
|
||||
<SliderBank
|
||||
title="Live voice + CV params"
|
||||
sliders={sliderConfig}
|
||||
values={sliderValues}
|
||||
onChange={() => {
|
||||
/* read-only */
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
drawerTitle="MEMLCelium settings"
|
||||
primaryInput={() => (
|
||||
<>
|
||||
<XYPad
|
||||
|
|
@ -53,7 +39,7 @@ export const MEMLCeliumMode: Component = () => {
|
|||
outputArea={() => (
|
||||
<>
|
||||
<OutputDisplay
|
||||
values={runtime.processedOutputs}
|
||||
values={runtime.paramOutputs}
|
||||
width={360}
|
||||
height={120}
|
||||
color="#5b9eef"
|
||||
|
|
|
|||
|
|
@ -2,25 +2,30 @@
|
|||
* ModeShell — common scaffolding shared by every concrete mode.
|
||||
*
|
||||
* Provides:
|
||||
* - Header (mode name, optional voice-space PillToggle, audio start/stop).
|
||||
* - Primary input area (joystick / xy-pad / audio analyser, mode-supplied).
|
||||
* - Output area (sliders / output bars, mode-supplied).
|
||||
* - Header (mode name, voice space pill, audio start/stop, drawer button).
|
||||
* - Primary input area (mode-supplied) + JoyMap navigator on its right.
|
||||
* - Output area (mode-supplied).
|
||||
* - Control axes bar (Boldness/Memory/Precision wired to controlStore).
|
||||
* - Training controls (wired to the mode runtime).
|
||||
* - Optional right-side drawer for mode-specific settings.
|
||||
* - Training controls — undo, A/B, region/param pin all wired through.
|
||||
* - SettingsDrawer (collapsible sections, per-param overrides, advanced).
|
||||
*
|
||||
* Modes only have to author the primary input + output JSX. Everything
|
||||
* 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 { ControlAxis } from '../primitives/ControlAxis';
|
||||
import { PillToggle } from '../primitives/PillToggle';
|
||||
import { Drawer } from '../primitives/Drawer';
|
||||
import { JoyMap } from '../primitives/JoyMap';
|
||||
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 { ModeSchema } from './generated';
|
||||
import { SettingsDrawer } from './SettingsDrawer';
|
||||
import styles from './ModeShell.module.css';
|
||||
|
||||
export interface ModeShellProps {
|
||||
|
|
@ -30,7 +35,7 @@ export interface ModeShellProps {
|
|||
primaryInput: () => JSX.Element;
|
||||
/** Output / visualisation area. */
|
||||
outputArea: () => JSX.Element;
|
||||
/** Optional drawer body for mode-specific settings. */
|
||||
/** Optional drawer body for mode-specific settings (replaces default). */
|
||||
drawerContent?: () => JSX.Element;
|
||||
drawerTitle?: string;
|
||||
/** 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) => {
|
||||
const [drawerOpen, setDrawerOpen] = createSignal(false);
|
||||
const [snapshotPopupOpen, setSnapshotPopupOpen] = createSignal(false);
|
||||
const showVoiceSpaces = () =>
|
||||
props.schema.ui.show_voice_space_selector && props.schema.voice_spaces.length > 0;
|
||||
|
||||
|
|
@ -49,6 +55,14 @@ export const ModeShell: Component<ModeShellProps> = (props) => {
|
|||
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 (
|
||||
<section class={styles.shell} aria-label={`${props.schema.mode_id} mode`}>
|
||||
<header class={styles.header}>
|
||||
|
|
@ -74,6 +88,28 @@ export const ModeShell: Component<ModeShellProps> = (props) => {
|
|||
</Show>
|
||||
|
||||
<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
|
||||
when={props.runtime.audio.started()}
|
||||
fallback={
|
||||
|
|
@ -82,9 +118,7 @@ export const ModeShell: Component<ModeShellProps> = (props) => {
|
|||
class={styles.audioBtn}
|
||||
onClick={() => void props.runtime.audio.start()}
|
||||
aria-label="Start audio engine"
|
||||
>
|
||||
▶ Start audio
|
||||
</button>
|
||||
>▶ Start audio</button>
|
||||
}
|
||||
>
|
||||
<button
|
||||
|
|
@ -92,25 +126,37 @@ export const ModeShell: Component<ModeShellProps> = (props) => {
|
|||
class={`${styles.audioBtn} ${styles.on}`}
|
||||
onClick={() => void props.runtime.audio.stop()}
|
||||
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>
|
||||
>⚙</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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>
|
||||
|
||||
|
|
@ -136,15 +182,24 @@ export const ModeShell: Component<ModeShellProps> = (props) => {
|
|||
onThumbsUp={() => props.runtime.thumbsUp()}
|
||||
onThumbsDown={() => props.runtime.thumbsDown()}
|
||||
onUndo={() => {
|
||||
// Stream 9 ships without undo wiring — no-op until session-store
|
||||
// gets a snapshot/pop method exposed via the runtime. Stubbed
|
||||
// so the button still appears.
|
||||
// Click = pop one snapshot. Long-press = open list popup.
|
||||
props.runtime.undo();
|
||||
}}
|
||||
exampleCount={() => props.runtime.training.examples()}
|
||||
lastLoss={() => props.runtime.training.lastLoss()}
|
||||
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>
|
||||
|
||||
|
|
@ -159,20 +214,72 @@ export const ModeShell: Component<ModeShellProps> = (props) => {
|
|||
input ({props.runtime.pipedInput()[0].toFixed(2)},
|
||||
{' '}
|
||||
{props.runtime.pipedInput()[1].toFixed(2)})
|
||||
{' · '}
|
||||
noise {explorationStore.state.noiseLevel.toFixed(3)}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<Show when={props.drawerContent}>
|
||||
<Drawer
|
||||
open={drawerOpen()}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
side="right"
|
||||
title={props.drawerTitle ?? 'Mode settings'}
|
||||
width={420}
|
||||
width={460}
|
||||
>
|
||||
<Show
|
||||
when={props.drawerContent}
|
||||
fallback={
|
||||
<SettingsDrawer schema={props.schema} runtime={props.runtime} />
|
||||
}
|
||||
>
|
||||
{props.drawerContent!()}
|
||||
</Drawer>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -11,9 +11,7 @@ import { ModeShell } from './ModeShell';
|
|||
import { useModeRuntime } from './mode-runtime';
|
||||
import { XYPad } from '../primitives/XYPad';
|
||||
import { OutputDisplay } from '../primitives/OutputDisplay';
|
||||
import { SliderBank } from '../primitives/SliderBank';
|
||||
import { LossPlot } from '../primitives/LossPlot';
|
||||
import { paramsToSliderConfig, outputsToSliderValues } from './mode-helpers';
|
||||
import { PafSynthSchema } from './generated/paf_synth_schema';
|
||||
|
||||
export const PAFSynthMode: Component = () => {
|
||||
|
|
@ -21,8 +19,6 @@ export const PAFSynthMode: Component = () => {
|
|||
const runtime = useModeRuntime(schema);
|
||||
|
||||
const [voiceSpace, setVoiceSpace] = createSignal(0);
|
||||
const sliderConfig = paramsToSliderConfig(schema.params);
|
||||
const sliderValues = () => outputsToSliderValues(runtime.processedOutputs(), schema.params);
|
||||
|
||||
return (
|
||||
<ModeShell
|
||||
|
|
@ -30,18 +26,7 @@ export const PAFSynthMode: Component = () => {
|
|||
runtime={runtime}
|
||||
activeVoiceSpace={voiceSpace}
|
||||
onVoiceSpaceChange={setVoiceSpace}
|
||||
drawerTitle="PAF synth params"
|
||||
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.
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
drawerTitle="PAF synth settings"
|
||||
primaryInput={() => (
|
||||
<>
|
||||
<XYPad
|
||||
|
|
@ -59,7 +44,7 @@ export const PAFSynthMode: Component = () => {
|
|||
outputArea={() => (
|
||||
<>
|
||||
<OutputDisplay
|
||||
values={runtime.processedOutputs}
|
||||
values={runtime.paramOutputs}
|
||||
width={360}
|
||||
height={120}
|
||||
color="var(--accent)"
|
||||
|
|
|
|||
250
playground/src/modes/SettingsDrawer.module.css
Normal file
250
playground/src/modes/SettingsDrawer.module.css
Normal 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;
|
||||
}
|
||||
656
playground/src/modes/SettingsDrawer.tsx
Normal file
656
playground/src/modes/SettingsDrawer.tsx
Normal 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;
|
||||
|
|
@ -2,10 +2,15 @@
|
|||
* SoundAnalysisMIDIMode — sound analysis → MIDI CC output (audio_in input).
|
||||
*
|
||||
* Schema declares `primary_input: 'audio_in'` and `engine_id: 'thru'` (no
|
||||
* synthesis). The full firmware pipeline feeds audio analysis features
|
||||
* (pitch / aperiodicity / energy / brightness / etc.) into the first 6
|
||||
* input channels and joystick coords into the last 4. Stream 9 ships a
|
||||
* scaffold UI; mic capture + analysis wiring is a stream-10 task.
|
||||
* synthesis). Stream 10 wires the mic into channels 2..5 via ModeShell's
|
||||
* Mic button + the runtime's MicInput. The joystick is used as a fallback
|
||||
* for channels 0..1 and during testing without mic permissions.
|
||||
*
|
||||
* 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';
|
||||
|
|
@ -13,44 +18,18 @@ import { ModeShell } from './ModeShell';
|
|||
import { useModeRuntime } from './mode-runtime';
|
||||
import { VirtualJoystick } from '../primitives/VirtualJoystick';
|
||||
import { OutputDisplay } from '../primitives/OutputDisplay';
|
||||
import { SliderBank } from '../primitives/SliderBank';
|
||||
import { LossPlot } from '../primitives/LossPlot';
|
||||
import { paramsToSliderConfig, outputsToSliderValues } from './mode-helpers';
|
||||
import { SoundAnalysisMidiSchema } from './generated/sound_analysis_midi_schema';
|
||||
|
||||
export const SoundAnalysisMIDIMode: Component = () => {
|
||||
const schema = SoundAnalysisMidiSchema;
|
||||
const runtime = useModeRuntime(schema);
|
||||
const sliderConfig = paramsToSliderConfig(schema.params);
|
||||
const sliderValues = () => outputsToSliderValues(runtime.processedOutputs(), schema.params);
|
||||
|
||||
return (
|
||||
<ModeShell
|
||||
schema={schema}
|
||||
runtime={runtime}
|
||||
drawerTitle="MIDI CC routing"
|
||||
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>
|
||||
)}
|
||||
drawerTitle="Sound analysis → MIDI settings"
|
||||
primaryInput={() => (
|
||||
<>
|
||||
<Show
|
||||
|
|
@ -72,7 +51,7 @@ export const SoundAnalysisMIDIMode: Component = () => {
|
|||
gap: 'var(--sp-3)',
|
||||
padding: 'var(--sp-4)',
|
||||
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)',
|
||||
'min-width': '260px',
|
||||
'min-height': '200px',
|
||||
|
|
@ -81,11 +60,13 @@ export const SoundAnalysisMIDIMode: Component = () => {
|
|||
'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)' }}>
|
||||
Stream 10 will request `getUserMedia` and feed audio analysis
|
||||
features into the MLP. For now you can still drive the model
|
||||
manually with the joystick below.
|
||||
{runtime.mic.started()
|
||||
? 'Audio features → channels 2..5. Joystick below drives channels 0..1.'
|
||||
: 'Tap "Mic" in the header to feed mic features into the model.'}
|
||||
</span>
|
||||
<VirtualJoystick
|
||||
size={200}
|
||||
|
|
@ -100,7 +81,7 @@ export const SoundAnalysisMIDIMode: Component = () => {
|
|||
outputArea={() => (
|
||||
<>
|
||||
<OutputDisplay
|
||||
values={runtime.processedOutputs}
|
||||
values={runtime.paramOutputs}
|
||||
width={360}
|
||||
height={120}
|
||||
color="var(--info)"
|
||||
|
|
|
|||
|
|
@ -7,17 +7,13 @@ import { ModeShell } from './ModeShell';
|
|||
import { useModeRuntime } from './mode-runtime';
|
||||
import { VirtualJoystick } from '../primitives/VirtualJoystick';
|
||||
import { OutputDisplay } from '../primitives/OutputDisplay';
|
||||
import { SliderBank } from '../primitives/SliderBank';
|
||||
import { LossPlot } from '../primitives/LossPlot';
|
||||
import { paramsToSliderConfig, outputsToSliderValues } from './mode-helpers';
|
||||
import { VerbFxSchema } from './generated/verb_fx_schema';
|
||||
|
||||
export const VerbFXMode: Component = () => {
|
||||
const schema = VerbFxSchema;
|
||||
const runtime = useModeRuntime(schema);
|
||||
const [voiceSpace, setVoiceSpace] = createSignal(0);
|
||||
const sliderConfig = paramsToSliderConfig(schema.params);
|
||||
const sliderValues = () => outputsToSliderValues(runtime.processedOutputs(), schema.params);
|
||||
|
||||
return (
|
||||
<ModeShell
|
||||
|
|
@ -25,17 +21,7 @@ export const VerbFXMode: Component = () => {
|
|||
runtime={runtime}
|
||||
activeVoiceSpace={voiceSpace}
|
||||
onVoiceSpaceChange={setVoiceSpace}
|
||||
drawerTitle="Verb / FX params"
|
||||
drawerContent={() => (
|
||||
<SliderBank
|
||||
title="Live FX params"
|
||||
sliders={sliderConfig}
|
||||
values={sliderValues}
|
||||
onChange={() => {
|
||||
/* read-only */
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
drawerTitle="Verb / FX settings"
|
||||
primaryInput={() => (
|
||||
<>
|
||||
<VirtualJoystick
|
||||
|
|
@ -53,7 +39,7 @@ export const VerbFXMode: Component = () => {
|
|||
outputArea={() => (
|
||||
<>
|
||||
<OutputDisplay
|
||||
values={runtime.processedOutputs}
|
||||
values={runtime.paramOutputs}
|
||||
width={360}
|
||||
height={120}
|
||||
color="#b464ff"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
* the playground schema declares `primary_input: 'joystick'` and feeds the
|
||||
* MLP from joy_x/joy_y/joy_z/joy_w. The audio-reactive flavour is left to
|
||||
* stream 10 (mic input wiring).
|
||||
* Schema declares `primary_input: 'joystick'` and feeds the MLP from
|
||||
* joy_x/joy_y plus optional mic features. ModeShell exposes a Mic toggle
|
||||
* that opt-in feeds analysis features into channels 2..5.
|
||||
*/
|
||||
|
||||
import { Component } from 'solid-js';
|
||||
|
|
@ -12,32 +11,18 @@ import { ModeShell } from './ModeShell';
|
|||
import { useModeRuntime } from './mode-runtime';
|
||||
import { VirtualJoystick } from '../primitives/VirtualJoystick';
|
||||
import { OutputDisplay } from '../primitives/OutputDisplay';
|
||||
import { SliderBank } from '../primitives/SliderBank';
|
||||
import { LossPlot } from '../primitives/LossPlot';
|
||||
import { paramsToSliderConfig, outputsToSliderValues } from './mode-helpers';
|
||||
import { XiasriSchema } from './generated/xiasri_schema';
|
||||
|
||||
export const XIASRIMode: Component = () => {
|
||||
const schema = XiasriSchema;
|
||||
const runtime = useModeRuntime(schema);
|
||||
const sliderConfig = paramsToSliderConfig(schema.params);
|
||||
const sliderValues = () => outputsToSliderValues(runtime.processedOutputs(), schema.params);
|
||||
|
||||
return (
|
||||
<ModeShell
|
||||
schema={schema}
|
||||
runtime={runtime}
|
||||
drawerTitle="XIASRI params"
|
||||
drawerContent={() => (
|
||||
<SliderBank
|
||||
title="Live verb / pitch params"
|
||||
sliders={sliderConfig}
|
||||
values={sliderValues}
|
||||
onChange={() => {
|
||||
/* read-only */
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
drawerTitle="XIASRI settings"
|
||||
primaryInput={() => (
|
||||
<>
|
||||
<VirtualJoystick
|
||||
|
|
@ -47,14 +32,14 @@ export const XIASRIMode: Component = () => {
|
|||
position={runtime.pipedInput}
|
||||
/>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
outputArea={() => (
|
||||
<>
|
||||
<OutputDisplay
|
||||
values={runtime.processedOutputs}
|
||||
values={runtime.paramOutputs}
|
||||
width={360}
|
||||
height={120}
|
||||
color="var(--accent-2)"
|
||||
|
|
|
|||
|
|
@ -31,16 +31,28 @@ export function paramsToSliderConfig(params: ReadonlyArray<Param>): SliderConfig
|
|||
/**
|
||||
* 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).
|
||||
*
|
||||
* 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(
|
||||
outputs: Float32Array,
|
||||
params: ReadonlyArray<Param>,
|
||||
overrides?: Record<string, { min: number; max: number; muted?: boolean; fixedValue?: number }>,
|
||||
): number[] {
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i < params.length; ++i) {
|
||||
const v = outputs[i] ?? 0;
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,23 +6,30 @@
|
|||
* 1. Hold a primary 2D input position (joystick / xy-pad / external feed).
|
||||
* 2. Push it through the input pipeline.
|
||||
* 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
|
||||
* `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
|
||||
* `useModeRuntime(schema)` that owns the lifecycle and exposes reactive
|
||||
* accessors plus the `setInput(x, y)` driver. Modes only have to render a
|
||||
* primary input that calls `runtime.setInput(x, y)` and the runtime takes
|
||||
* care of everything downstream.
|
||||
* Stream 10 wires:
|
||||
* - Compound axes → underlying stores (input/output/exploration).
|
||||
* - Per-param overrides between MLP outputs and engine.
|
||||
* - Snapshot + undo + A/B compare on RL events.
|
||||
* - 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 { outputStore } from '../stores/output-store';
|
||||
import { coreBus } from '../stores/bus';
|
||||
import { processInput, defaultInputState, type InputState } from '../input/pipeline';
|
||||
import {
|
||||
processOutput,
|
||||
|
|
@ -33,6 +40,13 @@ import { EngineHost } from '../audio/engine-host';
|
|||
import type { EngineId } from '../ml/types';
|
||||
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
|
||||
* matches the legacy playground's C15 update cadence.
|
||||
|
|
@ -62,6 +76,12 @@ export interface ModeRuntime {
|
|||
/** Output-sliced + pipeline-processed vector (length = schema.output_size). */
|
||||
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. */
|
||||
ready: () => boolean;
|
||||
|
||||
|
|
@ -73,6 +93,13 @@ export interface ModeRuntime {
|
|||
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. */
|
||||
training: {
|
||||
busy: () => boolean;
|
||||
|
|
@ -88,6 +115,28 @@ export interface ModeRuntime {
|
|||
thumbsUp: () => void;
|
||||
thumbsDown: () => 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 {
|
||||
|
|
@ -124,12 +173,33 @@ export function useModeRuntime(
|
|||
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 --------------------------------------------
|
||||
const [pipedInput, setPipedInput] = createSignal<readonly [number, number]>([0.5, 0.5]);
|
||||
const [frozen, setFrozen] = createSignal(false);
|
||||
let inputState: InputState = defaultInputState();
|
||||
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 now = performance.now();
|
||||
const dt = Math.max(0.001, (now - lastFrameMs) / 1000);
|
||||
|
|
@ -140,24 +210,47 @@ export function useModeRuntime(
|
|||
inputStore.__setLiveState(result.state);
|
||||
setPipedInput([result.x, result.y]);
|
||||
setFrozen(result.frozen);
|
||||
trailRing.push(result.x, result.y);
|
||||
|
||||
if (!ready()) return;
|
||||
// Push input to the MLP. Channels beyond [x,y] are zeroed out — modes
|
||||
// with input_size > 2 currently aren't fed extra inputs (audio analysis
|
||||
// wiring is a stream-10 task).
|
||||
// Push input to the MLP. Channels beyond [x,y] are zeroed out (or
|
||||
// overridden with mic features in audio-input modes).
|
||||
const inSz = schema.ml.input_size;
|
||||
mlStore.setInput(0, result.x);
|
||||
if (inSz > 1) mlStore.setInput(1, result.y);
|
||||
// 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();
|
||||
|
||||
// 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();
|
||||
const sliceLen = schema.ml.output_size;
|
||||
const [processedOutputs, setProcessedOutputs] = createSignal<Float32Array>(
|
||||
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.
|
||||
|
|
@ -175,12 +268,52 @@ export function useModeRuntime(
|
|||
const result = processOutput(slice as Float32Array, outputStore.config, outputState, dtMs);
|
||||
outputState = result.state;
|
||||
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(() => {
|
||||
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) -------------------------------------------
|
||||
|
|
@ -195,7 +328,6 @@ export function useModeRuntime(
|
|||
pendingParams = null;
|
||||
return;
|
||||
}
|
||||
// Copy because EngineHost transfers the buffer.
|
||||
const copy = new Float32Array(pendingParams);
|
||||
pendingParams = null;
|
||||
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(() => {
|
||||
const out = processedOutputs();
|
||||
const out = paramOutputs();
|
||||
if (out.length === 0) return;
|
||||
if (!host.isStarted) return;
|
||||
scheduleParamFlush(out);
|
||||
|
|
@ -228,8 +360,7 @@ export function useModeRuntime(
|
|||
try {
|
||||
await host.start(engineId);
|
||||
setAudioStarted(true);
|
||||
// Push the current outputs immediately on start.
|
||||
const out = processedOutputs();
|
||||
const out = paramOutputs();
|
||||
if (out.length > 0) host.setParams(new Float32Array(out));
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
|
|
@ -258,56 +389,228 @@ export function useModeRuntime(
|
|||
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 -------------------------------------------------
|
||||
const trainOnCurrent = () => {
|
||||
if (!ready()) return;
|
||||
const lr = controlStore.resolveParams()['learningRate'];
|
||||
const lrNum = typeof lr === 'number' ? lr : schema.ml.default_learning_rate;
|
||||
mlStore.train(lrNum, schema.ml.default_max_iterations, 0.001);
|
||||
autoSnapshot('before train');
|
||||
const lr = explorationStore.state.learningRate;
|
||||
mlStore.train(lr, schema.ml.default_max_iterations, 0.001);
|
||||
};
|
||||
|
||||
const thumbsUp = () => {
|
||||
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 out = processedOutputs();
|
||||
const out = paramOutputs();
|
||||
if (out.length === 0) return;
|
||||
const features = new Array(schema.ml.input_size).fill(0);
|
||||
features[0] = x;
|
||||
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);
|
||||
explorationStore.decayNoise(explorationStore.state.pressureForce);
|
||||
trainOnCurrent();
|
||||
};
|
||||
|
||||
const thumbsDown = () => {
|
||||
if (!ready()) return;
|
||||
const params = controlStore.resolveParams();
|
||||
const cap = typeof params['noiseCap'] === 'number'
|
||||
? (params['noiseCap'] as number)
|
||||
: 0.12;
|
||||
const spread = schema.ml.default_spread;
|
||||
mlStore.moveWeights(cap, spread);
|
||||
// Re-run inference at current input so the visual updates.
|
||||
autoSnapshot('before thumbs-down');
|
||||
const cap = explorationStore.state.noiseCap;
|
||||
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(explorationStore.state.pressureForce);
|
||||
const [x, y] = pipedInput();
|
||||
setInput(x, y);
|
||||
};
|
||||
|
||||
const randomize = () => {
|
||||
if (!ready()) return;
|
||||
mlStore.drawWeights(schema.ml.default_spread);
|
||||
autoSnapshot('before randomize');
|
||||
mlStore.drawWeights(explorationStore.state.spread);
|
||||
const [x, y] = pipedInput();
|
||||
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 {
|
||||
setInput,
|
||||
pipedInput,
|
||||
frozen,
|
||||
rawOutputs: rawOutputsAccessor,
|
||||
processedOutputs,
|
||||
paramOutputs,
|
||||
ready,
|
||||
audio: {
|
||||
started: audioStarted,
|
||||
|
|
@ -315,6 +618,11 @@ export function useModeRuntime(
|
|||
stop: stopAudio,
|
||||
setMuted: (muted) => host.setMuted(muted),
|
||||
},
|
||||
mic: {
|
||||
started: micStarted,
|
||||
start: startMic,
|
||||
stop: stopMic,
|
||||
},
|
||||
training: {
|
||||
busy: () => mlStore.state.training,
|
||||
examples: () => mlStore.state.exampleCount,
|
||||
|
|
@ -325,6 +633,22 @@ export function useModeRuntime(
|
|||
thumbsUp,
|
||||
thumbsDown,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
254
playground/src/stores/exploration-store.ts
Normal file
254
playground/src/stores/exploration-store.ts
Normal 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;
|
||||
|
|
@ -30,4 +30,9 @@ export {
|
|||
type ABState,
|
||||
type SessionPreset,
|
||||
} from './session-store';
|
||||
export {
|
||||
explorationStore,
|
||||
type ExplorationStore,
|
||||
type ExplorationState,
|
||||
} from './exploration-store';
|
||||
export { schedulePersist, flushPersist, loadPersisted, clearPersisted } from './persistence';
|
||||
|
|
|
|||
|
|
@ -1,9 +1,16 @@
|
|||
/**
|
||||
* Session store — snapshot stack, A/B compare, region pins, named session presets.
|
||||
*
|
||||
* Stream 8 (this stream) provides the API and a working in-memory
|
||||
* implementation with stub data shapes. Stream 10 wires the snapshots to
|
||||
* real ML weights and surfaces the data through UI.
|
||||
* Stream 10 wires real ML weights into the snapshot stack and A/B state.
|
||||
* Snapshots store a Float32Array copy of the weights at the moment they
|
||||
* 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';
|
||||
|
|
@ -13,15 +20,15 @@ import { schedulePersist, loadPersisted } from './persistence';
|
|||
const STORAGE_KEY = 'nisps:session';
|
||||
const MAX_SNAPSHOTS = 20;
|
||||
|
||||
/** A single weights snapshot (placeholder until ML stream is live). */
|
||||
/** A single weights snapshot. */
|
||||
export interface Snapshot {
|
||||
id: string;
|
||||
tag: string;
|
||||
timestamp: number;
|
||||
noiseLevel: number;
|
||||
zoomLevel: number | null;
|
||||
/** Stream 7+ stores actual weights here (Float32Array via ArrayBuffer in JSON). */
|
||||
weightsRef: string | null;
|
||||
/** Float32Array weight copy. Null if not captured (e.g. before WASM ready). */
|
||||
weights: Float32Array | null;
|
||||
}
|
||||
|
||||
export interface RegionPin {
|
||||
|
|
@ -106,14 +113,17 @@ export const sessionStore = {
|
|||
|
||||
// ----- 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 = {
|
||||
id: nextSnapshotId(),
|
||||
tag,
|
||||
timestamp: Date.now(),
|
||||
noiseLevel: opts.noiseLevel ?? 0,
|
||||
zoomLevel: opts.zoomLevel ?? null,
|
||||
weightsRef: opts.weightsRef ?? null,
|
||||
weights: opts.weights ? new Float32Array(opts.weights) : null,
|
||||
};
|
||||
setState(produce((s) => {
|
||||
s.snapshots.push(snap);
|
||||
|
|
@ -125,6 +135,18 @@ export const sessionStore = {
|
|||
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 {
|
||||
let popped: Snapshot | null = null;
|
||||
setState(produce((s) => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue