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