diff --git a/playground/src/modes/ModeShell.module.css b/playground/src/modes/ModeShell.module.css new file mode 100644 index 0000000..11568a2 --- /dev/null +++ b/playground/src/modes/ModeShell.module.css @@ -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); +} diff --git a/playground/src/modes/ModeShell.tsx b/playground/src/modes/ModeShell.tsx new file mode 100644 index 0000000..35f4cf3 --- /dev/null +++ b/playground/src/modes/ModeShell.tsx @@ -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 = (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 ( +
+
+
+

{formatModeName(props.schema.mode_id)}

+

+ {props.schema.ml.input_size} in → {props.schema.ml.output_size} out + {' · '} + engine: {props.schema.engine_id} +

+
+ + +
+ Voice space + String(props.activeVoiceSpace?.() ?? 0)} + onChange={(v) => props.onVoiceSpaceChange?.(Number(v))} + ariaLabel="Voice space" + /> +
+
+ +
+ void props.runtime.audio.start()} + aria-label="Start audio engine" + > + ▶ Start audio + + } + > + + + + + +
+
+ +
+
{props.primaryInput()}
+
{props.outputArea()}
+
+ +
+
+ + {(axis) => ( + controlStore.state[axis.key]} + onChange={(v) => controlStore.setAxis(axis.key, v)} + preset={() => controlStore.state.presetId} + onDoubleTap={() => controlStore.clearOffsets(axis.key)} + /> + )} + +
+
+ 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} + /> +
+
+ +

+ + Loading WASM ML…{' '} + + + frozen{' '} + + + input ({props.runtime.pipedInput()[0].toFixed(2)}, + {' '} + {props.runtime.pipedInput()[1].toFixed(2)}) + +

+ + + setDrawerOpen(false)} + side="right" + title={props.drawerTitle ?? 'Mode settings'} + width={420} + > + {props.drawerContent!()} + + +
+ ); +}; + +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; diff --git a/playground/src/modes/ModeSwitcher.module.css b/playground/src/modes/ModeSwitcher.module.css new file mode 100644 index 0000000..c226fb7 --- /dev/null +++ b/playground/src/modes/ModeSwitcher.module.css @@ -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); +} diff --git a/playground/src/modes/ModeSwitcher.tsx b/playground/src/modes/ModeSwitcher.tsx new file mode 100644 index 0000000..2f10235 --- /dev/null +++ b/playground/src/modes/ModeSwitcher.tsx @@ -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 ( +
+ Mode + + + {active().description} + + + + +
+ ); +}; + +export default ModeSwitcher; diff --git a/playground/src/modes/mode-helpers.ts b/playground/src/modes/mode-helpers.ts new file mode 100644 index 0000000..652d90d --- /dev/null +++ b/playground/src/modes/mode-helpers.ts @@ -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): 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, +): 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(' '); +} diff --git a/playground/src/modes/mode-runtime.ts b/playground/src/modes/mode-runtime.ts new file mode 100644 index 0000000..bc30713 --- /dev/null +++ b/playground/src/modes/mode-runtime.ts @@ -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; + stop: () => Promise; + setMuted: (muted: boolean) => void; + }; + + /** Loss / training plumbing surfaced from mlStore. */ + training: { + busy: () => boolean; + examples: () => number; + lastLoss: () => number | null; + lossHistory: () => ReadonlyArray; + }; + + /** 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([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( + 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 => { + 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 => { + 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; + } +}