feat(inputs): wire input layer into console + flesh out INPUTS drawer

Route onMove through the XY-pad source's pushPad; instantiate useInputLayer
per engine and expose it on ConsoleCtx. The Inputs drawer now picks the
source(s) (XY pad / MIDI / gamepad toggles), configures each (gamepad
single/double stick; MIDI learn-map with per-binding clear), and shows the
composed channel layout + the blend-to-2 reshape notice referencing the
multi-WASM TODO (inputs-spec).
This commit is contained in:
monkey-w1n5t0n 2026-06-28 04:24:48 +02:00
parent 091cfe2716
commit 433a4989e1
3 changed files with 179 additions and 26 deletions

View file

@ -51,6 +51,7 @@ import { FeedbackController, type ProtoFeedbackMode } from '../feedback';
import { DEFAULT_OUTPUT_MODE, OUTPUT_MODES, outputModeDescriptor } from './output-mode'; import { DEFAULT_OUTPUT_MODE, OUTPUT_MODES, outputModeDescriptor } from './output-mode';
import { useSettings, resolveInputMap } from '../settings/settings-store'; import { useSettings, resolveInputMap } from '../settings/settings-store';
import { useBackendManager } from '../backends'; import { useBackendManager } from '../backends';
import { useInputLayer } from '../inputs';
let SNAP_ID = 0; let SNAP_ID = 0;
@ -226,9 +227,17 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp
// rAF loop; the Editor serial protocol remains its own panel. // rAF loop; the Editor serial protocol remains its own panel.
const setOutputMode = (m: OutputMode) => setOutputModeState(m); const setOutputMode = (m: OutputMode) => setOutputModeState(m);
// Drive a pad/joystick/manifold move through the real engine, then mirror the // Modular input layer (workstream F): composes the active input SOURCE(s)
// raw position into React state for readouts. // (XY pad / MIDI / gamepad) into the engine. The XY pad is push-driven via
// `pushPad`; MIDI + gamepad are pulled by the layer's own rAF loop.
const inputs = useInputLayer(engine);
// Drive a pad/joystick/manifold move through the input layer's XY-pad source,
// then mirror the raw position into React state for readouts. The layer's loop
// composes it with any other active sources and writes to the engine; we still
// do a direct setInput so the pad feels instant even when it's the sole source.
const onMove = (x: number, y: number) => { const onMove = (x: number, y: number) => {
inputs.pushPad(x, y);
engine?.setInput(x, y); engine?.setInput(x, y);
setPos([x, y]); setPos([x, y]);
}; };
@ -576,6 +585,7 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp
setOscSendRaw, setOscSendRaw,
setParams: (next: MFParam[]) => setParams(next), setParams: (next: MFParam[]) => setParams(next),
markers, markers,
inputs,
health, health,
gradient: gradient.norms, gradient: gradient.norms,
gradientStatus: gradient.status, gradientStatus: gradient.status,

View file

@ -225,37 +225,175 @@ function LearningDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
} }
// =========================================================================== // ===========================================================================
// 2. INPUTS (dock-spec §2 — workstream F territory, referenced) // 2. INPUTS (workstream F; inputs-spec — modular input layer)
// =========================================================================== // ===========================================================================
const STATUS_TONE: Record<string, string> = {
ready: 'var(--accent)',
connecting: 'var(--warn)',
unavailable: 'var(--fg-dim)',
error: 'var(--danger)',
idle: 'var(--fg-dim)',
};
function InputsDrawer(ctx: ConsoleCtx, depth: DrawerDepth) { function InputsDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
const src = const inp = ctx.inputs;
ctx.mode.input === 'joystick' ? 'Joystick' : ctx.mode.input === 'audio_in' ? 'Mic (1-input)' : 'XY pad'; const enabledCount = inp.sources.filter((s) => s.enabled).length;
const reshaping = inp.axisCount > inp.engineInputSize;
return ( return (
<> <>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
<Badge tone="info">{src}</Badge> <Badge tone="info">
<Chip>2 inputs</Chip> {enabledCount === 0 ? 'no source' : `${enabledCount} source${enabledCount > 1 ? 's' : ''}`}
</Badge>
<Chip tone="var(--accent)">{inp.axisCount} axes</Chip>
<Chip>engine: {inp.engineInputSize}-in</Chip>
{reshaping && <Chip tone="var(--warn)">blended {inp.engineInputSize}</Chip>}
</div> </div>
<SectionLabel>Source</SectionLabel>
<Segmented <SectionLabel>Sources</SectionLabel>
value={ctx.mode.input} <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
onChange={() => { {inp.sources.map((s) => (
/* TODO(workstream F, inputs-spec): MIDI / gamepad / hands sources land here; the <div
input source is currently fixed by the mode. engine.setInput(x,y) is the only door. */ key={s.kind}
}} style={{
options={[ display: 'flex',
{ value: 'xy', label: 'XY pad' }, alignItems: 'center',
{ value: 'joystick', label: 'Joystick' }, justifyContent: 'space-between',
{ value: 'audio_in', label: 'Mic' }, gap: 8,
]} padding: '4px 8px',
/> background: 'var(--bg-2)',
border: '1px solid var(--line)',
borderRadius: 'var(--r-1)',
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--fg-mute)' }}>
{s.label}
{s.enabled ? ` · ${s.axisCount} ax` : ''}
</span>
{depth !== 'peek' && (
<span style={{ fontSize: 9, color: STATUS_TONE[s.status.state] ?? 'var(--fg-dim)' }}>
{s.status.message}
</span>
)}
</div>
<Switch checked={s.enabled} onChange={(v) => inp.setEnabled(s.kind, v)} label="" />
</div>
))}
</div>
{depth !== 'peek' && ( {depth !== 'peek' && (
<p style={{ fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)', margin: 0, lineHeight: 1.6 }}> <>
v1 browser is fixed-2-input ({`MLP<2,…>`}). The modular N×M input matrix, per-axis pipeline {/* ---- Gamepad config ---- */}
(deadzone zoom curve smoothing momentum) and MIDI / gamepad sources are owned by {inp.sources.find((s) => s.kind === 'gamepad')?.enabled && (
workstream F (inputs-spec); this drawer fixes the dock shape only. <>
</p> <SectionLabel>Gamepad · sticks</SectionLabel>
<Segmented
value={inp.gamepadStickMode}
onChange={inp.setGamepadStickMode}
options={[
{ value: 'single', label: 'Single (2 ax)' },
{ value: 'double', label: 'Double (4 ax)' },
]}
/>
</>
)}
{/* ---- MIDI learn-map ---- */}
{inp.sources.find((s) => s.kind === 'midi')?.enabled && (
<>
<SectionLabel>MIDI · learn-map</SectionLabel>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
<Button
size="sm"
variant={inp.midiLearnArmed ? 'primary' : 'secondary'}
onClick={() => inp.armMidiLearn(!inp.midiLearnArmed)}
>
{inp.midiLearnArmed ? 'learning… (move a control)' : 'Learn axis'}
</Button>
{inp.midiBindings.length > 0 && (
<Button size="sm" variant="secondary" onClick={inp.clearMidiBindings}>
Clear all
</Button>
)}
</div>
{inp.midiBindings.length === 0 ? (
<p style={{ fontSize: 9, color: 'var(--fg-dim)', margin: 0 }}>
No axes learned yet arm Learn, then wiggle a knob or hit a pad. CCs map to a
continuous axis; notes map to a gate (1 while held).
</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{inp.midiBindings.map((b, i) => (
<div
key={`${b.kind}-${b.number}-${b.channel}`}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
fontSize: 'var(--fs-xs)',
color: 'var(--fg-mute)',
}}
>
<span>
{b.label} · {b.value.toFixed(2)}
</span>
<button
type="button"
onClick={() => inp.clearMidiBinding(i)}
style={{
background: 'transparent',
border: 0,
color: 'var(--danger)',
cursor: 'pointer',
fontSize: 'var(--fs-xs)',
}}
>
</button>
</div>
))}
</div>
)}
{inp.midiInputs.length > 0 && (
<p style={{ fontSize: 9, color: 'var(--fg-dim)', margin: 0 }}>
Listening on: {inp.midiInputs.map((p) => p.name).join(', ')}
</p>
)}
</>
)}
{/* ---- Channel layout ---- */}
<SectionLabel>Channel layout</SectionLabel>
{inp.channelLayout.length === 0 ? (
<p style={{ fontSize: 9, color: 'var(--fg-dim)', margin: 0 }}>
No active axes. Enable a source above.
</p>
) : (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{inp.channelLayout.map((c, i) => (
<Chip key={i}>
{i}: {c.source}·{c.label}
</Chip>
))}
</div>
)}
<p style={{ fontSize: 9, color: 'var(--fg-dim)', margin: 0, lineHeight: 1.6 }}>
The active sources concatenate into one input vector at the head of the spine.
{reshaping
? ` The browser WASM is a fixed ${inp.engineInputSize}-input head (MLP<2,…>), so the
${inp.axisCount} axes are blended down to ${inp.engineInputSize} (evenX / oddY mean).`
: ''}{' '}
{/* TODO(workstream F, docs/redesign/inputs-spec.md "multiple WASM modules +
warm-start"): give every axis its own genuine input dimension by (re)loading a
WASM module whose MLP arity matches axisCount and warm-starting from the prior
net. Deferred the reduction lives in InputLayer.compose(). */}
True per-axis dimensions land with the multi-WASM reshape (inputs-spec).
</p>
</>
)} )}
</> </>
); );

View file

@ -5,6 +5,7 @@ import type { MFMode, MFParam } from './model';
import type { BackendId } from '../dock/output-state'; import type { BackendId } from '../dock/output-state';
import type { FeedbackMode } from '../engine/types'; import type { FeedbackMode } from '../engine/types';
import type { BackendStatus } from '../backends/backend'; import type { BackendStatus } from '../backends/backend';
import type { UseInputLayer } from '../inputs';
/** The two product feedback modes (dock-spec §1.1; rl-feedback-design §0). */ /** The two product feedback modes (dock-spec §1.1; rl-feedback-design §0). */
export type FeedbackModeUI = 'explore-and-place' | 'geometric-dislike'; export type FeedbackModeUI = 'explore-and-place' | 'geometric-dislike';
@ -110,6 +111,10 @@ export interface ConsoleCtx {
/** Markers plotted at the input location where each feedback was given. */ /** Markers plotted at the input location where each feedback was given. */
markers: FeedbackMarker[]; markers: FeedbackMarker[];
// ---- Modular input layer (workstream F; inputs-spec) ----
/** The composed input layer: source enable/config/status + channel layout. */
inputs: UseInputLayer;
health: number; health: number;
gradient: number[]; gradient: number[];
gradientStatus: string[]; gradientStatus: string[];