+
+ props.runtime.heatmap.colorMode()}
+ onChange={(v) => props.runtime.heatmap.setColorMode(v as HeatmapColorMode)}
+ ariaLabel="Heatmap color mode"
+ />
+
+
+
+
+
+
Session preset
+
+ setPresetName(e.currentTarget.value)}
+ />
+
+
+ 0}>
+
+ {(p) => (
+
+ {p.name}
+
+
+
+ )}
+
+
+
+
+
+ e.currentTarget.select()}
+ />
+
+
+
+
+
+
+ );
+};
+
+export default SettingsDrawer;
diff --git a/playground/src/modes/SoundAnalysisMIDIMode.tsx b/playground/src/modes/SoundAnalysisMIDIMode.tsx
index d4296ee..5fc102c 100644
--- a/playground/src/modes/SoundAnalysisMIDIMode.tsx
+++ b/playground/src/modes/SoundAnalysisMIDIMode.tsx
@@ -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 (
(
-
- {
- /* read-only */
- }}
- />
-
- WebMIDI routing is wired up in stream 10. For now CC values are
- visible in the live readout.
-
-
- )}
+ drawerTitle="Sound analysis → MIDI settings"
primaryInput={() => (
<>
{
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',
}}
>
- Mic input — TODO
+
+ {runtime.mic.started() ? '🎤 Mic active' : 'Mic input'}
+
- 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.'}
{
outputArea={() => (
<>
{
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 (
{
runtime={runtime}
activeVoiceSpace={voiceSpace}
onVoiceSpaceChange={setVoiceSpace}
- drawerTitle="Verb / FX params"
- drawerContent={() => (
- {
- /* read-only */
- }}
- />
- )}
+ drawerTitle="Verb / FX settings"
primaryInput={() => (
<>
{
outputArea={() => (
<>
{
const schema = XiasriSchema;
const runtime = useModeRuntime(schema);
- const sliderConfig = paramsToSliderConfig(schema.params);
- const sliderValues = () => outputsToSliderValues(runtime.processedOutputs(), schema.params);
return (
(
- {
- /* read-only */
- }}
- />
- )}
+ drawerTitle="XIASRI settings"
primaryInput={() => (
<>
{
position={runtime.pipedInput}
/>
- Joystick → verb / pitch space. Mic input wiring is a stream-10 task.
+ Joystick → verb / pitch space. Enable mic for audio-reactive features.
>
)}
outputArea={() => (
<>
): 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,
+ overrides?: Record,
): 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;
}
diff --git a/playground/src/modes/mode-runtime.ts b/playground/src/modes/mode-runtime.ts
index bc30713..191a0f3 100644
--- a/playground/src/modes/mode-runtime.ts
+++ b/playground/src/modes/mode-runtime.ts
@@ -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;
+ stop: () => Promise;
+ };
+
/** 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;
+ /** Snap input back to a trail point. */
+ snapToTrail: (p: { x: number; y: number }) => void;
+
+ /** Heatmap sampler. Returns the underlying cells; modes pass to . */
+ 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([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);
- for (let i = 2; i < inSz; ++i) mlStore.setInput(i, 0);
+ // Mic features (if active) take channels 2..5.
+ if (mic.isRunning() && inSz > 2) {
+ const f = mic.getFeatures();
+ const fv = [f.energy, f.brightness, f.pitch, f.aperiodicity];
+ for (let i = 2; i < inSz; ++i) {
+ mlStore.setInput(i, fv[i - 2] ?? 0);
+ }
+ } else {
+ for (let i = 2; i < inSz; ++i) mlStore.setInput(i, 0);
+ }
mlStore.process();
+
+ // 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(
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(
+ 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('luminance');
+ const [heatmapCells, setHeatmapCells] = createSignal(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,
};
}
diff --git a/playground/src/stores/exploration-store.ts b/playground/src/stores/exploration-store.ts
new file mode 100644
index 0000000..bb6b985
--- /dev/null
+++ b/playground/src/stores/exploration-store.ts
@@ -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>(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(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): 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;
diff --git a/playground/src/stores/index.ts b/playground/src/stores/index.ts
index 5bbbfc8..8cac6df 100644
--- a/playground/src/stores/index.ts
+++ b/playground/src/stores/index.ts
@@ -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';
diff --git a/playground/src/stores/session-store.ts b/playground/src/stores/session-store.ts
index 90d8c01..c1ae573 100644
--- a/playground/src/stores/session-store.ts
+++ b/playground/src/stores/session-store.ts
@@ -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 {
+ return state.snapshots;
+ },
+
popSnapshot(): Snapshot | null {
let popped: Snapshot | null = null;
setState(produce((s) => {