feat(playground/modes): ModeShell + runtime + switcher scaffolding (meml-yt7)

Stream 9 (part 1/2) — shared infrastructure for the per-mode TSX layer.

  - ModeShell.tsx: common chrome (header, voice-space PillToggle, audio
    start/stop, control axes, training controls, drawer, status line).
    Modes only have to author primary input + output JSX.
  - mode-runtime.ts: useModeRuntime(schema) hook. Owns input pipeline,
    WASM ML calls, output pipeline, and a 50ms-throttled engine-host
    bridge. Exposes setInput/processedOutputs/training/audio surfaces
    so mode TSX stays declarative.
  - mode-helpers.ts: schema → SliderBank config + Float32Array →
    slider-range value mapping. Pure functions.
  - ModeSwitcher.tsx: top-of-page <select> wired to modeStore.

ModeShell and the runtime read controlStore (Boldness/Memory/Precision)
to derive learning rate + RL noise cap so axes affect training without
needing per-mode plumbing.
This commit is contained in:
w1n5t0n 2026-04-29 17:00:43 +03:00
parent 3021552a38
commit ebd5dba4c0
6 changed files with 845 additions and 0 deletions

View file

@ -0,0 +1,162 @@
.shell {
display: grid;
grid-template-rows: auto 1fr auto auto;
gap: var(--sp-4);
min-height: 100%;
padding: var(--sp-4);
background: var(--bg);
color: var(--fg);
}
.header {
display: flex;
align-items: center;
gap: var(--sp-3);
padding-bottom: var(--sp-3);
border-bottom: 1px solid var(--line);
}
.title {
font-size: var(--fs-lg);
font-weight: 600;
color: var(--accent);
margin: 0;
}
.subtitle {
font-size: var(--fs-sm);
color: var(--fg-mute);
margin: 0;
}
.audioToggle {
margin-left: auto;
display: flex;
align-items: center;
gap: var(--sp-2);
}
.audioBtn {
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: var(--r-1);
padding: var(--sp-2) var(--sp-3);
font-size: var(--fs-sm);
color: var(--fg);
cursor: pointer;
}
.audioBtn.on {
border-color: var(--accent);
color: var(--accent);
}
.audioBtn:hover {
background: var(--bg-3);
}
.voiceSpaces {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.voiceSpacesLabel {
font-size: var(--fs-xs);
color: var(--fg-mute);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.body {
display: grid;
grid-template-columns: minmax(320px, 1fr) minmax(280px, 380px);
gap: var(--sp-4);
align-items: start;
}
@media (max-width: 900px) {
.body {
grid-template-columns: 1fr;
}
}
.primaryArea {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-4);
background: var(--bg-1);
border: 1px solid var(--line);
border-radius: var(--r-2);
min-height: 280px;
}
.outputArea {
display: flex;
flex-direction: column;
gap: var(--sp-3);
padding: var(--sp-3);
background: var(--bg-1);
border: 1px solid var(--line);
border-radius: var(--r-2);
min-height: 200px;
}
.controls {
display: flex;
gap: var(--sp-4);
flex-wrap: wrap;
align-items: stretch;
padding: var(--sp-3);
background: var(--bg-1);
border: 1px solid var(--line);
border-radius: var(--r-2);
}
.controlAxes {
flex: 1 1 auto;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: var(--sp-3);
}
.trainingPanel {
flex: 0 0 auto;
}
.drawerToggleBar {
display: flex;
gap: var(--sp-2);
justify-content: flex-end;
}
.drawerToggleBtn {
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: var(--r-1);
padding: var(--sp-2) var(--sp-3);
cursor: pointer;
font-size: var(--fs-sm);
color: var(--fg-mute);
}
.drawerToggleBtn:hover {
color: var(--fg);
background: var(--bg-3);
}
.statusLine {
font-size: var(--fs-xs);
color: var(--fg-dim);
font-family: var(--font-mono);
}
.frozen {
color: var(--accent-2);
}
.notReady {
color: var(--warn);
}

View file

@ -0,0 +1,193 @@
/**
* 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).
* - Control axes bar (Boldness/Memory/Precision wired to controlStore).
* - Training controls (wired to the mode runtime).
* - Optional right-side drawer for mode-specific settings.
*
* 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 { TrainingControls } from '../primitives/TrainingControls';
import { ControlAxis } from '../primitives/ControlAxis';
import { PillToggle } from '../primitives/PillToggle';
import { Drawer } from '../primitives/Drawer';
import { controlStore } from '../stores/control-store';
import type { ModeRuntime } from './mode-runtime';
import type { ModeSchema } from './generated';
import styles from './ModeShell.module.css';
export interface ModeShellProps {
schema: ModeSchema;
runtime: ModeRuntime;
/** Primary input renderer (joystick / xy-pad / etc.). */
primaryInput: () => JSX.Element;
/** Output / visualisation area. */
outputArea: () => JSX.Element;
/** Optional drawer body for mode-specific settings. */
drawerContent?: () => JSX.Element;
drawerTitle?: string;
/** Active voice space index (only used if schema.voice_spaces is non-empty). */
activeVoiceSpace?: () => number;
onVoiceSpaceChange?: (idx: number) => void;
}
export const ModeShell: Component<ModeShellProps> = (props) => {
const [drawerOpen, setDrawerOpen] = createSignal(false);
const showVoiceSpaces = () =>
props.schema.ui.show_voice_space_selector && props.schema.voice_spaces.length > 0;
const voiceSpaceOptions = () =>
props.schema.voice_spaces.map((label, idx) => ({
value: String(idx),
label,
}));
return (
<section class={styles.shell} aria-label={`${props.schema.mode_id} mode`}>
<header class={styles.header}>
<div>
<h2 class={styles.title}>{formatModeName(props.schema.mode_id)}</h2>
<p class={styles.subtitle}>
{props.schema.ml.input_size} in {props.schema.ml.output_size} out
{' · '}
engine: <code>{props.schema.engine_id}</code>
</p>
</div>
<Show when={showVoiceSpaces()}>
<div class={styles.voiceSpaces}>
<span class={styles.voiceSpacesLabel}>Voice space</span>
<PillToggle
options={voiceSpaceOptions()}
value={() => String(props.activeVoiceSpace?.() ?? 0)}
onChange={(v) => props.onVoiceSpaceChange?.(Number(v))}
ariaLabel="Voice space"
/>
</div>
</Show>
<div class={styles.audioToggle}>
<Show
when={props.runtime.audio.started()}
fallback={
<button
type="button"
class={styles.audioBtn}
onClick={() => void props.runtime.audio.start()}
aria-label="Start audio engine"
>
Start audio
</button>
}
>
<button
type="button"
class={`${styles.audioBtn} ${styles.on}`}
onClick={() => void props.runtime.audio.stop()}
aria-label="Stop audio engine"
>
Stop audio
</button>
</Show>
<Show when={props.drawerContent}>
<button
type="button"
class={styles.drawerToggleBtn}
onClick={() => setDrawerOpen(true)}
aria-label="Open settings drawer"
>
</button>
</Show>
</div>
</header>
<div class={styles.body}>
<div class={styles.primaryArea}>{props.primaryInput()}</div>
<div class={styles.outputArea}>{props.outputArea()}</div>
</div>
<div class={styles.controls}>
<div class={styles.controlAxes}>
<For each={AXES}>
{(axis) => (
<ControlAxis
label={axis.label}
endpoints={axis.endpoints}
value={() => controlStore.state[axis.key]}
onChange={(v) => controlStore.setAxis(axis.key, v)}
preset={() => controlStore.state.presetId}
onDoubleTap={() => controlStore.clearOffsets(axis.key)}
/>
)}
</For>
</div>
<div class={styles.trainingPanel}>
<TrainingControls
onTrain={() => props.runtime.trainOnCurrent()}
onRandomize={() => props.runtime.randomize()}
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.
}}
exampleCount={() => props.runtime.training.examples()}
lastLoss={() => props.runtime.training.lastLoss()}
busy={() => props.runtime.training.busy()}
canUndo={() => false}
/>
</div>
</div>
<p class={styles.statusLine}>
<Show when={!props.runtime.ready()}>
<span class={styles.notReady}>Loading WASM ML</span>{' '}
</Show>
<Show when={props.runtime.frozen()}>
<span class={styles.frozen}>frozen</span>{' '}
</Show>
<span>
input ({props.runtime.pipedInput()[0].toFixed(2)},
{' '}
{props.runtime.pipedInput()[1].toFixed(2)})
</span>
</p>
<Show when={props.drawerContent}>
<Drawer
open={drawerOpen()}
onClose={() => setDrawerOpen(false)}
side="right"
title={props.drawerTitle ?? 'Mode settings'}
width={420}
>
{props.drawerContent!()}
</Drawer>
</Show>
</section>
);
};
const AXES = [
{ key: 'boldness' as const, label: 'Boldness', endpoints: ['Caution', 'Bold'] as const },
{ key: 'memory' as const, label: 'Memory', endpoints: ['Amnesia', 'Elephant'] as const },
{ key: 'precision' as const, label: 'Precision', endpoints: ['Raw', 'Precise'] as const },
];
function formatModeName(id: string): string {
return id
.split('_')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
export default ModeShell;

View file

@ -0,0 +1,43 @@
.bar {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-2) var(--sp-4);
background: var(--bg-1);
border-bottom: 1px solid var(--line);
}
.label {
font-size: var(--fs-xs);
color: var(--fg-mute);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.select {
background: var(--bg-2);
color: var(--fg);
border: 1px solid var(--line);
border-radius: var(--r-1);
padding: var(--sp-1) var(--sp-3);
font-family: var(--font-mono);
font-size: var(--fs-sm);
cursor: pointer;
}
.select:hover {
border-color: var(--line-strong);
}
.select:focus {
outline: 1px solid var(--accent);
}
.description {
font-size: var(--fs-xs);
color: var(--fg-dim);
}
.placeholder {
color: var(--warn);
}

View file

@ -0,0 +1,54 @@
/**
* ModeSwitcher top-level select that picks the active mode.
*
* Writes through `modeStore.switchMode(id)` so persistence + the bus event
* fire correctly. Reads the current selection back from the store so it
* stays in sync with persisted state on first paint.
*
* Audio engine switching is handled inside the mode runtime (each mode
* routes its own engine_id through `EngineHost.setEngine` when started).
*/
import { Component, createMemo, For, Show } from 'solid-js';
import { modeStore } from '../stores/mode-store';
import { MODE_REGISTRY, getModeById } from './index';
import styles from './ModeSwitcher.module.css';
export const ModeSwitcher: Component = () => {
const activeId = () => modeStore.state.activeModeId ?? MODE_REGISTRY[0]!.id;
const active = createMemo(() => getModeById(activeId()));
return (
<div class={styles.bar} role="region" aria-label="Mode selector">
<span class={styles.label}>Mode</span>
<select
class={styles.select}
value={activeId()}
onChange={(e) => modeStore.switchMode(e.currentTarget.value)}
aria-label="Select active mode"
>
<For each={MODE_REGISTRY}>
{(m) => (
<option value={m.id}>
{m.label}
{m.placeholder ? ' (TODO)' : ''}
</option>
)}
</For>
</select>
<span
class={styles.description}
classList={{ [styles.placeholder]: !!active().placeholder }}
>
{active().description}
</span>
<Show when={active().placeholder}>
<span class={styles.placeholder} aria-hidden="true">
</span>
</Show>
</div>
);
};
export default ModeSwitcher;

View file

@ -0,0 +1,54 @@
/**
* Helpers shared across mode TSX files. Pure functions; no Solid state.
*/
import type { Param } from './generated/types';
import type { SliderConfig } from '../primitives/SliderBank';
import type { CurveName } from '../output/curves';
/**
* Convert a schema param list into SliderConfig entries that the SliderBank
* primitive understands. Sliders are grouped by the schema's `group` field
* so the bank renders collapsible sections.
*/
export function paramsToSliderConfig(params: ReadonlyArray<Param>): SliderConfig[] {
let lastGroup: string | null = null;
return params.map((p) => {
const isNewGroup = p.group !== lastGroup;
lastGroup = p.group;
const cfg: SliderConfig = {
id: p.name,
label: p.label,
min: p.min,
max: p.max,
curve: p.curve as CurveName,
};
if (isNewGroup) cfg.section = formatGroupName(p.group);
return cfg;
});
}
/**
* 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).
*/
export function outputsToSliderValues(
outputs: Float32Array,
params: ReadonlyArray<Param>,
): 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));
}
return out;
}
function formatGroupName(group: string): string {
if (!group) return '';
return group
.split(/[_\s]+/)
.map((p) => p.charAt(0).toUpperCase() + p.slice(1))
.join(' ');
}

View file

@ -0,0 +1,339 @@
/**
* Mode runtime shared wiring between mode TSX components and the
* playground's stores / WASM ML / audio engine host.
*
* Every mode does the same dance:
* 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.
* 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.
*
* 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.
*/
import { createEffect, createSignal, onCleanup, onMount } from 'solid-js';
import { mlStore, modeStore, controlStore } from '../stores';
import { inputStore } from '../stores/input-store';
import { outputStore } from '../stores/output-store';
import { processInput, defaultInputState, type InputState } from '../input/pipeline';
import {
processOutput,
defaultOutputState,
type OutputState,
} from '../output/pipeline';
import { EngineHost } from '../audio/engine-host';
import type { EngineId } from '../ml/types';
import type { ModeSchema } from './generated';
/**
* Throttle interval for engine param updates (ms). 50ms 20fps which
* matches the legacy playground's C15 update cadence.
*/
const ENGINE_PARAM_THROTTLE_MS = 50;
/** A single shared EngineHost. Audio only starts on user gesture. */
let engineHost: EngineHost | null = null;
function getEngineHost(): EngineHost {
if (!engineHost) engineHost = new EngineHost();
return engineHost;
}
export interface ModeRuntime {
/** Driver — call from joystick/xy-pad/etc. */
setInput: (x: number, y: number) => void;
/** Most recent processed input (after pipeline). */
pipedInput: () => readonly [number, number];
/** Whether the input is currently frozen by zoom. */
frozen: () => boolean;
/** Raw 126-output ML vector (live). */
rawOutputs: () => Float32Array;
/** Output-sliced + pipeline-processed vector (length = schema.output_size). */
processedOutputs: () => Float32Array;
/** True iff WASM has loaded and the MLP is ready. */
ready: () => boolean;
/** Audio host control. */
audio: {
started: () => boolean;
start: () => Promise<void>;
stop: () => Promise<void>;
setMuted: (muted: boolean) => void;
};
/** Loss / training plumbing surfaced from mlStore. */
training: {
busy: () => boolean;
examples: () => number;
lastLoss: () => number | null;
lossHistory: () => ReadonlyArray<number>;
};
/** Trigger a sync train + push the current pipeline-processed sample. */
trainOnCurrent: () => void;
/** RL callbacks. */
thumbsUp: () => void;
thumbsDown: () => void;
randomize: () => void;
}
interface RuntimeOptions {
/** Override the engine id (defaults to schema.engine_id). */
engineOverride?: EngineId;
/** Skip starting the audio engine even on user gesture. */
audioDisabled?: boolean;
}
/**
* Create the runtime for a given mode schema. Call from inside a Solid
* component (uses createSignal/onCleanup).
*/
export function useModeRuntime(
schema: ModeSchema,
opts: RuntimeOptions = {},
): ModeRuntime {
// ----- WASM init ---------------------------------------------------------
const [ready, setReady] = createSignal(mlStore.state.ready);
// Lazy initialise WASM. Idempotent across remounts.
void mlStore
.initialize(schema.ml.input_size, schema.ml.output_size)
.then(() => setReady(true))
.catch((err) => {
// Best-effort; UI keeps running without ML.
// eslint-disable-next-line no-console
console.error('[mode-runtime] mlStore.initialize failed:', err);
});
// ----- Mode-store side effects ------------------------------------------
// Make sure the active mode in the store matches what's actually rendered.
if (modeStore.state.activeModeId !== schema.mode_id) {
modeStore.switchMode(schema.mode_id);
}
// ----- 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();
const setInput = (rawX: number, rawY: number): void => {
const now = performance.now();
const dt = Math.max(0.001, (now - lastFrameMs) / 1000);
lastFrameMs = now;
const result = processInput([rawX, rawY], inputStore.config, inputState, dt);
inputState = result.state;
inputStore.__setLiveState(result.state);
setPipedInput([result.x, result.y]);
setFrozen(result.frozen);
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).
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);
mlStore.process();
};
// ----- Output pipeline state -------------------------------------------
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
);
// Run the output pipeline whenever raw outputs change.
const rawOutputsAccessor = mlStore.outputs;
let lastOutFrameMs = performance.now();
const recomputeOutputs = () => {
const raw = rawOutputsAccessor();
if (raw.length === 0) return;
const now = performance.now();
const dtMs = Math.max(1, now - lastOutFrameMs);
lastOutFrameMs = now;
// Slice to mode's output_size up front.
const slice = raw.length === sliceLen ? raw : raw.subarray(0, sliceLen);
const result = processOutput(slice as Float32Array, outputStore.config, outputState, dtMs);
outputState = result.state;
setProcessedOutputs(result.processed);
};
// Trigger recompute on any raw-output change.
createEffect(() => {
rawOutputsAccessor();
recomputeOutputs();
});
// ----- Engine wiring (audio) -------------------------------------------
const host = getEngineHost();
const [audioStarted, setAudioStarted] = createSignal(host.isStarted);
let pendingParams: Float32Array | null = null;
let throttleTimer: number | null = null;
const flushParams = () => {
throttleTimer = null;
if (!pendingParams || !host.isStarted) {
pendingParams = null;
return;
}
// Copy because EngineHost transfers the buffer.
const copy = new Float32Array(pendingParams);
pendingParams = null;
try {
host.setParams(copy);
} catch (err) {
// eslint-disable-next-line no-console
console.warn('[mode-runtime] setParams failed:', err);
}
};
const scheduleParamFlush = (params: Float32Array) => {
pendingParams = params;
if (throttleTimer === null) {
throttleTimer = window.setTimeout(flushParams, ENGINE_PARAM_THROTTLE_MS);
}
};
// Pipe processedOutputs into the engine host whenever they change.
createEffect(() => {
const out = processedOutputs();
if (out.length === 0) return;
if (!host.isStarted) return;
scheduleParamFlush(out);
});
const engineId = (opts.engineOverride ?? (schema.engine_id as EngineId));
const startAudio = async (): Promise<void> => {
if (opts.audioDisabled) return;
try {
await host.start(engineId);
setAudioStarted(true);
// Push the current outputs immediately on start.
const out = processedOutputs();
if (out.length > 0) host.setParams(new Float32Array(out));
} catch (err) {
// eslint-disable-next-line no-console
console.error('[mode-runtime] audio start failed:', err);
}
};
const stopAudio = async (): Promise<void> => {
try {
await host.stop();
} finally {
setAudioStarted(false);
}
};
// Switch engine if mode changes engine_id (e.g. on remount).
onMount(() => {
if (host.isStarted) host.setEngine(engineId);
});
onCleanup(() => {
if (throttleTimer !== null) {
clearTimeout(throttleTimer);
throttleTimer = null;
}
pendingParams = null;
});
// ----- 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);
};
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();
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);
mlStore.addExample(features, labels);
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.
const [x, y] = pipedInput();
setInput(x, y);
};
const randomize = () => {
if (!ready()) return;
mlStore.drawWeights(schema.ml.default_spread);
const [x, y] = pipedInput();
setInput(x, y);
};
return {
setInput,
pipedInput,
frozen,
rawOutputs: rawOutputsAccessor,
processedOutputs,
ready,
audio: {
started: audioStarted,
start: startAudio,
stop: stopAudio,
setMuted: (muted) => host.setMuted(muted),
},
training: {
busy: () => mlStore.state.training,
examples: () => mlStore.state.exampleCount,
lastLoss: () => mlStore.state.lastLoss,
lossHistory: () => mlStore.state.lossHistory,
},
trainOnCurrent,
thumbsUp,
thumbsDown,
randomize,
};
}
/**
* Disposes the shared EngineHost. Test helper production never calls this.
*/
export function __disposeEngineHost(): void {
if (engineHost) {
engineHost.dispose();
engineHost = null;
}
}