From 9e59eb04ce73d7e078869425211a1b1e3c87456e Mon Sep 17 00:00:00 2001 From: monkey-w1n5t0n Date: Sun, 28 Jun 2026 21:05:30 +0200 Subject: [PATCH] feat(manifold): MIDI + game controller inputs; widen ML net to N-D Wire the modular input layer into the Console and reshape the browser engine so input axes are genuine independent dimensions. Inputs (manifold/src/inputs/): - gamepad-source: emit press+release edges with standard-mapping labels (enables hold-and-move); single/double-stick already present. - midi-input-source: single-device selection + batch "MIDI Learn" (every CC swept while armed becomes an axis); notes stay discrete. - input-layer: compose() forwards each axis 1:1 (no mean-blend); add onReducedInput so the manifold tracks gamepad/MIDI position. - types: InputAction.phase, InputMode. Console (manifold/src/console/): - ConsoleApp: bind gamepad buttons to verdicts (RB up / LB down / X randomise / Y nudge / B undo / A-hold reposition); mirror composed position onto the manifold. - Drawers: rebuilt Inputs drawer (source picker, gamepad legend, MIDI device picker + batch-learn flow, learned-control meters). Engine (nisps/wasm, manifold/src/engine): - DefaultMLP widened MLP<2,..> -> MLP<32,..> (32 = MAX_AXES); each active axis gets a dedicated slot, unused slots held at 0 (inert). Rebuilt nisps.wasm (playground + manifold). - spine/engine-api: setInputs writes the full N-D vector (was dropping arr[2+]); primary pair keeps the 2-D pipeline; process() re-ticks the whole vector via spine.reprocess(). Tests: - parity_check/parity_wasm: ParityMLP -> 32 inputs, widen example bufs. - CMakeLists: build parity binary with -ffp-contract=off so native matches FMA-free WASM (training amplified the gap past 1e-5). Inputs dock is still an exclusive picker; mixing toggles, reshape modal, and the >2-D slider view (inputs-spec.md) are groundwork-laid but not yet wired. See docs/redesign/midi-gamepad-inputs-worklog.md. --- MAP.md | 8 + docs/redesign/midi-gamepad-inputs-worklog.md | 96 +++++ manifold/public/nisps.wasm | Bin 105285 -> 102739 bytes manifold/src/console/ConsoleApp.tsx | 53 +++ manifold/src/console/Drawers.tsx | 354 ++++++++++++------- manifold/src/engine/engine-api.ts | 10 +- manifold/src/engine/spine.ts | 70 +++- manifold/src/inputs/gamepad-source.ts | 36 +- manifold/src/inputs/index.ts | 7 +- manifold/src/inputs/input-layer.ts | 96 +++-- manifold/src/inputs/midi-input-source.ts | 87 +++-- manifold/src/inputs/types.ts | 18 + manifold/src/inputs/useInputLayer.ts | 155 +++++--- nisps/CMakeLists.txt | 8 +- nisps/wasm/bindings.cpp | 31 +- playground/public/nisps.wasm | Bin 105285 -> 102739 bytes tests/cpp/parity_check.cpp | 17 +- tests/cpp/parity_wasm.mjs | 14 +- 18 files changed, 739 insertions(+), 321 deletions(-) create mode 100644 docs/redesign/midi-gamepad-inputs-worklog.md diff --git a/MAP.md b/MAP.md index 83120ac..fb0b999 100644 --- a/MAP.md +++ b/MAP.md @@ -66,6 +66,14 @@ anchor + locked decisions) and the `docs/redesign/*-spec.md` set. - `manifold/src/midi-devices/` — external-synth device templates. `generated/` is codegen output from `schemas/midi_devices/` (`MIDI_DEVICES` catalogue + `MIDI_DEVICES_BY_ID`, params by name+CC). The MIDI Outputs config (`dock/OutputsBackendConfig.tsx`) reads it for the device picker + param-select that fills the CC table. +- `manifold/src/inputs/` — modular INPUT layer feeding the ML head. The Inputs dock picks ONE exclusive mode + (`InputMode` = `internal` | `gamepad` | `midi`; Internal/XY-pad is default). `input-layer.ts` owns a single rAF + loop composing the active source's axes → reduced to the engine arity (fixed 2-in WASM → even/odd blend) → one + `setInputs`, plus an `onReducedInput` callback the manifold tracks. Sources: `xy-pad-source` (push-driven), + `gamepad-source` (sticks→axes single/double; buttons emit press+release actions, bound in `ConsoleApp` to + verdicts — LB/RB=down/up, X/Y/B=randomise/nudge/undo, A-hold=reposition), `midi-input-source` (device picker + + BATCH "MIDI Learn": every CC swept while armed becomes an axis, shown as read-only meters). `useInputLayer.ts` + is the React binding; `base-source.ts` shared status/action plumbing; `types.ts` the adapter contract. - `manifold/src/feedback/` — `controller.ts` (Explore-and-place scratchpad + geometric-dislike + solo, TS prototype), `rng.ts` (seeded). - `manifold/src/settings/` — `settings-store.ts` (monochrome icons, input-map shape, corner radius). diff --git a/docs/redesign/midi-gamepad-inputs-worklog.md b/docs/redesign/midi-gamepad-inputs-worklog.md new file mode 100644 index 0000000..34e487f --- /dev/null +++ b/docs/redesign/midi-gamepad-inputs-worklog.md @@ -0,0 +1,96 @@ +# Work log — MIDI + Game Controller inputs, N-D engine foundation + +*Scope: what was actually built on the `feat/midi-inputs` branch. This is a +description of the work, not a spec. The design intent lives in +`docs/redesign/inputs-spec.md`; where this branch diverges from or only partially +realises that spec, it is called out below.* + +## Summary + +This branch wires the modular input layer (which already existed as adapters in +`manifold/src/inputs/`) into the Console, adds the missing gamepad→verdict and +MIDI-device plumbing, and reshapes the browser ML engine so input axes are +genuine independent dimensions instead of being blended into two. It landed in +two passes: + +1. **Input methods** — a working Inputs dock with three sources (Internal XY + pad, Game Controller, MIDI), gamepad buttons bound to verdicts, and a batch + "MIDI Learn". +2. **Engine foundation for mixing** — the WASM net was widened from 2 inputs to + a 32-input maximum so each active axis gets its own dimension (no blending). + +The Inputs dock currently presents the three sources as an **exclusive** picker. +The engine groundwork for *mixing* sources (independent dimensions, no idle-bias) +is in place, but the dock toggles, the reshape-confirm modal, and the +>2-dimension slider visualisation described in `inputs-spec.md` are **not yet +wired** — see "Not done yet" below. + +## What changed + +### Input sources (`manifold/src/inputs/`) +- `gamepad-source.ts` — buttons now emit both press and release edges (with + standard-mapping labels A/B/X/Y/LB/RB/…), enabling hold-and-move gestures. + Single/double-stick (2/4 axes) was already present. +- `midi-input-source.ts` — added single-device selection (`selectDevice`, the + dock device picker; default still listens to all ports) and changed MIDI-Learn + from one-binding-per-arm to a **batch** capture: while armed, every distinct CC + that moves is appended as an axis; notes stay discrete actions and are not + auto-bound. Learned CCs are exposed as bindings for the dock. +- `types.ts` — `InputAction` gained an optional `phase` ('press' | 'release'); + added an `InputMode` ('internal' | 'gamepad' | 'midi') type. +- `input-layer.ts` — added `onReducedInput` so the on-screen manifold can track a + gamepad/MIDI-driven position. **`compose()` no longer mean-blends**: it + forwards each active axis 1:1 to its own engine input slot (the engine + zero-pads the rest; a zero input is inert, `0 × weight = 0`). +- `useInputLayer.ts` — the React binding; exposes the active mode, per-source + status, gamepad stick mode, MIDI device list/selection, batch-learn arm, and + learned bindings. (Currently exclusive — one mode at a time.) + +### Console wiring (`manifold/src/console/`) +- `ConsoleApp.tsx` — subscribes to gamepad actions and binds them to existing + verdict handlers: RB = thumbs-up, LB = thumbs-down, X = randomise, Y = nudge, + B = undo, A-hold = reposition (hold, move stick, release to place an example + at the stick position). Mirrors the composed input position onto the manifold + when a non-pad source is active (deduped to avoid per-frame re-renders). +- `Drawers.tsx` — rebuilt the Inputs drawer: a source picker, a gamepad stick + toggle + button legend, a MIDI device picker, the batch MIDI-Learn flow with + its "move every control, then Done" message, and learned controls rendered as + read-only meters styled distinctly from the output sliders. + +### Engine (`manifold/src/engine/`, `nisps/wasm/`) +- `nisps/wasm/bindings.cpp` — `DefaultMLP` widened `MLP<2,…>` → `MLP<32,…>` + (32 = `MAX_AXES`). Each active axis maps to a dedicated input slot; unused + slots are held at 0. Rebuilt `nisps.wasm` and synced to both + `playground/public/` and `manifold/public/` (the C ABI / `nisps.js` glue is + unchanged). +- `spine.ts` / `engine-api.ts` — `setInputs(arr)` now writes the full + N-dimensional vector (it previously dropped everything past `arr[1]`); the + primary pair still runs through the 2-D input pipeline so the pad keeps its + feel, axes 2+ are written raw, and `process()` re-ticks the whole vector after + weight changes via the new `spine.reprocess()`. + +### Tests / build +- `tests/cpp/parity_check.cpp` + `tests/cpp/parity_wasm.mjs` — `ParityMLP` + bumped to 32 inputs and the example/feature buffers widened to match the net's + arity (`add_example` requires `features.size() >= NIn`). +- `nisps/CMakeLists.txt` — the parity binary now builds with `-ffp-contract=off`. + Widening the input layer exposed a native↔WASM divergence: native clang/gcc + fuse multiply-adds (FMA) the WASM build has no instruction for, and the + training loop amplified the rounding difference past the 1e-5 parity tolerance. + Disabling FP contraction on the native parity build alone restores bit-equality + (max delta ~2.4e-7). + +## Verification +- C++ suites (4/4) pass; native↔WASM parity passes at 1e-5. +- `manifold` typechecks and builds; the Playwright smoke test (engine loads, + input→output propagates) passes. + +## Not done yet (vs `inputs-spec.md`) +- The Inputs dock is an **exclusive** picker; mixing several sources at once + (independent toggles) is not wired, though the engine and `compose()` now + support it. +- No reshape-confirm modal + net reset when the active input set changes. +- No swap to a slider visualisation when more than two input dimensions are + active (the 2-D manifold is always shown). +- The input pipeline (deadzone/zoom/curve) is applied only to the primary pair; + per-source conditioning for axes 2+ is left raw. diff --git a/manifold/public/nisps.wasm b/manifold/public/nisps.wasm index d2226c2075364b0a39d6b372d04540c1a9eec6fb..9ee47b2fee54b38884064c95cae148f400b7b4a1 100755 GIT binary patch delta 18111 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 E7XaA0UjP6A diff --git a/manifold/src/console/ConsoleApp.tsx b/manifold/src/console/ConsoleApp.tsx index e6d6912..d0bf780 100644 --- a/manifold/src/console/ConsoleApp.tsx +++ b/manifold/src/console/ConsoleApp.tsx @@ -546,6 +546,59 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp return () => window.removeEventListener('keydown', onKey); }); + // ---- Game-controller verdict bindings (inputs-spec) --------------------- + // The gamepad's sticks already feed the input layer (→ engine); its BUTTONS + // drive verdicts here. Standard-mapping indices: + // LB(4) = down/negative · RB(5) = up/positive · X(2) = randomise · + // Y(3) = nudge · B(1) = undo · A(0) hold-and-move = reposition an example + // (hold A, move the stick to a spot on the manifold, release to drop it). + // MIDI note actions are surfaced too but left unbound (MIDI mode learns CCs + // as INPUT axes; verdicts there stay on the on-screen / keyboard controls). + // The effect has no dep array (matching the keydown handler above) so each + // binding closes over the latest verdict functions + live `pos`. + useEffect(() => { + const unBtn = inputs.onAction((a) => { + if (a.source !== 'gamepad') return; + const phase = a.phase ?? 'press'; + if (phase === 'press') { + switch (a.id) { + case 'button:4': // LB → thumbs-down + perturb(); + break; + case 'button:5': // RB → thumbs-up + commit(); + break; + case 'button:2': // X → randomise / re-roll + reroll(); + break; + case 'button:3': // Y → nudge (scratchpad) + onScratchNudge(); + break; + case 'button:1': // B → undo + undo(); + break; + case 'button:0': // A (down) → begin repositioning an example + onPlace(); + break; + } + } else if (phase === 'release' && a.id === 'button:0') { + // A (up) → drop the example at the current (stick-driven) location. + onPickLocation(pos[0], pos[1]); + } + }); + // Mirror the composed gamepad/MIDI position onto the on-screen manifold so + // markers + readouts track the controller (the XY pad pushes its own pos). + // The callback fires every rAF frame — only re-render when it actually moves. + const unPos = inputs.onReducedInput((x, y) => { + if (inputs.inputMode === 'internal') return; + setPos((prev) => (Math.abs(prev[0] - x) < 1e-3 && Math.abs(prev[1] - y) < 1e-3 ? prev : [x, y])); + }); + return () => { + unBtn(); + unPos(); + }; + }); + const onToggleAudio = () => { if (!engine) return; if (engine.audio.isStarted) { diff --git a/manifold/src/console/Drawers.tsx b/manifold/src/console/Drawers.tsx index cd1561e..52f4aef 100644 --- a/manifold/src/console/Drawers.tsx +++ b/manifold/src/console/Drawers.tsx @@ -23,6 +23,7 @@ import type { ReactNode } from 'react'; import { Badge, Button, PillToggle, Slider, Switch } from '../primitives'; import type { ConsoleCtx, DrawerDepth, DrawerKey, FeedbackModeUI, SoloMode } from './types'; +import type { InputMode } from '../inputs'; import { OutputControlRow } from '../dock/OutputControlRow'; import { BackendAdvanced } from '../dock/BackendAdvanced'; import { OutputsBackendConfig, BackendStatusChip } from '../dock/OutputsBackendConfig'; @@ -236,165 +237,246 @@ const STATUS_TONE: Record = { idle: 'var(--fg-dim)', }; +const INPUT_MODE_OPTS: { value: InputMode; label: string }[] = [ + { value: 'internal', label: 'Internal' }, + { value: 'gamepad', label: 'Game Controller' }, + { value: 'midi', label: 'MIDI' }, +]; + +/** Standard-mapping gamepad button → verdict legend (mirrors ConsoleApp). */ +const GAMEPAD_LEGEND: { btn: string; action: string }[] = [ + { btn: 'RB', action: 'Up · positive feedback' }, + { btn: 'LB', action: 'Down · negative feedback' }, + { btn: 'X', action: 'Randomise' }, + { btn: 'Y', action: 'Nudge' }, + { btn: 'B', action: 'Undo' }, + { btn: 'A (hold)', action: 'Reposition — hold, move stick, release to place' }, +]; + +/** + * Read-only INPUT meter — shows a learned MIDI control's live value. Deliberately + * styled apart from the output Sliders (which are orange, interactive thumbs): + * these are inset bars on the secondary accent with an "in" tag, so the user can + * see at a glance that these feed the net rather than being driven by it. + */ +function MidiInputMeter({ label, value, onClear }: { label: string; value: number; onClear: () => void }) { + const pct = Math.max(0, Math.min(1, value)); + return ( +
+ + {label} + +
+
+
+ + {value.toFixed(2)} + + +
+ ); +} + function InputsDrawer(ctx: ConsoleCtx, depth: DrawerDepth) { const inp = ctx.inputs; - const enabledCount = inp.sources.filter((s) => s.enabled).length; const reshaping = inp.axisCount > inp.engineInputSize; + const active = inp.sources.find((s) => s.enabled); + const modeLabel = INPUT_MODE_OPTS.find((o) => o.value === inp.inputMode)?.label ?? 'Internal'; return ( <>
- - {enabledCount === 0 ? 'no source' : `${enabledCount} source${enabledCount > 1 ? 's' : ''}`} - - {inp.axisCount} axes - engine: {inp.engineInputSize}-in + {modeLabel} + {inp.inputMode !== 'internal' && {inp.axisCount} axes} {reshaping && blended → {inp.engineInputSize}} + {active && ( + {active.status.state} + )}
- Sources -
- {inp.sources.map((s) => ( -
-
- - {s.label} - {s.enabled ? ` · ${s.axisCount} ax` : ''} - - {depth !== 'peek' && ( - - {s.status.message} - - )} -
- inp.setEnabled(s.kind, v)} label="" /> -
- ))} -
+ Input source + + {active && depth !== 'peek' && ( + + {active.status.message} + + )} - {depth !== 'peek' && ( + {/* ---- Internal (XY pad / manifold) ---- */} + {inp.inputMode === 'internal' && depth !== 'peek' && ( +

+ Drag the on-screen manifold / XY pad. Two axes feed the net directly — this is the default. +

+ )} + + {/* ---- Game Controller ---- */} + {inp.inputMode === 'gamepad' && depth !== 'peek' && ( <> - {/* ---- Gamepad config ---- */} - {inp.sources.find((s) => s.kind === 'gamepad')?.enabled && ( - <> - Gamepad · sticks - - - )} - - {/* ---- MIDI learn-map ---- */} - {inp.sources.find((s) => s.kind === 'midi')?.enabled && ( - <> - MIDI · learn-map -
- - {inp.midiBindings.length > 0 && ( - - )} + Sticks + + Buttons +
+ {GAMEPAD_LEGEND.map((g) => ( +
+ {g.btn} + {g.action}
- {inp.midiBindings.length === 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). -

- ) : ( -
- {inp.midiBindings.map((b, i) => ( -
- - {b.label} · {b.value.toFixed(2)} - - -
- ))} -
- )} - {inp.midiInputs.length > 0 && ( -

- Listening on: {inp.midiInputs.map((p) => p.name).join(', ')} -

- )} - - )} + ))} +
+

+ Connect a controller and press any button to wake it. Sticks drive the input map; + buttons fire the verdicts above. +

+ + )} - {/* ---- Channel layout ---- */} - Channel layout - {inp.channelLayout.length === 0 ? ( + {/* ---- MIDI ---- */} + {inp.inputMode === 'midi' && depth !== 'peek' && ( + <> + Device + {inp.midiInputs.length === 0 ? (

- No active axes. Enable a source above. + No MIDI inputs detected. Connect a device — it appears here automatically.

) : ( -
- {inp.channelLayout.map((c, i) => ( - - {i}: {c.source}·{c.label} - +
+ + {inp.midiInputs.map((p) => ( + ))}
)} -

- 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} (even→X / odd→Y 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). -

+ MIDI Learn + {inp.midiLearnArmed ? ( +
+ + Move all of the controls you want to use, then click Done. Each knob or fader you + touch becomes an input. + + +
+ ) : ( +
+ + {inp.midiBindings.length > 0 && ( + + )} +
+ )} + + {inp.midiBindings.length > 0 && ( + <> + Learned controls +
+ {inp.midiBindings.map((b, i) => ( + inp.clearMidiBinding(i)} + /> + ))} +
+ + )} )} + + {/* ---- Reshape note (only when >2 axes feed the fixed WASM head) ---- */} + {reshaping && depth !== 'peek' && ( +

+ {/* 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(). */} + The browser WASM is a fixed {inp.engineInputSize}-input head (MLP<2,…>), so the{' '} + {inp.axisCount} axes are blended down to {inp.engineInputSize} (even→X / odd→Y mean). True + per-axis dimensions land with the multi-WASM reshape. +

+ )} ); } diff --git a/manifold/src/engine/engine-api.ts b/manifold/src/engine/engine-api.ts index 3b19719..1b0373a 100644 --- a/manifold/src/engine/engine-api.ts +++ b/manifold/src/engine/engine-api.ts @@ -164,9 +164,13 @@ export class EngineApi { this.spine.setInput(x, y); } - /** Set an arbitrary input vector (first two used as XY for the fixed 2→N MLP). */ + /** + * Set the full N-dimensional input vector (one axis per active input source). + * The first two axes run through the 2-D input pipeline; axes 2+ are raw. + * Extra axes beyond the net's input arity are ignored; unused slots → 0. + */ setInputs(arr: ReadonlyArray): void { - this.spine.setInput(arr[0] ?? 0.5, arr[1] ?? 0.5); + this.spine.setInputs(arr); } /** Live post-ML output vector (reused buffer — read, don't retain). */ @@ -193,7 +197,7 @@ export class EngineApi { * state without the user having to move the controller. */ process(): void { - this.spine.setInput(this.spine.lastRawX, this.spine.lastRawY); + this.spine.reprocess(); } // ---- Training ------------------------------------------------------ diff --git a/manifold/src/engine/spine.ts b/manifold/src/engine/spine.ts index 70186a0..12ceda3 100644 --- a/manifold/src/engine/spine.ts +++ b/manifold/src/engine/spine.ts @@ -94,6 +94,8 @@ export class Spine implements EngineSink { // Last raw input, so `EngineApi.process()` can re-tick after a weight change. lastRawX = 0.5; lastRawY = 0.5; + // Full last raw input vector (N-D) for re-ticking without losing extra axes. + private lastRawInputs: Float32Array = new Float32Array(2); private mlBuf: F32 = new Float32Array(126); private routedBuf: F32 | null = null; @@ -164,34 +166,61 @@ export class Spine implements EngineSink { // ---- The hot action ------------------------------------------------ /** - * Drive a raw [0,1] XY input through processed → ml → routed eagerly and - * synchronously, then fire the single backend.send at the tail. Off render. - * Returns the routed buffer (live, reused — do not retain across calls). + * Drive a raw [0,1] XY input through processed → ml → routed. Convenience for + * the 2-D manifold / XY-pad path — delegates to {@link setInputs}. */ setInput(x: number, y: number): Float32Array | null { + return this.setInputs([x, y]); + } + + /** + * Drive an N-dimensional raw input vector (each ∈ [0,1]) through + * processed → ml → routed eagerly and synchronously, then fire the single + * backend.send at the tail. Off render. Returns the routed buffer (live, + * reused — do not retain across calls). + * + * The mix-and-match input layer composes one axis PER active input source + * (XY pad / gamepad sticks / learned MIDI CCs) into this vector. The first + * two axes run through the 2-D input pipeline (deadzone→zoom→curve→smoothing→ + * momentum) so the pad keeps its feel and the ≤2-D path is unchanged; axes 2+ + * are written raw (sources self-condition). Unused slots up to the net's input + * arity are held at 0 so a shrinking vector never leaves a stale dimension hot. + */ + setInputs(arr: ArrayLike): Float32Array | null { const iml = this.iml; if (!iml) return null; - const now = (typeof performance !== 'undefined' ? performance.now() : Date.now()); - const dt = this.lastTickMs > 0 ? (now - this.lastTickMs) / 1000 : 1 / 60; - this.lastTickMs = now; + const dt = this.dt_(); + const inSize = this.state_.inputSize; - // 1. processed (pure input pipeline) + // 1. primary pair through the pure input pipeline (pad feel / 2-D parity). + const x = arr.length > 0 ? arr[0] : 0.5; + const y = arr.length > 1 ? arr[1] : 0.5; this.rawInput[0] = x; this.rawInput[1] = y; this.lastRawX = x; this.lastRawY = y; const proc = processInput(this.rawInput, this.inputConfig, this.inputState, dt); this.inputState = proc.state; - - // 2. ml (inference into the reused buffer; no alloc) iml.setInput(0, proc.x); iml.setInput(1, proc.y); + + // 2. extra axes raw; unused slots cleared to 0. Remember the full raw vector + // so process() can re-tick after a weight change without losing dims. + if (this.lastRawInputs.length !== inSize) this.lastRawInputs = new Float32Array(inSize); + this.lastRawInputs[0] = x; + this.lastRawInputs[1] = y; + for (let i = 2; i < inSize; i++) { + const v = i < arr.length ? arr[i] : 0; + iml.setInput(i, v); + this.lastRawInputs[i] = v; + } + + // 3. ml (inference into the reused buffer; no alloc). iml.processInto(this.mlBuf); - // Mirror to liveOutputs for imperative reads + bump. this.liveOutputs.set(this.mlBuf.subarray(0, this.liveOutputs.length)); - // 3. routed (output pipeline → reused routedBuf) + // 4. routed (output pipeline → reused routedBuf). const routedRes = processOutput(this.mlBuf, this.outputConfig, this.outputState, dt * 1000); this.outputState = routedRes.state; const routed = routedRes.processed; @@ -201,13 +230,30 @@ export class Spine implements EngineSink { this.routedBuf = routed; } - // 4. single backend.send at the tail (off React render) + // 5. single backend.send at the tail (off React render). if (this.backendSend && this.routedBuf) this.backendSend(this.routedBuf); this.bump_(); return this.routedBuf; } + /** + * Re-run the LAST full raw input vector through the spine (after a weight + * change — train / randomise / feedback) so outputs + audio reflect the new + * net without the user touching a control. Preserves all N dimensions. + */ + reprocess(): Float32Array | null { + return this.setInputs(this.lastRawInputs); + } + + /** Monotonic per-tick dt in seconds (≈1/60 on the first tick). */ + private dt_(): number { + const now = (typeof performance !== 'undefined' ? performance.now() : Date.now()); + const dt = this.lastTickMs > 0 ? (now - this.lastTickMs) / 1000 : 1 / 60; + this.lastTickMs = now; + return dt; + } + // ---- Imperative reads (canvas consumers bypass React) -------------- /** Live post-ML output vector. Reused — read, don't retain. */ diff --git a/manifold/src/inputs/gamepad-source.ts b/manifold/src/inputs/gamepad-source.ts index 7ec4d0a..65bca26 100644 --- a/manifold/src/inputs/gamepad-source.ts +++ b/manifold/src/inputs/gamepad-source.ts @@ -25,6 +25,31 @@ export type StickMode = 'single' | 'double'; const DEADZONE = 0.08; +/** + * Standard-mapping button index → human label. The Inputs dock binds these to + * verdict ops (see useInputLayer / ConsoleApp): LB/RB = down/up feedback, the + * face buttons = randomise / nudge / undo, and a hold-and-move button drops a + * repositioned example. The labels here keep the dock legend honest. + */ +const BUTTON_LABELS: Record = { + 0: 'A', + 1: 'B', + 2: 'X', + 3: 'Y', + 4: 'LB', + 5: 'RB', + 6: 'LT', + 7: 'RT', + 8: 'Back', + 9: 'Start', + 10: 'L3', + 11: 'R3', + 12: 'D↑', + 13: 'D↓', + 14: 'D←', + 15: 'D→', +}; + export class GamepadSource extends BaseSource { readonly kind: InputSourceKind = 'gamepad'; readonly label = 'Gamepad'; @@ -117,8 +142,17 @@ export class GamepadSource extends BaseSource { this.emitAction({ source: this.kind, id: `button:${i}`, - label: `Button ${i}`, + label: BUTTON_LABELS[i] ?? `Button ${i}`, value: pad.buttons[i].value || 1, + phase: 'press', + }); + } else if (!pressed && this.buttonsDown[i]) { + this.emitAction({ + source: this.kind, + id: `button:${i}`, + label: BUTTON_LABELS[i] ?? `Button ${i}`, + value: 0, + phase: 'release', }); } this.buttonsDown[i] = pressed; diff --git a/manifold/src/inputs/index.ts b/manifold/src/inputs/index.ts index da993a6..75c3b18 100644 --- a/manifold/src/inputs/index.ts +++ b/manifold/src/inputs/index.ts @@ -1,14 +1,15 @@ /** * Modular INPUT layer (workstream F) — public surface. * - * The user picks the input SOURCE(s) feeding the ML head (XY pad / MIDI / - * gamepad, or a combination); the InputLayer composes their axes into one - * N-dim vector at the head of the reactive spine. See input-layer.ts for the + * The user picks ONE exclusive input MODE feeding the ML head (Internal XY pad / + * Game Controller / MIDI); the InputLayer composes the active source's axes into + * one N-dim vector at the head of the reactive spine. See input-layer.ts for the * arity-reduction + the documented multi-WASM reshape TODO. */ export type { InputSource, InputSourceKind, + InputMode, InputSourceState, InputSourceStatus, InputAction, diff --git a/manifold/src/inputs/input-layer.ts b/manifold/src/inputs/input-layer.ts index a59ed2b..3cfc0f9 100644 --- a/manifold/src/inputs/input-layer.ts +++ b/manifold/src/inputs/input-layer.ts @@ -13,24 +13,18 @@ * its value), but routing everything through one compose path keeps sources * composable and the channel layout coherent. * - * ── Arity mismatch (the WASM reshape TODO) ────────────────────────────────── - * The browser WASM is fixed at MLP<2, …, 126> — a TWO-input head. When the - * composed vector has > 2 axes (double-stick gamepad = 4, MIDI learn-map = many) - * we must reduce to 2 to feed today's engine. We do NOT fake a wider net. + * ── Dedicated dimensions (no blending) ────────────────────────────────────── + * The WASM net is over-provisioned to a 32-input head (= MAX_AXES; see + * nisps/wasm/bindings.cpp). Each active axis drives its OWN engine input slot + * 1:1 — a double-stick gamepad is 4 genuine dims, a learned MIDI surface is N + * genuine dims. `compose()` simply forwards the active axes; the engine + * zero-pads the remaining slots and a zero input is inert (0 × weight = 0), so + * unused dimensions never perturb the net. We do NOT mean-blend (the previous + * behaviour) — that diluted every source and biased the net toward idle + * sources' resting values. * - * chosen reduction (this pass): pairwise BLEND. - * inX = mean(axis[0], axis[2], axis[4], …) // even axes - * inY = mean(axis[1], axis[3], axis[5], …) // odd axes - * so a single stick passes straight through (axis0→X, axis1→Y), a double - * stick averages L/R into one XY, and MIDI axes fold into X/Y by parity. - * - * TODO(workstream F, docs/redesign/inputs-spec.md — "multiple WASM modules + - * warm-start"): the real fix is to (re)load a WASM module whose MLP input arity - * matches the composed axis count and warm-start its weights from the prior net, - * so every axis gets its own genuine input dimension instead of being blended. - * That is a larger build (multiple .wasm artefacts or a runtime-variadic head) - * and is deliberately deferred — this layer is wired so that swapping the - * reduction for a true reshape is a localised change in `compose()`. + * Changing the ACTIVE axis count is a reshape: the front-end resets the net + * (recreate-from-scratch, behind a confirm modal) since slot meanings change. */ import type { InputAction, InputSource } from './types'; @@ -50,6 +44,7 @@ export class InputLayer { private rafId: number | null = null; private actionListeners = new Set<(a: InputAction) => void>(); private layoutListeners = new Set<() => void>(); + private reducedListeners = new Set<(x: number, y: number) => void>(); private unsubActions = new Map void>(); attach(engine: InputEngineSink): void { @@ -144,50 +139,32 @@ export class InputLayer { // 4. one engine write. engine.setInputs(reduced); + + // 5. report the reduced 2D position so the on-screen manifold can track a + // gamepad/MIDI-driven input (the XY pad pushes its own position). + if (this.reducedListeners.size) { + const x = reduced[0] ?? 0.5; + const y = reduced[1] ?? reduced[0] ?? 0.5; + for (const cb of this.reducedListeners) cb(x, y); + } } /** - * Reduce the composed N-axis vector to the engine's input arity. + * Map the composed N active axes to the engine's input vector — DEDICATED + * DIMENSIONS, no blending. Each active axis i drives engine input slot i 1:1; + * the engine zero-pads the slots beyond `count` and a zero input is inert + * (0 × weight = 0), so unused dimensions never perturb the net. * - * For the fixed 2-input WASM, fold by parity (even→X, odd→Y) via mean. If a - * future multi-module engine reports inputSize >= n, this passes axes through - * 1:1 (truncated/padded) — the seam where the real reshape lands. + * The net's input arity is over-provisioned (32, = MAX_AXES), so `inputSize` + * is effectively always ≥ n; the `min` only guards a transient where more + * axes are active than the net can take. We deliberately do NOT mean-blend + * (the old behaviour) — that diluted every source and biased the net toward + * idle sources' resting values. */ private compose(n: number, inputSize: number): number[] { - if (inputSize >= n) { - // True passthrough path (future multi-module head). Pad with 0.5. - const out = new Array(inputSize); - for (let i = 0; i < inputSize; i++) out[i] = i < n ? this.vector[i] : 0.5; - return out; - } - if (inputSize === 2) { - let sx = 0; - let sy = 0; - let cx = 0; - let cy = 0; - for (let i = 0; i < n; i++) { - if ((i & 1) === 0) { - sx += this.vector[i]; - cx++; - } else { - sy += this.vector[i]; - cy++; - } - } - return [cx ? sx / cx : 0.5, cy ? sy / cy : 0.5]; - } - // Generic fallback for any other fixed arity: chunked mean. - const out = new Array(inputSize).fill(0.5); - const per = Math.ceil(n / inputSize); - for (let k = 0; k < inputSize; k++) { - let s = 0; - let c = 0; - for (let i = k * per; i < Math.min((k + 1) * per, n); i++) { - s += this.vector[i]; - c++; - } - if (c) out[k] = s / c; - } + const count = Math.min(n, inputSize); + const out = new Array(count); + for (let i = 0; i < count; i++) out[i] = this.vector[i]; return out; } @@ -207,6 +184,14 @@ export class InputLayer { }; } + /** Subscribe to the reduced 2D input each frame (composed → engine arity). */ + onReducedInput(cb: (x: number, y: number) => void): () => void { + this.reducedListeners.add(cb); + return () => { + this.reducedListeners.delete(cb); + }; + } + private fanAction(a: InputAction): void { for (const cb of this.actionListeners) cb(a); } @@ -221,5 +206,6 @@ export class InputLayer { this.unsubActions.clear(); this.actionListeners.clear(); this.layoutListeners.clear(); + this.reducedListeners.clear(); } } diff --git a/manifold/src/inputs/midi-input-source.ts b/manifold/src/inputs/midi-input-source.ts index d419470..353fb31 100644 --- a/manifold/src/inputs/midi-input-source.ts +++ b/manifold/src/inputs/midi-input-source.ts @@ -10,10 +10,17 @@ * note-off. Note-on ALSO surfaces a discrete action (so a pad can fire * commit/perturb without the keyboard). * - * **Learn-map.** When `armLearn()` is active, the NEXT distinct CC or note seen - * is bound to a new axis (appended). This is the standard "MIDI learn" gesture: - * arm → wiggle the knob/pad → it captures. Axes can be cleared individually. - * The bindings are exposed for the dock channel-layout view. + * **Batch learn ("MIDI Learn" mode).** When `armLearn(true)` is active, EVERY + * distinct CC that moves is captured as a new axis (appended, deduped). The + * gesture the dock presents: arm → wiggle ALL the knobs/faders you want → + * click "Done" (`armLearn(false)`). This differs from the one-shot learn other + * apps use — the user sweeps the whole control surface in one pass. Notes are + * NOT auto-bound as axes (they stay as discrete actions); the "controls" the + * user sweeps are continuous CCs. Axes can be cleared individually or all. + * + * **Device selection.** By default every connected input port is listened to. + * `selectDevice(id)` narrows to a single port (the dock device picker); `null` + * restores listen-all. * * Pull-based: messages latch the latest per-binding value into `values`; * `sample()` copies them out. Hot path performs no IO/allocation. @@ -51,6 +58,8 @@ export class WebMidiInputSource extends BaseSource { private inputs: MIDIInput[] = []; private bindings: MidiBinding[] = []; private learnArmed = false; + /** Restrict listening to this input port id; null = every connected port. */ + private selectedDeviceId: string | null = null; private bindingsListeners = new Set<(b: MidiBinding[]) => void>(); isAvailable(): boolean { @@ -73,12 +82,16 @@ export class WebMidiInputSource extends BaseSource { // ---- Learn-map API (consumed by the dock) ------------------------------- - /** Arm/disarm MIDI-learn: the next distinct CC/note is captured as an axis. */ + /** + * Enter/leave batch MIDI-Learn. While armed, every distinct CC that moves is + * appended as an axis (the user sweeps their whole control surface, then + * clicks Done). Disarming keeps whatever was captured. + */ armLearn(armed: boolean): void { this.learnArmed = armed; this.setStatus( armed - ? { state: 'ready', message: 'Learn armed — move a knob or hit a pad' } + ? { state: 'ready', message: 'MIDI Learn — move every control you want, then click Done' } : this.readyStatus(), ); } @@ -87,6 +100,16 @@ export class WebMidiInputSource extends BaseSource { return this.learnArmed; } + /** Narrow listening to one input port (dock device picker). null = all ports. */ + selectDevice(id: string | null): void { + this.selectedDeviceId = id; + this.rewire(); + } + + getSelectedDeviceId(): string | null { + return this.selectedDeviceId; + } + getBindings(): ReadonlyArray { return this.bindings; } @@ -149,11 +172,15 @@ export class WebMidiInputSource extends BaseSource { if (!this.access) return; for (const inp of this.inputs) inp.onmidimessage = null; this.inputs = []; - this.access.inputs.forEach((inp) => { + this.access.inputs.forEach((inp, id) => { + // Honour the device picker: when a port is selected, listen to it alone. + if (this.selectedDeviceId !== null && id !== this.selectedDeviceId) return; inp.onmidimessage = (e) => this.onMessage(e); this.inputs.push(inp); }); - if (this.statusState.state !== 'connecting') this.setStatus(this.readyStatus()); + if (this.statusState.state !== 'connecting' && !this.learnArmed) { + this.setStatus(this.readyStatus()); + } } private readyStatus(): { state: 'ready'; message: string } { @@ -173,41 +200,45 @@ export class WebMidiInputSource extends BaseSource { const d2 = data.length > 2 ? data[2] : 0; if (status === STATUS_BYTE_CC) { - this.handleBindable('cc', d1, channel, d2 / 127); + this.handleCc(d1, channel, d2 / 127); } else if (status === STATUS_BYTE_NOTE_ON && d2 > 0) { - this.handleBindable('note', d1, channel, 1); - this.emitAction({ source: this.kind, id: `note:${d1}`, label: `Note ${d1}`, value: d2 / 127 }); + // Notes drive a held-gate on any already-learned note axis + a discrete + // action (so a pad can fire commit/perturb). Batch learn binds CCs only. + this.updateNote(d1, channel, 1); + this.emitAction({ source: this.kind, id: `note:${d1}`, label: `Note ${d1}`, value: d2 / 127, phase: 'press' }); } else if (status === STATUS_BYTE_NOTE_OFF || (status === STATUS_BYTE_NOTE_ON && d2 === 0)) { - this.handleBindable('note', d1, channel, 0, /*onlyUpdate*/ true); + this.updateNote(d1, channel, 0); + this.emitAction({ source: this.kind, id: `note:${d1}`, label: `Note ${d1}`, value: 0, phase: 'release' }); } } /** - * Route an incoming bindable message: update a matching binding's value, or — - * if learn is armed — create a new axis binding for it. + * Route an incoming CC: update a matching binding's value, or — if batch + * learn is armed — capture it as a NEW axis (deduped). Learn stays armed so + * the user can sweep their whole control surface in one pass. */ - private handleBindable( - kind: MidiBindingKind, - number: number, - channel: number, - value: number, - onlyUpdate = false, - ): void { + private handleCc(number: number, channel: number, value: number): void { const existing = this.bindings.find( - (b) => b.kind === kind && b.number === number && b.channel === channel, + (b) => b.kind === 'cc' && b.number === number && b.channel === channel, ); if (existing) { existing.value = value; this.notifyBindings(); return; } - if (onlyUpdate) return; // note-off for an unbound note: ignore if (this.learnArmed) { - const label = - kind === 'cc' ? `CC${number} ch${channel}` : `Note ${number} ch${channel}`; - this.bindings.push({ kind, number, channel, value, label }); - this.learnArmed = false; // learn one binding per arm - this.setStatus(this.readyStatus()); + this.bindings.push({ kind: 'cc', number, channel, value, label: `CC${number} ch${channel}` }); + this.notifyBindings(); + } + } + + /** Update a learned note axis's gate value (note bindings are not auto-learned). */ + private updateNote(number: number, channel: number, value: number): void { + const existing = this.bindings.find( + (b) => b.kind === 'note' && b.number === number && b.channel === channel, + ); + if (existing) { + existing.value = value; this.notifyBindings(); } } diff --git a/manifold/src/inputs/types.ts b/manifold/src/inputs/types.ts index e14d62d..35efbc1 100644 --- a/manifold/src/inputs/types.ts +++ b/manifold/src/inputs/types.ts @@ -44,6 +44,17 @@ export interface InputSourceStatus { /** Stable identity of a source kind. */ export type InputSourceKind = 'xy-pad' | 'midi' | 'gamepad'; +/** + * The exclusive INPUT MODE the user picks in the Inputs dock. Unlike the + * lower-level {@link InputSourceKind} (which the InputLayer can compose), the + * dock surfaces exactly one mode at a time: + * + * - `internal` → the on-screen XY pad / manifold (default; today's behaviour). + * - `gamepad` → a physical game controller (sticks → axes, buttons → verdicts). + * - `midi` → a connected MIDI device (learned CCs → axes). + */ +export type InputMode = 'internal' | 'gamepad' | 'midi'; + /** * A momentary discrete action surfaced by a source (e.g. a MIDI note-on or a * gamepad face-button press). Fanned out to InputLayer action listeners so the @@ -57,6 +68,13 @@ export interface InputAction { label: string; /** 0..1 velocity / analogue value where meaningful (else 1 for a press). */ value: number; + /** + * Edge phase. `press` (the default) fires on the leading edge; `release` on + * the trailing edge. Hold-and-move bindings (e.g. "hold a button, move the + * stick, release to drop an example") need both edges — most consumers only + * care about `press`. + */ + phase?: 'press' | 'release'; } /** diff --git a/manifold/src/inputs/useInputLayer.ts b/manifold/src/inputs/useInputLayer.ts index 8db7700..476e4c7 100644 --- a/manifold/src/inputs/useInputLayer.ts +++ b/manifold/src/inputs/useInputLayer.ts @@ -2,20 +2,20 @@ * useInputLayer — the thin React binding over the framework-neutral * {@link InputLayer} + source adapters. * - * Owns: - * - ONE InputLayer + one instance of each source (XY pad / MIDI / gamepad), - * created per engine and attached to it. - * - Which sources are ENABLED (the dock toggles these); enabling starts a - * source (async for MIDI) and adds it to the layer's composed set. - * - Per-source config (gamepad stick mode; MIDI learn arm + bindings). - * - The composed channel layout + per-source status, surfaced for the drawer. + * The dock surfaces ONE exclusive input MODE at a time (inputs-spec): + * - `internal` → the on-screen XY pad / manifold (default; today's behaviour). + * - `gamepad` → a physical game controller (sticks → axes, buttons → verdicts). + * - `midi` → a connected MIDI device (CCs learned onto axes). * - * The XY pad source is the one consumers push into directly: `pushPad(x,y)` is - * called from ConsoleApp.onMove so the existing pad keeps working unchanged - * while still composing with the other sources. + * Switching mode stops the previous source and starts the chosen one, then sets + * the layer's composed source set to exactly that source. The XY pad is the one + * consumers push into directly (`pushPad` from ConsoleApp.onMove) so the manifold + * keeps working unchanged in `internal` mode. * - * Discrete actions (MIDI notes / gamepad buttons) are fanned out via - * `onAction` so the console can later bind them to verdicts (commit/perturb). + * Per-mode config (gamepad stick mode + button verdict legend; MIDI device pick, + * batch learn arm, learned bindings) and per-source status are surfaced for the + * drawer. Discrete actions (gamepad buttons / MIDI notes) are fanned out via + * `onAction` so the console can bind them to verdicts (commit/perturb/…). */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { EngineApi } from '../engine'; @@ -23,7 +23,13 @@ import { InputLayer } from './input-layer'; import { XYPadSource } from './xy-pad-source'; import { WebMidiInputSource, type MidiBinding } from './midi-input-source'; import { GamepadSource, type StickMode } from './gamepad-source'; -import type { InputAction, InputSource, InputSourceKind, InputSourceStatus } from './types'; +import type { + InputAction, + InputMode, + InputSource, + InputSourceKind, + InputSourceStatus, +} from './types'; export interface SourceView { kind: InputSourceKind; @@ -33,13 +39,25 @@ export interface SourceView { axisCount: number; } +/** Which source backs each exclusive input mode. */ +const MODE_SOURCE: Record = { + internal: 'xy-pad', + gamepad: 'gamepad', + midi: 'midi', +}; + export interface UseInputLayer { /** Push the on-screen XY pad position (∈ [0,1]) — call from onMove. */ pushPad: (x: number, y: number) => void; - /** Per-source enable + status + axis count for the dock. */ + + // ---- exclusive mode ---- + /** The active input mode (Internal / Game Controller / MIDI). */ + inputMode: InputMode; + /** Switch the exclusive input mode. */ + setInputMode: (m: InputMode) => void; + + /** Per-source status + axis count for the dock (the active mode's source is `enabled`). */ sources: SourceView[]; - /** Toggle a source on/off. */ - setEnabled: (kind: InputSourceKind, enabled: boolean) => void; /** Composed channel layout (per-axis source+label). */ channelLayout: { source: string; label: string }[]; /** Total composed axis count. */ @@ -51,16 +69,23 @@ export interface UseInputLayer { gamepadStickMode: StickMode; setGamepadStickMode: (m: StickMode) => void; - // ---- midi learn-map ---- + // ---- midi device + learn-map ---- + /** Available MIDI input ports. */ + midiInputs: { id: string; name: string }[]; + /** The selected MIDI input port (null = listen to all ports). */ + midiDeviceId: string | null; + selectMidiDevice: (id: string | null) => void; + /** True while batch MIDI-Learn is armed (sweep controls, then Done). */ midiLearnArmed: boolean; armMidiLearn: (armed: boolean) => void; midiBindings: MidiBinding[]; clearMidiBinding: (i: number) => void; clearMidiBindings: () => void; - midiInputs: { id: string; name: string }[]; /** Subscribe to discrete actions (notes/buttons). */ onAction: (cb: (a: InputAction) => void) => () => void; + /** Subscribe to the reduced 2D input each frame (for the on-screen manifold). */ + onReducedInput: (cb: (x: number, y: number) => void) => () => void; } export function useInputLayer(engine: EngineApi | null): UseInputLayer { @@ -81,12 +106,7 @@ export function useInputLayer(engine: EngineApi | null): UseInputLayer { const midi = midiRef.current!; const gamepad = gamepadRef.current!; - // Enabled set — pad on by default (parity with today's behaviour). - const [enabled, setEnabledSet] = useState>({ - 'xy-pad': true, - midi: false, - gamepad: false, - }); + const [inputMode, setInputModeState] = useState('internal'); const [statuses, setStatuses] = useState>({ 'xy-pad': pad.status(), midi: midi.status(), @@ -97,8 +117,9 @@ export function useInputLayer(engine: EngineApi | null): UseInputLayer { const [midiLearnArmed, setMidiLearnArmed] = useState(false); const [midiBindings, setMidiBindings] = useState([]); const [midiInputs, setMidiInputs] = useState<{ id: string; name: string }[]>([]); + const [midiDeviceId, setMidiDeviceId] = useState(null); - // Attach to engine; start the pad immediately. Wire status/binding listeners. + // Attach to engine; start in `internal` mode (the XY pad). Wire listeners. useEffect(() => { if (!engine) return; layer.attach(engine); @@ -108,13 +129,13 @@ export function useInputLayer(engine: EngineApi | null): UseInputLayer { const unsubs: (() => void)[] = []; const wireStatus = (s: InputSource) => - unsubs.push( - s.onStatusChange((st) => setStatuses((m) => ({ ...m, [s.kind]: st }))), - ); + unsubs.push(s.onStatusChange((st) => setStatuses((m) => ({ ...m, [s.kind]: st })))); wireStatus(pad); wireStatus(midi); wireStatus(gamepad); unsubs.push(layer.onLayoutChange(() => setLayoutTick((t) => t + 1))); + // Refresh the device-picker list when ports come and go (hot-plug). + unsubs.push(midi.onStatusChange(() => setMidiInputs(midi.listInputs()))); unsubs.push( midi.onBindingsChange((b) => { setMidiBindings(b); @@ -131,34 +152,37 @@ export function useInputLayer(engine: EngineApi | null): UseInputLayer { // eslint-disable-next-line react-hooks/exhaustive-deps }, [engine]); - // Recompose the active source set whenever the enabled set changes. + // Recompose the active source set whenever the mode changes. useEffect(() => { - const active: InputSource[] = []; - if (enabled['xy-pad']) active.push(pad); - if (enabled.midi) active.push(midi); - if (enabled.gamepad) active.push(gamepad); - layer.setSources(active); + const kind = MODE_SOURCE[inputMode]; + const src = kind === 'xy-pad' ? pad : kind === 'gamepad' ? gamepad : midi; + layer.setSources([src]); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [enabled]); + }, [inputMode]); - const setEnabled = useCallback( - (kind: InputSourceKind, on: boolean) => { - setEnabledSet((m) => ({ ...m, [kind]: on })); - if (kind === 'midi') { - if (on) { - void midi.start().then(() => setMidiInputs(midi.listInputs())); - } else { - void midi.stop(); - } - } else if (kind === 'gamepad') { - if (on) gamepad.start(); - else gamepad.stop(); - } else if (kind === 'xy-pad') { - if (on) pad.start(); + const setInputMode = useCallback( + (mode: InputMode) => { + setInputModeState((prev) => { + if (prev === mode) return prev; + // Stop the outgoing source, start the incoming one. + if (prev === 'gamepad') gamepad.stop(); + else if (prev === 'midi') void midi.stop(); else pad.stop(); - } + + if (mode === 'gamepad') { + gamepad.start(); + } else if (mode === 'midi') { + void midi.start().then(() => { + setMidiInputs(midi.listInputs()); + setMidiDeviceId(midi.getSelectedDeviceId()); + }); + } else { + pad.start(); + } + return mode; + }); }, - [midi, gamepad, pad], + [pad, midi, gamepad], ); const setGamepadStickMode = useCallback( @@ -170,6 +194,15 @@ export function useInputLayer(engine: EngineApi | null): UseInputLayer { [gamepad], ); + const selectMidiDevice = useCallback( + (id: string | null) => { + midi.selectDevice(id); + setMidiDeviceId(id); + setMidiInputs(midi.listInputs()); + }, + [midi], + ); + const armMidiLearn = useCallback( (armed: boolean) => { midi.armLearn(armed); @@ -194,42 +227,50 @@ export function useInputLayer(engine: EngineApi | null): UseInputLayer { const pushPad = useCallback((x: number, y: number) => pad.pushAxes(x, y), [pad]); const onAction = useCallback((cb: (a: InputAction) => void) => layer.onAction(cb), [layer]); + const onReducedInput = useCallback( + (cb: (x: number, y: number) => void) => layer.onReducedInput(cb), + [layer], + ); const sources: SourceView[] = useMemo( () => ([pad, midi, gamepad] as InputSource[]).map((s) => ({ kind: s.kind, label: s.label, - enabled: enabled[s.kind], + enabled: MODE_SOURCE[inputMode] === s.kind, status: statuses[s.kind], - axisCount: enabled[s.kind] ? s.axisCount() : 0, + axisCount: MODE_SOURCE[inputMode] === s.kind ? s.axisCount() : 0, })), // layoutTick forces recompute when axis counts shift (learn-map / stick mode). // eslint-disable-next-line react-hooks/exhaustive-deps - [enabled, statuses, layoutTick, pad, midi, gamepad], + [inputMode, statuses, layoutTick, pad, midi, gamepad], ); const channelLayout = useMemo( () => layer.channelLayout(), // eslint-disable-next-line react-hooks/exhaustive-deps - [layoutTick, enabled], + [layoutTick, inputMode], ); return { pushPad, + inputMode, + setInputMode, sources, - setEnabled, channelLayout, axisCount: channelLayout.length, engineInputSize: engine?.architecture.inputSize ?? 2, gamepadStickMode, setGamepadStickMode, + midiInputs, + midiDeviceId, + selectMidiDevice, midiLearnArmed, armMidiLearn, midiBindings, clearMidiBinding, clearMidiBindings, - midiInputs, onAction, + onReducedInput, }; } diff --git a/nisps/CMakeLists.txt b/nisps/CMakeLists.txt index 8ff80d2..a6be410 100644 --- a/nisps/CMakeLists.txt +++ b/nisps/CMakeLists.txt @@ -165,8 +165,14 @@ if(NOT EMSCRIPTEN) if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang|AppleClang") target_compile_options(nisps_parity_check PRIVATE -Wall -Wextra -Werror -Wpedantic + # Disable FP multiply-add contraction so native matches the WASM + # build, which has no FMA instruction. Without this, native clang/gcc + # fuses MACs in the training backprop and the (chaotic) loop amplifies + # the rounding difference past the 1e-5 parity tolerance — pronounced + # since the input layer widened to 32 for mix-and-match inputs. + -ffp-contract=off ) elseif(MSVC) - target_compile_options(nisps_parity_check PRIVATE /W4 /WX) + target_compile_options(nisps_parity_check PRIVATE /W4 /WX /fp:precise) endif() endif() diff --git a/nisps/wasm/bindings.cpp b/nisps/wasm/bindings.cpp index 0404e68..fdfa3f3 100644 --- a/nisps/wasm/bindings.cpp +++ b/nisps/wasm/bindings.cpp @@ -5,24 +5,27 @@ // 2. AudioWorklet processor (playground/src/audio/worklet/...) — engine // calls. (Each instance owns its own WASM module instance.) // -// FIXED-ARCHITECTURE LIMITATION (VERY IMPORTANT) -// ---------------------------------------------- +// ARCHITECTURE (input dim is OVER-PROVISIONED for mix-and-match inputs) +// --------------------------------------------------------------------- // The C++ MLP class is templated on layer sizes (architecture.md §4.1, §6.2). // We instantiate ONE concrete configuration here: // -// using DefaultMLP = nisps::ml::MLP<2, 10, 14, 18, 126>; +// using DefaultMLP = nisps::ml::MLP<32, 10, 14, 18, 126>; // -// This was chosen as the union of the playground use case (2-D joystick → -// 126 synth params) and the largest hidden-layer footprint that still fits -// firmware budgets. `nisps_ml_create()` accepts caller-supplied input_size, -// output_size, hidden[], n_hidden but ONLY validates them against the -// compile-time defaults — extra inputs/outputs are clipped at the boundary. -// If the caller passes incompatible dimensions we still create the module: -// extra inputs are zero-padded, extra outputs are truncated, and the -// hidden-layer override is silently ignored. +// The 32-input dimension is the MAX number of composed input axes the manifold +// front-end can feed (matches MAX_AXES in manifold/src/inputs/input-layer.ts). +// The mix-and-match input layer (Internal XY pad + Game Controller + MIDI) gives +// each active axis its OWN dedicated input slot — NO mean-blending — and feeds +// the remaining (unused) slots a constant 0. The "active input dimension count" +// is a front-end concept: a 2-axis pad uses slots 0–1, a 4-axis pad+stick uses +// 0–3, etc. Because slot assignment is stable and unused slots are held at 0, +// the net behaves as an N-input net where N = active axes; changing N is a +// reshape, after which the front-end resets the weights (recreate-from-scratch, +// behind a confirm modal). 126 outputs cover C15 + any current schema. // -// Future work: ship multiple WASM modules (one per common architecture) or -// rebuild on demand. See architecture.md "open questions" — Stream 7 punts. +// `nisps_ml_create()` accepts caller-supplied input_size/output_size/hidden[] +// but only validates them against these compile-time defaults — extra +// inputs/outputs are clipped at the boundary and hidden overrides are ignored. // // WIRE FORMAT FOR WEIGHTS // ----------------------- @@ -85,7 +88,7 @@ namespace { // * 126 outputs — enough for the C15 mode and any current schema. // // The MLP also has dataset slots, loss history etc. — see mlp.hpp. -using DefaultMLP = nisps::ml::MLP<2u, 10u, 14u, 18u, 126u>; +using DefaultMLP = nisps::ml::MLP<32u, 10u, 14u, 18u, 126u>; constexpr std::size_t kDefaultInputs = DefaultMLP::kInput; constexpr std::size_t kDefaultOutputs = DefaultMLP::kOutput; diff --git a/playground/public/nisps.wasm b/playground/public/nisps.wasm index d2226c2075364b0a39d6b372d04540c1a9eec6fb..9ee47b2fee54b38884064c95cae148f400b7b4a1 100755 GIT binary patch delta 18111 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 E7XaA0UjP6A diff --git a/tests/cpp/parity_check.cpp b/tests/cpp/parity_check.cpp index 99bf291..82da3c3 100644 --- a/tests/cpp/parity_check.cpp +++ b/tests/cpp/parity_check.cpp @@ -21,7 +21,7 @@ // 4. ChannelStrip engine: identical methodology. // // We use the EXACT SAME compile-time MLP architecture as the WASM build: -// MLP<2, 10, 14, 18, 126> +// MLP<32, 10, 14, 18, 126> (32-input max for mix-and-match; see bindings.cpp) // // Output blob format // ------------------ @@ -62,7 +62,7 @@ namespace { -using ParityMLP = nisps::ml::MLP<2u, 10u, 14u, 18u, 126u>; +using ParityMLP = nisps::ml::MLP<32u, 10u, 14u, 18u, 126u>; // The WASM bindings (nisps/wasm/bindings.cpp) sign-extend the 32-bit JS // seed via `s ^ (s << 32)`. To get bit-equal output between native and @@ -133,9 +133,14 @@ int main(int argc, char** argv) { } // ---- Stage 2: training ---- - constexpr std::array, 3u> features = {{ - {{0.1f, 0.9f}}, {{0.5f, 0.5f}}, {{0.9f, 0.1f}}, - }}; + // Feature vectors are NIn(32)-wide: two real axes + zero-pad (the front-end + // feeds the same shape — active axes in the low slots, unused slots at 0). + // add_example requires features.size() >= NIn, so the pad is mandatory. + constexpr std::size_t kNIn = ParityMLP::kInput; + std::array, 3u> features = {}; + features[0][0] = 0.1f; features[0][1] = 0.9f; + features[1][0] = 0.5f; features[1][1] = 0.5f; + features[2][0] = 0.9f; features[2][1] = 0.1f; auto label_for = [](std::size_t i) { std::array out{}; const float a = static_cast(i) * 0.3f + 0.05f; @@ -146,7 +151,7 @@ int main(int argc, char** argv) { }; for (std::size_t i = 0; i < features.size(); ++i) { const auto label = label_for(i); - mlp.add_example(std::span(features[i].data(), 2u), + mlp.add_example(std::span(features[i].data(), kNIn), std::span(label.data(), 126u)); } const float final_loss = mlp.train(0.3f, 50u, 0.0f); diff --git a/tests/cpp/parity_wasm.mjs b/tests/cpp/parity_wasm.mjs index 840242b..afa3f7b 100644 --- a/tests/cpp/parity_wasm.mjs +++ b/tests/cpp/parity_wasm.mjs @@ -191,8 +191,8 @@ async function main() { api.describe(dimsBuf); const dims = new Int32Array(Module.HEAP32.buffer, dimsBuf, 6).slice(); api.free(dimsBuf); - // Expect: [2, 10, 14, 18, 126, 4] - const expectedDims = [2, 10, 14, 18, 126, 4]; + // Expect: [32, 10, 14, 18, 126, 4] (32-input max for mix-and-match) + const expectedDims = [32, 10, 14, 18, 126, 4]; for (let i = 0; i < expectedDims.length; ++i) { if (dims[i] !== expectedDims[i]) { console.error(`[parity_wasm] WASM build has dim[${i}]=${dims[i]}, native expected ${expectedDims[i]}`); @@ -200,10 +200,11 @@ async function main() { process.exit(2); } } + const N_IN = dims[0]; const N_OUT = dims[4]; // --- Stage 1: ML inference --- - const ml = api.create(2, N_OUT, 0, 0, SEED); + const ml = api.create(N_IN, N_OUT, 0, 0, SEED); api.drawWeights(ml, 0.5); api.setInput(ml, 0, INPUT_X); api.setInput(ml, 1, INPUT_Y); @@ -226,10 +227,13 @@ async function main() { for (let j = 0; j < N_OUT; ++j) out[j] = a + 0.005 * j; return out; }; - const featBuf = api.malloc(2 * 4); - const featF32 = new Float32Array(api.HEAPF32.buffer, featBuf, 2); + // Feature buffer is NIn-wide (zero-padded): two real axes + unused slots at 0, + // matching the native side and the front-end's mix-and-match input shape. + const featBuf = api.malloc(N_IN * 4); + const featF32 = new Float32Array(api.HEAPF32.buffer, featBuf, N_IN); const labelBuf = api.malloc(N_OUT * 4); for (let i = 0; i < features.length; ++i) { + featF32.fill(0); featF32[0] = features[i][0]; featF32[1] = features[i][1]; const label = labelFor(i);