feat(playground/modes): all 9 mode bindings + /modes route (meml-yt7)

Stream 9 (part 2/2) — concrete mode TSX components + routing.

Each firmware mode pairs the generated schema with the appropriate
primary input (XYPad for xy_pad schemas, VirtualJoystick for joystick
schemas) and feeds the runtime's processedOutputs into the shared
SliderBank/OutputDisplay/LossPlot layout. The set:

  - PAFSynthMode (xy_pad, voice spaces)
  - ChannelStripMode (joystick)
  - XIASRIMode (joystick — mic-input wiring deferred to stream 10)
  - VerbFXMode (joystick, voice spaces)
  - MEMLCeliumMode (xy_pad, voice spaces)
  - BreakOrMode (xy_pad)
  - ElysiamorfMode (xy_pad)
  - SoundAnalysisMIDIMode (audio_in fallback to joystick;
      mic capture is a stream-10 task — UI scaffold renders today)
  - C15Mode (browser-only placeholder; bridge not yet ported)

App.tsx adds a /modes route that renders <ModeSwitcher /> + the active
mode's component via Solid <Dynamic />. modes/index.ts exposes the
registry the switcher consumes.
This commit is contained in:
w1n5t0n 2026-04-29 17:00:56 +03:00
parent ebd5dba4c0
commit f916344c9b
11 changed files with 800 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,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';