merge stream 9: SolidJS modes + ModeShell + runtime (meml-yt7)

This commit is contained in:
w1n5t0n 2026-04-29 17:07:05 +03:00
commit 2bc422a880
17 changed files with 1645 additions and 7 deletions

View file

@ -1,11 +1,16 @@
import { Component, createSignal, lazy, onCleanup, Show } from 'solid-js';
import { Component, createMemo, createSignal, lazy, onCleanup, Show } from 'solid-js';
import { Dynamic } from 'solid-js/web';
import { modeStore } from './stores/mode-store';
import { MODE_REGISTRY, getModeById } from './modes';
import { ModeSwitcher } from './modes/ModeSwitcher';
import styles from './App.module.css';
type Route = 'home' | 'primitives' | 'unknown';
type Route = 'home' | 'primitives' | 'modes' | 'unknown';
function parseRoute(path: string): Route {
if (path === '' || path === '/' || path === '/index.html') return 'home';
if (path === '/dev/primitives' || path === '/dev/primitives/') return 'primitives';
if (path === '/modes' || path === '/modes/' || path.startsWith('/modes/')) return 'modes';
return 'unknown';
}
@ -39,6 +44,13 @@ const App: Component = () => {
>
home
</button>
<button
type="button"
class={route() === 'modes' ? styles.active : ''}
onClick={() => navigate('/modes')}
>
/modes
</button>
<button
type="button"
class={route() === 'primitives' ? styles.active : ''}
@ -50,7 +62,10 @@ const App: Component = () => {
</header>
<main class={styles.main}>
<Show when={route() === 'home'}>
<Home />
<Home navigate={navigate} />
</Show>
<Show when={route() === 'modes'}>
<ModesPage />
</Show>
<Show when={route() === 'primitives'}>
<PrimitivesShowcase />
@ -69,18 +84,59 @@ const App: Component = () => {
);
};
const Home: Component = () => {
const ModesPage: Component = () => {
const activeId = () => modeStore.state.activeModeId ?? MODE_REGISTRY[0]!.id;
const ActiveComponent = createMemo(() => getModeById(activeId()).Component);
return (
<div>
<ModeSwitcher />
<Dynamic component={ActiveComponent()} />
</div>
);
};
interface HomeProps {
navigate: (path: string) => void;
}
const Home: Component<HomeProps> = (props) => {
return (
<div class={styles.home}>
<h1 class={styles.title}>MEMLNaut Playground</h1>
<p class={styles.tagline}>
Interactive ML control of audio. SolidJS scaffold modes coming online in stream 9.
Interactive ML control of audio. Stream 9: nine modes, one shell.
</p>
<ul class={styles.linkList}>
<li><a href="/dev/primitives" onClick={(e) => { e.preventDefault(); window.history.pushState({}, '', '/dev/primitives'); window.dispatchEvent(new PopStateEvent('popstate')); }}>Primitives showcase</a> UI building blocks</li>
<li>
<a
href="/modes"
onClick={(e) => {
e.preventDefault();
props.navigate('/modes');
}}
>
Modes
</a>{' '}
pick a mode and start playing
</li>
<li>
<a
href="/dev/primitives"
onClick={(e) => {
e.preventDefault();
props.navigate('/dev/primitives');
}}
>
Primitives showcase
</a>{' '}
UI building blocks
</li>
</ul>
<p class={styles.note}>
This is a fresh scaffold. ML, WASM, and audio engines are not yet wired up.
ML inference runs on the main thread via WASM. Audio synthesis runs
in an AudioWorklet start it from inside any mode using the "Start
audio" button. Audio cannot autoplay (browser policy).
</p>
</div>
);

View file

@ -0,0 +1,64 @@
/**
* BreakOrMode drum/breakbeat synthesis (xy_pad input, 56 outputs).
*/
import { Component } from 'solid-js';
import { ModeShell } from './ModeShell';
import { useModeRuntime } from './mode-runtime';
import { XYPad } from '../primitives/XYPad';
import { OutputDisplay } from '../primitives/OutputDisplay';
import { SliderBank } from '../primitives/SliderBank';
import { LossPlot } from '../primitives/LossPlot';
import { paramsToSliderConfig, outputsToSliderValues } from './mode-helpers';
import { BreakorSchema } from './generated/breakor_schema';
export const BreakOrMode: Component = () => {
const schema = BreakorSchema;
const runtime = useModeRuntime(schema);
const sliderConfig = paramsToSliderConfig(schema.params);
const sliderValues = () => outputsToSliderValues(runtime.processedOutputs(), schema.params);
return (
<ModeShell
schema={schema}
runtime={runtime}
drawerTitle="Breakor params"
drawerContent={() => (
<SliderBank
title="Live drum params"
sliders={sliderConfig}
values={sliderValues}
onChange={() => {
/* read-only */
}}
/>
)}
primaryInput={() => (
<>
<XYPad
size={280}
ariaLabel="Breakor pad"
onMove={(x, y) => runtime.setInput(x, y)}
position={runtime.pipedInput}
/>
<span style={{ 'font-size': 'var(--fs-xs)', color: 'var(--fg-mute)' }}>
Drag through drum space.
</span>
</>
)}
outputArea={() => (
<>
<OutputDisplay
values={runtime.processedOutputs}
width={360}
height={120}
color="var(--good)"
/>
<LossPlot history={runtime.training.lossHistory} width={360} height={70} />
</>
)}
/>
);
};
export default BreakOrMode;

View file

@ -0,0 +1,48 @@
/**
* C15Mode browser-only stub.
*
* The C15 (Nonlinear Labs C15) WASM synthesizer is not yet ported into the
* SolidJS playground; only firmware modes have schemas + WASM engines so
* far. This file exists so the mode switcher can list all eight firmware
* modes plus a "C15" entry that surfaces a clear "TODO" rather than 404'ing.
*
* Stream 11+ is expected to add a `c15` schema and wire the existing
* `playground/c15` directory into `nisps/wasm/engines/`.
*/
import { Component } from 'solid-js';
export const C15Mode: Component = () => {
return (
<section
style={{
display: 'flex',
'flex-direction': 'column',
'align-items': 'center',
gap: 'var(--sp-4)',
padding: 'var(--sp-6)',
margin: 'var(--sp-5) auto',
'max-width': '720px',
'background': 'var(--bg-1)',
border: '1px dashed var(--line-strong)',
'border-radius': 'var(--r-3)',
color: 'var(--fg)',
'text-align': 'center',
}}
>
<h2 style={{ color: 'var(--accent-2)', margin: 0 }}>C15 mode TODO</h2>
<p style={{ color: 'var(--fg-mute)', margin: 0 }}>
The C15 WASM synthesizer hasn't been ported into the SolidJS rewrite
yet. The legacy bridge lives in <code>playground/c15/</code> and
<code>playground/js/synth/c15-bridge.js</code>; stream 11+ will wrap
it as an AudioWorklet engine alongside the firmware-derived ones.
</p>
<p style={{ color: 'var(--fg-dim)', 'font-size': 'var(--fs-sm)', margin: 0 }}>
Until then, pick one of the firmware modes (PAFSynth, Channel Strip,
Verb FX, etc.) from the mode switcher above.
</p>
</section>
);
};
export default C15Mode;

View file

@ -0,0 +1,68 @@
/**
* ChannelStripMode channel-strip processor controlled by joystick.
*
* 24 outputs feed EQ / dynamics / gain. Schema has no voice spaces so the
* shell omits the selector. Drawer shows the live param sliders.
*/
import { Component } from 'solid-js';
import { ModeShell } from './ModeShell';
import { useModeRuntime } from './mode-runtime';
import { VirtualJoystick } from '../primitives/VirtualJoystick';
import { OutputDisplay } from '../primitives/OutputDisplay';
import { SliderBank } from '../primitives/SliderBank';
import { LossPlot } from '../primitives/LossPlot';
import { paramsToSliderConfig, outputsToSliderValues } from './mode-helpers';
import { ChannelStripSchema } from './generated/channel_strip_schema';
export const ChannelStripMode: Component = () => {
const schema = ChannelStripSchema;
const runtime = useModeRuntime(schema);
const sliderConfig = paramsToSliderConfig(schema.params);
const sliderValues = () => outputsToSliderValues(runtime.processedOutputs(), schema.params);
return (
<ModeShell
schema={schema}
runtime={runtime}
drawerTitle="Channel strip params"
drawerContent={() => (
<SliderBank
title="Live parameter values"
sliders={sliderConfig}
values={sliderValues}
onChange={() => {
/* read-only for now */
}}
/>
)}
primaryInput={() => (
<>
<VirtualJoystick
size={260}
ariaLabel="Channel strip joystick"
onMove={(x, y) => runtime.setInput(x, y)}
position={runtime.pipedInput}
/>
<span style={{ 'font-size': 'var(--fs-xs)', color: 'var(--fg-mute)' }}>
Mix EQ + dynamics by moving the joystick.
</span>
</>
)}
outputArea={() => (
<>
<OutputDisplay
values={runtime.processedOutputs}
width={360}
height={120}
color="var(--accent-3)"
/>
<LossPlot history={runtime.training.lossHistory} width={360} height={70} />
</>
)}
/>
);
};
export default ChannelStripMode;

View file

@ -0,0 +1,64 @@
/**
* ElysiamorfMode granular / morphing synth (xy_pad input, 40 outputs).
*/
import { Component } from 'solid-js';
import { ModeShell } from './ModeShell';
import { useModeRuntime } from './mode-runtime';
import { XYPad } from '../primitives/XYPad';
import { OutputDisplay } from '../primitives/OutputDisplay';
import { SliderBank } from '../primitives/SliderBank';
import { LossPlot } from '../primitives/LossPlot';
import { paramsToSliderConfig, outputsToSliderValues } from './mode-helpers';
import { ElysiamorfSchema } from './generated/elysiamorf_schema';
export const ElysiamorfMode: Component = () => {
const schema = ElysiamorfSchema;
const runtime = useModeRuntime(schema);
const sliderConfig = paramsToSliderConfig(schema.params);
const sliderValues = () => outputsToSliderValues(runtime.processedOutputs(), schema.params);
return (
<ModeShell
schema={schema}
runtime={runtime}
drawerTitle="Elysiamorf params"
drawerContent={() => (
<SliderBank
title="Live morph params"
sliders={sliderConfig}
values={sliderValues}
onChange={() => {
/* read-only */
}}
/>
)}
primaryInput={() => (
<>
<XYPad
size={280}
ariaLabel="Elysiamorf pad"
onMove={(x, y) => runtime.setInput(x, y)}
position={runtime.pipedInput}
/>
<span style={{ 'font-size': 'var(--fs-xs)', color: 'var(--fg-mute)' }}>
Drag to morph through grain space.
</span>
</>
)}
outputArea={() => (
<>
<OutputDisplay
values={runtime.processedOutputs}
width={360}
height={120}
color="#ffb060"
/>
<LossPlot history={runtime.training.lossHistory} width={360} height={70} />
</>
)}
/>
);
};
export default ElysiamorfMode;

View file

@ -0,0 +1,68 @@
/**
* MEMLCeliumMode uSEQ-Celium dual-MLP CV/gate output (56 outputs).
*/
import { Component, createSignal } from 'solid-js';
import { ModeShell } from './ModeShell';
import { useModeRuntime } from './mode-runtime';
import { XYPad } from '../primitives/XYPad';
import { OutputDisplay } from '../primitives/OutputDisplay';
import { SliderBank } from '../primitives/SliderBank';
import { LossPlot } from '../primitives/LossPlot';
import { paramsToSliderConfig, outputsToSliderValues } from './mode-helpers';
import { MemlceliumSchema } from './generated/memlcelium_schema';
export const MEMLCeliumMode: Component = () => {
const schema = MemlceliumSchema;
const runtime = useModeRuntime(schema);
const [voiceSpace, setVoiceSpace] = createSignal(0);
const sliderConfig = paramsToSliderConfig(schema.params);
const sliderValues = () => outputsToSliderValues(runtime.processedOutputs(), schema.params);
return (
<ModeShell
schema={schema}
runtime={runtime}
activeVoiceSpace={voiceSpace}
onVoiceSpaceChange={setVoiceSpace}
drawerTitle="MEMLCelium voice + CV"
drawerContent={() => (
<SliderBank
title="Live voice + CV params"
sliders={sliderConfig}
values={sliderValues}
onChange={() => {
/* read-only */
}}
/>
)}
primaryInput={() => (
<>
<XYPad
size={280}
ariaLabel="MEMLCelium pad"
onMove={(x, y) => runtime.setInput(x, y)}
position={runtime.pipedInput}
/>
<span style={{ 'font-size': 'var(--fs-xs)', color: 'var(--fg-mute)' }}>
Sculpt voice + CV/gate. CV/gate streaming over USB serial requires
firmware (browser preview only).
</span>
</>
)}
outputArea={() => (
<>
<OutputDisplay
values={runtime.processedOutputs}
width={360}
height={120}
color="#5b9eef"
/>
<LossPlot history={runtime.training.lossHistory} width={360} height={70} />
</>
)}
/>
);
};
export default MEMLCeliumMode;

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,74 @@
/**
* PAFSynthMode Phase Aligned Formant synth (XY pad input, 33 outputs).
*
* Uses the xy_pad as primary input (as per schema). Voice spaces are
* exposed via the shell header. A drawer renders the SliderBank for
* monitoring (and eventually editing) per-parameter values.
*/
import { Component, createSignal } from 'solid-js';
import { ModeShell } from './ModeShell';
import { useModeRuntime } from './mode-runtime';
import { XYPad } from '../primitives/XYPad';
import { OutputDisplay } from '../primitives/OutputDisplay';
import { SliderBank } from '../primitives/SliderBank';
import { LossPlot } from '../primitives/LossPlot';
import { paramsToSliderConfig, outputsToSliderValues } from './mode-helpers';
import { PafSynthSchema } from './generated/paf_synth_schema';
export const PAFSynthMode: Component = () => {
const schema = PafSynthSchema;
const runtime = useModeRuntime(schema);
const [voiceSpace, setVoiceSpace] = createSignal(0);
const sliderConfig = paramsToSliderConfig(schema.params);
const sliderValues = () => outputsToSliderValues(runtime.processedOutputs(), schema.params);
return (
<ModeShell
schema={schema}
runtime={runtime}
activeVoiceSpace={voiceSpace}
onVoiceSpaceChange={setVoiceSpace}
drawerTitle="PAF synth params"
drawerContent={() => (
<SliderBank
title="Live parameter values"
sliders={sliderConfig}
values={sliderValues}
onChange={() => {
// Sliders are display-only here. Stream 10 wires the per-param
// override editor which writes through modeStore.setOverride.
}}
/>
)}
primaryInput={() => (
<>
<XYPad
size={280}
ariaLabel="PAF synth control pad"
onMove={(x, y) => runtime.setInput(x, y)}
position={runtime.pipedInput}
/>
<span style={{ 'font-size': 'var(--fs-xs)', color: 'var(--fg-mute)' }}>
Drag to sculpt formants. Voice space:&nbsp;
<strong>{schema.voice_spaces[voiceSpace()] ?? 'Default'}</strong>
</span>
</>
)}
outputArea={() => (
<>
<OutputDisplay
values={runtime.processedOutputs}
width={360}
height={120}
color="var(--accent)"
/>
<LossPlot history={runtime.training.lossHistory} width={360} height={70} />
</>
)}
/>
);
};
export default PAFSynthMode;

View file

@ -0,0 +1,115 @@
/**
* SoundAnalysisMIDIMode sound analysis MIDI CC output (audio_in input).
*
* Schema declares `primary_input: 'audio_in'` and `engine_id: 'thru'` (no
* synthesis). The full firmware pipeline feeds audio analysis features
* (pitch / aperiodicity / energy / brightness / etc.) into the first 6
* input channels and joystick coords into the last 4. Stream 9 ships a
* scaffold UI; mic capture + analysis wiring is a stream-10 task.
*/
import { Component, Show } from 'solid-js';
import { ModeShell } from './ModeShell';
import { useModeRuntime } from './mode-runtime';
import { VirtualJoystick } from '../primitives/VirtualJoystick';
import { OutputDisplay } from '../primitives/OutputDisplay';
import { SliderBank } from '../primitives/SliderBank';
import { LossPlot } from '../primitives/LossPlot';
import { paramsToSliderConfig, outputsToSliderValues } from './mode-helpers';
import { SoundAnalysisMidiSchema } from './generated/sound_analysis_midi_schema';
export const SoundAnalysisMIDIMode: Component = () => {
const schema = SoundAnalysisMidiSchema;
const runtime = useModeRuntime(schema);
const sliderConfig = paramsToSliderConfig(schema.params);
const sliderValues = () => outputsToSliderValues(runtime.processedOutputs(), schema.params);
return (
<ModeShell
schema={schema}
runtime={runtime}
drawerTitle="MIDI CC routing"
drawerContent={() => (
<div>
<SliderBank
title="MIDI CC values"
sliders={sliderConfig}
values={sliderValues}
onChange={() => {
/* read-only */
}}
/>
<p
style={{
'font-size': 'var(--fs-xs)',
color: 'var(--fg-mute)',
'margin-top': 'var(--sp-3)',
}}
>
WebMIDI routing is wired up in stream 10. For now CC values are
visible in the live readout.
</p>
</div>
)}
primaryInput={() => (
<>
<Show
when={schema.ui.primary_input === 'audio_in'}
fallback={
<VirtualJoystick
size={260}
onMove={(x, y) => runtime.setInput(x, y)}
position={runtime.pipedInput}
ariaLabel="Sound analysis joystick"
/>
}
>
<div
style={{
display: 'flex',
'flex-direction': 'column',
'align-items': 'center',
gap: 'var(--sp-3)',
padding: 'var(--sp-4)',
background: 'var(--bg-2)',
border: '1px dashed var(--line-strong)',
'border-radius': 'var(--r-2)',
'min-width': '260px',
'min-height': '200px',
'justify-content': 'center',
color: 'var(--fg-mute)',
'text-align': 'center',
}}
>
<strong style={{ color: 'var(--accent-2)' }}>Mic input TODO</strong>
<span style={{ 'font-size': 'var(--fs-xs)' }}>
Stream 10 will request `getUserMedia` and feed audio analysis
features into the MLP. For now you can still drive the model
manually with the joystick below.
</span>
<VirtualJoystick
size={200}
onMove={(x, y) => runtime.setInput(x, y)}
position={runtime.pipedInput}
ariaLabel="Manual joystick fallback"
/>
</div>
</Show>
</>
)}
outputArea={() => (
<>
<OutputDisplay
values={runtime.processedOutputs}
width={360}
height={120}
color="var(--info)"
/>
<LossPlot history={runtime.training.lossHistory} width={360} height={70} />
</>
)}
/>
);
};
export default SoundAnalysisMIDIMode;

View file

@ -0,0 +1,68 @@
/**
* VerbFXMode verb / fx unit (joystick input, ~47 outputs).
*/
import { Component, createSignal } from 'solid-js';
import { ModeShell } from './ModeShell';
import { useModeRuntime } from './mode-runtime';
import { VirtualJoystick } from '../primitives/VirtualJoystick';
import { OutputDisplay } from '../primitives/OutputDisplay';
import { SliderBank } from '../primitives/SliderBank';
import { LossPlot } from '../primitives/LossPlot';
import { paramsToSliderConfig, outputsToSliderValues } from './mode-helpers';
import { VerbFxSchema } from './generated/verb_fx_schema';
export const VerbFXMode: Component = () => {
const schema = VerbFxSchema;
const runtime = useModeRuntime(schema);
const [voiceSpace, setVoiceSpace] = createSignal(0);
const sliderConfig = paramsToSliderConfig(schema.params);
const sliderValues = () => outputsToSliderValues(runtime.processedOutputs(), schema.params);
return (
<ModeShell
schema={schema}
runtime={runtime}
activeVoiceSpace={voiceSpace}
onVoiceSpaceChange={setVoiceSpace}
drawerTitle="Verb / FX params"
drawerContent={() => (
<SliderBank
title="Live FX params"
sliders={sliderConfig}
values={sliderValues}
onChange={() => {
/* read-only */
}}
/>
)}
primaryInput={() => (
<>
<VirtualJoystick
size={260}
ariaLabel="Verb FX joystick"
onMove={(x, y) => runtime.setInput(x, y)}
position={runtime.pipedInput}
/>
<span style={{ 'font-size': 'var(--fs-xs)', color: 'var(--fg-mute)' }}>
Sweep through verb space. Voice space:&nbsp;
<strong>{schema.voice_spaces[voiceSpace()] ?? 'Default'}</strong>
</span>
</>
)}
outputArea={() => (
<>
<OutputDisplay
values={runtime.processedOutputs}
width={360}
height={120}
color="#b464ff"
/>
<LossPlot history={runtime.training.lossHistory} width={360} height={70} />
</>
)}
/>
);
};
export default VerbFXMode;

View file

@ -0,0 +1,69 @@
/**
* XIASRIMode audio-reactive verb / pitch effects driven by joystick.
*
* Although the firmware variant historically used audio analysis as input,
* the playground schema declares `primary_input: 'joystick'` and feeds the
* MLP from joy_x/joy_y/joy_z/joy_w. The audio-reactive flavour is left to
* stream 10 (mic input wiring).
*/
import { Component } from 'solid-js';
import { ModeShell } from './ModeShell';
import { useModeRuntime } from './mode-runtime';
import { VirtualJoystick } from '../primitives/VirtualJoystick';
import { OutputDisplay } from '../primitives/OutputDisplay';
import { SliderBank } from '../primitives/SliderBank';
import { LossPlot } from '../primitives/LossPlot';
import { paramsToSliderConfig, outputsToSliderValues } from './mode-helpers';
import { XiasriSchema } from './generated/xiasri_schema';
export const XIASRIMode: Component = () => {
const schema = XiasriSchema;
const runtime = useModeRuntime(schema);
const sliderConfig = paramsToSliderConfig(schema.params);
const sliderValues = () => outputsToSliderValues(runtime.processedOutputs(), schema.params);
return (
<ModeShell
schema={schema}
runtime={runtime}
drawerTitle="XIASRI params"
drawerContent={() => (
<SliderBank
title="Live verb / pitch params"
sliders={sliderConfig}
values={sliderValues}
onChange={() => {
/* read-only */
}}
/>
)}
primaryInput={() => (
<>
<VirtualJoystick
size={260}
ariaLabel="XIASRI joystick"
onMove={(x, y) => runtime.setInput(x, y)}
position={runtime.pipedInput}
/>
<span style={{ 'font-size': 'var(--fs-xs)', color: 'var(--fg-mute)' }}>
Joystick verb / pitch space. Mic input wiring is a stream-10 task.
</span>
</>
)}
outputArea={() => (
<>
<OutputDisplay
values={runtime.processedOutputs}
width={360}
height={120}
color="var(--accent-2)"
/>
<LossPlot history={runtime.training.lossHistory} width={360} height={70} />
</>
)}
/>
);
};
export default XIASRIMode;

View file

@ -0,0 +1,99 @@
/**
* Mode registry maps mode_id (and the special "c15" placeholder) to the
* TSX component that renders it. The ModeSwitcher reads from this list to
* populate its dropdown, and `App.tsx` looks up the active mode here.
*
* Order matters the switcher renders modes in this order.
*/
import type { Component } from 'solid-js';
import { PAFSynthMode } from './PAFSynthMode';
import { ChannelStripMode } from './ChannelStripMode';
import { XIASRIMode } from './XIASRIMode';
import { VerbFXMode } from './VerbFXMode';
import { MEMLCeliumMode } from './MEMLCeliumMode';
import { BreakOrMode } from './BreakOrMode';
import { ElysiamorfMode } from './ElysiamorfMode';
import { SoundAnalysisMIDIMode } from './SoundAnalysisMIDIMode';
import { C15Mode } from './C15Mode';
export interface ModeRegistration {
/** Stable id; matches the schema's `mode_id` for firmware modes. */
id: string;
/** Human-readable label for the switcher. */
label: string;
/** Short description for the switcher tooltip. */
description: string;
/** TSX component rendering the mode. */
Component: Component;
/** True for browser-only placeholders (currently just C15). */
placeholder?: boolean;
}
export const MODE_REGISTRY: ReadonlyArray<ModeRegistration> = [
{
id: 'paf_synth',
label: 'PAF Synth',
description: 'Phase-aligned formant synth (XY pad).',
Component: PAFSynthMode,
},
{
id: 'channel_strip',
label: 'Channel Strip',
description: 'EQ + dynamics processing channel.',
Component: ChannelStripMode,
},
{
id: 'xiasri',
label: 'XIASRI',
description: 'Audio-reactive verb / pitch engine.',
Component: XIASRIMode,
},
{
id: 'verb_fx',
label: 'Verb FX',
description: 'Reverb / multi-effects unit.',
Component: VerbFXMode,
},
{
id: 'memlcelium',
label: 'MEML Celium',
description: 'Voice + dual-MLP CV/gate via uSEQ.',
Component: MEMLCeliumMode,
},
{
id: 'breakor',
label: 'Breakor',
description: 'Drum / breakbeat synthesis.',
Component: BreakOrMode,
},
{
id: 'elysiamorf',
label: 'Elysiamorf',
description: 'Granular morphing synth.',
Component: ElysiamorfMode,
},
{
id: 'sound_analysis_midi',
label: 'Sound Analysis → MIDI',
description: 'Audio features → MIDI CC routing.',
Component: SoundAnalysisMIDIMode,
},
{
id: 'c15',
label: 'C15 (browser-only)',
description: 'C15 WASM synth. Not yet ported.',
Component: C15Mode,
placeholder: true,
},
];
/** Look up a mode by id, falling back to the first registration. */
export function getModeById(id: string | null): ModeRegistration {
if (!id) return MODE_REGISTRY[0]!;
return MODE_REGISTRY.find((m) => m.id === id) ?? MODE_REGISTRY[0]!;
}
export { ModeShell } from './ModeShell';
export { useModeRuntime } from './mode-runtime';
export type { ModeRuntime } from './mode-runtime';

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;
}
}