merge stream 9: SolidJS modes + ModeShell + runtime (meml-yt7)
This commit is contained in:
commit
2bc422a880
17 changed files with 1645 additions and 7 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
64
playground/src/modes/BreakOrMode.tsx
Normal file
64
playground/src/modes/BreakOrMode.tsx
Normal 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;
|
||||
48
playground/src/modes/C15Mode.tsx
Normal file
48
playground/src/modes/C15Mode.tsx
Normal 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;
|
||||
68
playground/src/modes/ChannelStripMode.tsx
Normal file
68
playground/src/modes/ChannelStripMode.tsx
Normal 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;
|
||||
64
playground/src/modes/ElysiamorfMode.tsx
Normal file
64
playground/src/modes/ElysiamorfMode.tsx
Normal 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;
|
||||
68
playground/src/modes/MEMLCeliumMode.tsx
Normal file
68
playground/src/modes/MEMLCeliumMode.tsx
Normal 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;
|
||||
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;
|
||||
74
playground/src/modes/PAFSynthMode.tsx
Normal file
74
playground/src/modes/PAFSynthMode.tsx
Normal 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:
|
||||
<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;
|
||||
115
playground/src/modes/SoundAnalysisMIDIMode.tsx
Normal file
115
playground/src/modes/SoundAnalysisMIDIMode.tsx
Normal 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;
|
||||
68
playground/src/modes/VerbFXMode.tsx
Normal file
68
playground/src/modes/VerbFXMode.tsx
Normal 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:
|
||||
<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;
|
||||
69
playground/src/modes/XIASRIMode.tsx
Normal file
69
playground/src/modes/XIASRIMode.tsx
Normal 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;
|
||||
99
playground/src/modes/index.ts
Normal file
99
playground/src/modes/index.ts
Normal 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';
|
||||
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