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:
parent
3021552a38
commit
ebd5dba4c0
6 changed files with 845 additions and 0 deletions
162
playground/src/modes/ModeShell.module.css
Normal file
162
playground/src/modes/ModeShell.module.css
Normal 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);
|
||||
}
|
||||
193
playground/src/modes/ModeShell.tsx
Normal file
193
playground/src/modes/ModeShell.tsx
Normal 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;
|
||||
43
playground/src/modes/ModeSwitcher.module.css
Normal file
43
playground/src/modes/ModeSwitcher.module.css
Normal 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);
|
||||
}
|
||||
54
playground/src/modes/ModeSwitcher.tsx
Normal file
54
playground/src/modes/ModeSwitcher.tsx
Normal 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;
|
||||
54
playground/src/modes/mode-helpers.ts
Normal file
54
playground/src/modes/mode-helpers.ts
Normal 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(' ');
|
||||
}
|
||||
339
playground/src/modes/mode-runtime.ts
Normal file
339
playground/src/modes/mode-runtime.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue