diff --git a/playground/src/App.tsx b/playground/src/App.tsx index bce2877..49cc522 100644 --- a/playground/src/App.tsx +++ b/playground/src/App.tsx @@ -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 + + } + > + + + + + + + + +
+
{props.primaryInput()}
+
{props.outputArea()}
+
+ +
+
+ + {(axis) => ( + controlStore.state[axis.key]} + onChange={(v) => controlStore.setAxis(axis.key, v)} + preset={() => controlStore.state.presetId} + onDoubleTap={() => controlStore.clearOffsets(axis.key)} + /> + )} + +
+
+ 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} + /> +
+
+ +

+ + Loading WASM ML…{' '} + + + frozen{' '} + + + input ({props.runtime.pipedInput()[0].toFixed(2)}, + {' '} + {props.runtime.pipedInput()[1].toFixed(2)}) + +

+ + + setDrawerOpen(false)} + side="right" + title={props.drawerTitle ?? 'Mode settings'} + width={420} + > + {props.drawerContent!()} + + + + ); +}; + +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; diff --git a/playground/src/modes/ModeSwitcher.module.css b/playground/src/modes/ModeSwitcher.module.css new file mode 100644 index 0000000..c226fb7 --- /dev/null +++ b/playground/src/modes/ModeSwitcher.module.css @@ -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); +} diff --git a/playground/src/modes/ModeSwitcher.tsx b/playground/src/modes/ModeSwitcher.tsx new file mode 100644 index 0000000..2f10235 --- /dev/null +++ b/playground/src/modes/ModeSwitcher.tsx @@ -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 ( +
+ Mode + + + {active().description} + + + + +
+ ); +}; + +export default ModeSwitcher; diff --git a/playground/src/modes/PAFSynthMode.tsx b/playground/src/modes/PAFSynthMode.tsx new file mode 100644 index 0000000..a268762 --- /dev/null +++ b/playground/src/modes/PAFSynthMode.tsx @@ -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 ( + ( + { + // Sliders are display-only here. Stream 10 wires the per-param + // override editor which writes through modeStore.setOverride. + }} + /> + )} + primaryInput={() => ( + <> + runtime.setInput(x, y)} + position={runtime.pipedInput} + /> + + Drag to sculpt formants. Voice space:  + {schema.voice_spaces[voiceSpace()] ?? 'Default'} + + + )} + outputArea={() => ( + <> + + + + )} + /> + ); +}; + +export default PAFSynthMode; diff --git a/playground/src/modes/SoundAnalysisMIDIMode.tsx b/playground/src/modes/SoundAnalysisMIDIMode.tsx new file mode 100644 index 0000000..d4296ee --- /dev/null +++ b/playground/src/modes/SoundAnalysisMIDIMode.tsx @@ -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 ( + ( +
+ { + /* read-only */ + }} + /> +

+ WebMIDI routing is wired up in stream 10. For now CC values are + visible in the live readout. +

+
+ )} + primaryInput={() => ( + <> + runtime.setInput(x, y)} + position={runtime.pipedInput} + ariaLabel="Sound analysis joystick" + /> + } + > +
+ Mic input — TODO + + 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. + + runtime.setInput(x, y)} + position={runtime.pipedInput} + ariaLabel="Manual joystick fallback" + /> +
+
+ + )} + outputArea={() => ( + <> + + + + )} + /> + ); +}; + +export default SoundAnalysisMIDIMode; diff --git a/playground/src/modes/VerbFXMode.tsx b/playground/src/modes/VerbFXMode.tsx new file mode 100644 index 0000000..dc6c0ed --- /dev/null +++ b/playground/src/modes/VerbFXMode.tsx @@ -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 ( + ( + { + /* read-only */ + }} + /> + )} + primaryInput={() => ( + <> + runtime.setInput(x, y)} + position={runtime.pipedInput} + /> + + Sweep through verb space. Voice space:  + {schema.voice_spaces[voiceSpace()] ?? 'Default'} + + + )} + outputArea={() => ( + <> + + + + )} + /> + ); +}; + +export default VerbFXMode; diff --git a/playground/src/modes/XIASRIMode.tsx b/playground/src/modes/XIASRIMode.tsx new file mode 100644 index 0000000..33cd446 --- /dev/null +++ b/playground/src/modes/XIASRIMode.tsx @@ -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 ( + ( + { + /* read-only */ + }} + /> + )} + primaryInput={() => ( + <> + runtime.setInput(x, y)} + position={runtime.pipedInput} + /> + + Joystick → verb / pitch space. Mic input wiring is a stream-10 task. + + + )} + outputArea={() => ( + <> + + + + )} + /> + ); +}; + +export default XIASRIMode; diff --git a/playground/src/modes/index.ts b/playground/src/modes/index.ts new file mode 100644 index 0000000..b7a3782 --- /dev/null +++ b/playground/src/modes/index.ts @@ -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 = [ + { + 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'; diff --git a/playground/src/modes/mode-helpers.ts b/playground/src/modes/mode-helpers.ts new file mode 100644 index 0000000..652d90d --- /dev/null +++ b/playground/src/modes/mode-helpers.ts @@ -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): 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, +): 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(' '); +} diff --git a/playground/src/modes/mode-runtime.ts b/playground/src/modes/mode-runtime.ts new file mode 100644 index 0000000..bc30713 --- /dev/null +++ b/playground/src/modes/mode-runtime.ts @@ -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; + stop: () => Promise; + setMuted: (muted: boolean) => void; + }; + + /** Loss / training plumbing surfaced from mlStore. */ + training: { + busy: () => boolean; + examples: () => number; + lastLoss: () => number | null; + lossHistory: () => ReadonlyArray; + }; + + /** 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([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( + 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 => { + 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 => { + 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; + } +}