From 091cfe2716607a0d49af42fdb2ff48f3ccf0f1ea Mon Sep 17 00:00:00 2001 From: monkey-w1n5t0n Date: Sun, 28 Jun 2026 04:23:13 +0200 Subject: [PATCH] =?UTF-8?q?feat(inputs):=20modular=20input=20layer=20?= =?UTF-8?q?=E2=80=94=20adapter=20interface=20+=20XY/MIDI/gamepad=20sources?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workstream F core. Pull-based InputSource interface (N axes + discrete actions), three sources (XYPadSource push-based, WebMidiInputSource with learn-map CCs/notes, GamepadSource single/double stick), and an InputLayer that composes active sources into one N-dim vector at the head of the reactive spine. Arity reduction blends >2 axes down to the fixed 2-input WASM head; the true multi-WASM reshape is a documented TODO (inputs-spec). useInputLayer is the React binding (enable/config/status/channel layout). --- manifold/src/inputs/base-source.ts | 56 ++++++ manifold/src/inputs/gamepad-source.ts | 162 ++++++++++++++++ manifold/src/inputs/index.ts | 24 +++ manifold/src/inputs/input-layer.ts | 225 ++++++++++++++++++++++ manifold/src/inputs/midi-input-source.ts | 219 +++++++++++++++++++++ manifold/src/inputs/types.ts | 101 ++++++++++ manifold/src/inputs/useInputLayer.ts | 235 +++++++++++++++++++++++ manifold/src/inputs/xy-pad-source.ts | 56 ++++++ 8 files changed, 1078 insertions(+) create mode 100644 manifold/src/inputs/base-source.ts create mode 100644 manifold/src/inputs/gamepad-source.ts create mode 100644 manifold/src/inputs/index.ts create mode 100644 manifold/src/inputs/input-layer.ts create mode 100644 manifold/src/inputs/midi-input-source.ts create mode 100644 manifold/src/inputs/types.ts create mode 100644 manifold/src/inputs/useInputLayer.ts create mode 100644 manifold/src/inputs/xy-pad-source.ts diff --git a/manifold/src/inputs/base-source.ts b/manifold/src/inputs/base-source.ts new file mode 100644 index 0000000..8d37582 --- /dev/null +++ b/manifold/src/inputs/base-source.ts @@ -0,0 +1,56 @@ +/** + * BaseSource — shared status + action plumbing for InputSource adapters. + * Subclasses implement axisCount/axisLabels/sample/start/stop. + */ +import type { + InputAction, + InputSource, + InputSourceKind, + InputSourceStatus, +} from './types'; + +export abstract class BaseSource implements InputSource { + abstract readonly kind: InputSourceKind; + abstract readonly label: string; + + protected statusState: InputSourceStatus; + private statusListeners = new Set<(s: InputSourceStatus) => void>(); + private actionListeners = new Set<(a: InputAction) => void>(); + + constructor(initial: InputSourceStatus = { state: 'idle', message: 'idle' }) { + this.statusState = initial; + } + + abstract axisCount(): number; + abstract axisLabels(): string[]; + abstract sample(out: Float32Array, offset: number): number; + abstract start(): Promise | void; + abstract stop(): Promise | void; + + status(): InputSourceStatus { + return this.statusState; + } + + onStatusChange(cb: (s: InputSourceStatus) => void): () => void { + this.statusListeners.add(cb); + return () => { + this.statusListeners.delete(cb); + }; + } + + onAction(cb: (a: InputAction) => void): () => void { + this.actionListeners.add(cb); + return () => { + this.actionListeners.delete(cb); + }; + } + + protected setStatus(s: InputSourceStatus): void { + this.statusState = s; + for (const cb of this.statusListeners) cb(s); + } + + protected emitAction(a: InputAction): void { + for (const cb of this.actionListeners) cb(a); + } +} diff --git a/manifold/src/inputs/gamepad-source.ts b/manifold/src/inputs/gamepad-source.ts new file mode 100644 index 0000000..7ec4d0a --- /dev/null +++ b/manifold/src/inputs/gamepad-source.ts @@ -0,0 +1,162 @@ +/** + * GamepadSource — physical gamepad via the Gamepad API. + * + * Stick mode: + * - 'single' → 2 axes (left stick X/Y) + * - 'double' → 4 axes (left stick X/Y + right stick X/Y) + * + * Standard-mapping gamepad axes are [-1,1]; we remap to [0,1] for the ML head + * and apply a small radial deadzone (sticks rarely rest at exact centre). A + * y-axis flip makes "stick up" = 1 (the API has up = -1). + * + * Pull-based: the Gamepad API is itself a poll-based snapshot + * (`navigator.getGamepads()`), so `sample()` reads the live snapshot directly — + * no event latching needed for axes. Buttons ARE edge-detected each frame (in + * `poll()`, driven by the InputLayer loop) and surfaced as discrete actions. + * + * Graceful degrade: if the Gamepad API is missing OR no pad is connected, the + * source reports `unavailable`/`connecting` and contributes its axes as 0.5 + * (centre) so it never destabilises the composed vector. + */ +import { BaseSource } from './base-source'; +import type { InputSourceKind } from './types'; + +export type StickMode = 'single' | 'double'; + +const DEADZONE = 0.08; + +export class GamepadSource extends BaseSource { + readonly kind: InputSourceKind = 'gamepad'; + readonly label = 'Gamepad'; + + private stickMode: StickMode = 'single'; + private padIndex: number | null = null; + private running = false; + /** Per-button last pressed-state for edge detection. */ + private buttonsDown: boolean[] = []; + + private onConnect = (e: GamepadEvent) => { + if (this.padIndex === null) this.padIndex = e.gamepad.index; + this.setStatus({ state: 'ready', message: `Gamepad: ${e.gamepad.id}` }); + }; + private onDisconnect = (e: GamepadEvent) => { + if (e.gamepad.index === this.padIndex) { + this.padIndex = null; + this.setStatus({ state: 'connecting', message: 'Gamepad disconnected — press a button' }); + } + }; + + isAvailable(): boolean { + return typeof navigator !== 'undefined' && typeof navigator.getGamepads === 'function'; + } + + setStickMode(mode: StickMode): void { + this.stickMode = mode; + } + + getStickMode(): StickMode { + return this.stickMode; + } + + axisCount(): number { + return this.stickMode === 'double' ? 4 : 2; + } + + axisLabels(): string[] { + return this.stickMode === 'double' + ? ['L-X', 'L-Y', 'R-X', 'R-Y'] + : ['L-X', 'L-Y']; + } + + private activePad(): Gamepad | null { + if (!this.isAvailable()) return null; + const pads = navigator.getGamepads(); + if (this.padIndex !== null) { + const p = pads[this.padIndex]; + if (p) return p; + } + // Fall back to the first connected pad. + for (const p of pads) { + if (p) { + this.padIndex = p.index; + return p; + } + } + return null; + } + + sample(out: Float32Array, offset: number): number { + const n = this.axisCount(); + const pad = this.activePad(); + if (!pad) { + for (let i = 0; i < n; i++) out[offset + i] = 0.5; // centre when absent + return n; + } + // Left stick = axes 0,1; right stick = axes 2,3 (standard mapping). + out[offset] = remap(pad.axes[0] ?? 0); + out[offset + 1] = remap(-(pad.axes[1] ?? 0)); // flip: up = 1 + if (n === 4) { + out[offset + 2] = remap(pad.axes[2] ?? 0); + out[offset + 3] = remap(-(pad.axes[3] ?? 0)); + } + return n; + } + + /** Edge-detect button presses → discrete actions. Driven by InputLayer loop. */ + poll(): void { + if (!this.running) return; + const pad = this.activePad(); + if (!pad) return; + if (this.buttonsDown.length !== pad.buttons.length) { + this.buttonsDown = pad.buttons.map((b) => b.pressed); + return; // first frame after (re)connect: prime, don't fire + } + for (let i = 0; i < pad.buttons.length; i++) { + const pressed = pad.buttons[i].pressed; + if (pressed && !this.buttonsDown[i]) { + this.emitAction({ + source: this.kind, + id: `button:${i}`, + label: `Button ${i}`, + value: pad.buttons[i].value || 1, + }); + } + this.buttonsDown[i] = pressed; + } + } + + start(): void { + if (this.running) return; + this.running = true; + if (!this.isAvailable()) { + this.setStatus({ state: 'unavailable', message: 'Gamepad API not supported in this browser' }); + return; + } + window.addEventListener('gamepadconnected', this.onConnect); + window.addEventListener('gamepaddisconnected', this.onDisconnect); + if (this.activePad()) { + this.setStatus({ state: 'ready', message: 'Gamepad ready' }); + } else { + this.setStatus({ state: 'connecting', message: 'Press a button on your gamepad to connect' }); + } + } + + stop(): void { + this.running = false; + if (typeof window !== 'undefined') { + window.removeEventListener('gamepadconnected', this.onConnect); + window.removeEventListener('gamepaddisconnected', this.onDisconnect); + } + this.buttonsDown = []; + this.setStatus({ state: 'idle', message: 'Gamepad off' }); + } +} + +/** Map a [-1,1] stick axis (with radial deadzone) to [0,1]. */ +function remap(v: number): number { + let x = v; + if (x > -DEADZONE && x < DEADZONE) x = 0; + else x = x > 0 ? (x - DEADZONE) / (1 - DEADZONE) : (x + DEADZONE) / (1 - DEADZONE); + const out = (x + 1) / 2; + return out < 0 ? 0 : out > 1 ? 1 : out; +} diff --git a/manifold/src/inputs/index.ts b/manifold/src/inputs/index.ts new file mode 100644 index 0000000..da993a6 --- /dev/null +++ b/manifold/src/inputs/index.ts @@ -0,0 +1,24 @@ +/** + * 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 + * arity-reduction + the documented multi-WASM reshape TODO. + */ +export type { + InputSource, + InputSourceKind, + InputSourceState, + InputSourceStatus, + InputAction, +} from './types'; +export { InputLayer } from './input-layer'; +export type { InputEngineSink } from './input-layer'; +export { XYPadSource } from './xy-pad-source'; +export { WebMidiInputSource } from './midi-input-source'; +export type { MidiBinding, MidiBindingKind } from './midi-input-source'; +export { GamepadSource } from './gamepad-source'; +export type { StickMode } from './gamepad-source'; +export { useInputLayer } from './useInputLayer'; +export type { UseInputLayer, SourceView } from './useInputLayer'; diff --git a/manifold/src/inputs/input-layer.ts b/manifold/src/inputs/input-layer.ts new file mode 100644 index 0000000..a59ed2b --- /dev/null +++ b/manifold/src/inputs/input-layer.ts @@ -0,0 +1,225 @@ +/** + * InputLayer — composes the active InputSource adapters into ONE N-dim input + * vector at the head of the reactive spine, and drives engine.setInputs. + * + * Sits ABOVE the engine: it owns a single rAF loop that, each frame, + * 1. polls poll-based sources (gamepad buttons → actions), + * 2. pulls every active source's axes into the shared `vector` (pull-based), + * 3. blends/maps the N-dim vector down to the engine's input arity, and + * 4. fires engine.setInputs(...) exactly once. + * + * The XY pad remains push-driven via the existing onMove handler — when it's the + * only active source the loop is effectively a no-op for it (it already latched + * 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. + * + * 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()`. + */ +import type { InputAction, InputSource } from './types'; + +/** Minimal engine surface the layer needs (keeps this framework/engine-neutral). */ +export interface InputEngineSink { + setInputs(arr: ReadonlyArray): void; + readonly architecture: { inputSize: number }; +} + +const MAX_AXES = 32; + +export class InputLayer { + private sources: InputSource[] = []; + private engine: InputEngineSink | null = null; + private vector = new Float32Array(MAX_AXES); + private running = false; + private rafId: number | null = null; + private actionListeners = new Set<(a: InputAction) => void>(); + private layoutListeners = new Set<() => void>(); + private unsubActions = new Map void>(); + + attach(engine: InputEngineSink): void { + this.engine = engine; + } + + /** Replace the active source set. Sources already started stay started. */ + setSources(sources: InputSource[]): void { + // Unsubscribe actions from sources being dropped. + for (const [src, unsub] of this.unsubActions) { + if (!sources.includes(src)) { + unsub(); + this.unsubActions.delete(src); + } + } + // Subscribe new sources' actions through to our fan-out. + for (const src of sources) { + if (!this.unsubActions.has(src)) { + this.unsubActions.set(src, src.onAction((a) => this.fanAction(a))); + } + } + this.sources = sources; + this.notifyLayout(); + } + + getSources(): ReadonlyArray { + return this.sources; + } + + /** Total composed axis count across active sources. */ + axisCount(): number { + let n = 0; + for (const s of this.sources) n += s.axisCount(); + return Math.min(n, MAX_AXES); + } + + /** Per-axis labels in composed order ("XY:X", "Gamepad:L-X", …). */ + channelLayout(): { source: string; label: string }[] { + const out: { source: string; label: string }[] = []; + for (const s of this.sources) { + const labels = s.axisLabels(); + for (const l of labels) { + if (out.length >= MAX_AXES) break; + out.push({ source: s.label, label: l }); + } + } + return out; + } + + // ---- loop ---------------------------------------------------------------- + + start(): void { + if (this.running) return; + this.running = true; + const tick = () => { + if (!this.running) return; + this.frame(); + this.rafId = requestAnimationFrame(tick); + }; + this.rafId = requestAnimationFrame(tick); + } + + stop(): void { + this.running = false; + if (this.rafId !== null) { + cancelAnimationFrame(this.rafId); + this.rafId = null; + } + } + + /** One composition tick (also callable directly from a push handler/tests). */ + frame(): void { + const engine = this.engine; + if (!engine || this.sources.length === 0) return; + + // 1. poll poll-based sources (gamepad button edges → actions). + for (const s of this.sources) { + const maybePoll = s as { poll?: () => void }; + if (typeof maybePoll.poll === 'function') maybePoll.poll(); + } + + // 2. pull each source's axes into the shared vector. + let n = 0; + for (const s of this.sources) { + if (n >= MAX_AXES) break; + n += s.sample(this.vector, n); + } + if (n === 0) return; + + // 3. reduce to the engine's input arity (see file header — blend, not fake). + const reduced = this.compose(n, engine.architecture.inputSize); + + // 4. one engine write. + engine.setInputs(reduced); + } + + /** + * Reduce the composed N-axis vector to the engine's input arity. + * + * 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. + */ + 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; + } + return out; + } + + // ---- actions + layout fan-out ------------------------------------------- + + onAction(cb: (a: InputAction) => void): () => void { + this.actionListeners.add(cb); + return () => { + this.actionListeners.delete(cb); + }; + } + + onLayoutChange(cb: () => void): () => void { + this.layoutListeners.add(cb); + return () => { + this.layoutListeners.delete(cb); + }; + } + + private fanAction(a: InputAction): void { + for (const cb of this.actionListeners) cb(a); + } + + private notifyLayout(): void { + for (const cb of this.layoutListeners) cb(); + } + + dispose(): void { + this.stop(); + for (const unsub of this.unsubActions.values()) unsub(); + this.unsubActions.clear(); + this.actionListeners.clear(); + this.layoutListeners.clear(); + } +} diff --git a/manifold/src/inputs/midi-input-source.ts b/manifold/src/inputs/midi-input-source.ts new file mode 100644 index 0000000..d419470 --- /dev/null +++ b/manifold/src/inputs/midi-input-source.ts @@ -0,0 +1,219 @@ +/** + * WebMidiInputSource — a MIDI controller as an N-axis input source (NEW). + * + * Uses `navigator.requestMIDIAccess()` (feature-detected; graceful degrade) and + * listens on every input port. Incoming Control-Change and Note messages drive + * "learned" axes: + * + * - **CC axis**: a learned CC# (per channel) → its 7-bit value mapped to [0,1]. + * - **Note axis**: a learned note → 1 while held (note-on, velocity>0), 0 on + * 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. + * + * Pull-based: messages latch the latest per-binding value into `values`; + * `sample()` copies them out. Hot path performs no IO/allocation. + * + * NOTE: the canonical learn-map spec was `docs/redesign/inputs-spec.md`, which + * is absent from this tree. The shape here follows the WebMidiBackend output + * pattern (src/backends/midi-backend.ts) for symmetry. + */ +import { BaseSource } from './base-source'; +import type { InputSourceKind } from './types'; + +export type MidiBindingKind = 'cc' | 'note'; + +export interface MidiBinding { + kind: MidiBindingKind; + /** CC number or note number. */ + number: number; + /** 1-based MIDI channel (1..16). */ + channel: number; + /** Latest normalised value ∈ [0,1]. */ + value: number; + /** Human label ("CC74 ch1", "Note 60 ch1"). */ + label: string; +} + +const STATUS_BYTE_CC = 0xb0; +const STATUS_BYTE_NOTE_ON = 0x90; +const STATUS_BYTE_NOTE_OFF = 0x80; + +export class WebMidiInputSource extends BaseSource { + readonly kind: InputSourceKind = 'midi'; + readonly label = 'MIDI'; + + private access: MIDIAccess | null = null; + private inputs: MIDIInput[] = []; + private bindings: MidiBinding[] = []; + private learnArmed = false; + private bindingsListeners = new Set<(b: MidiBinding[]) => void>(); + + isAvailable(): boolean { + return typeof navigator !== 'undefined' && typeof navigator.requestMIDIAccess === 'function'; + } + + axisCount(): number { + return this.bindings.length; + } + + axisLabels(): string[] { + return this.bindings.map((b) => b.label); + } + + sample(out: Float32Array, offset: number): number { + const n = this.bindings.length; + for (let i = 0; i < n; i++) out[offset + i] = this.bindings[i].value; + return n; + } + + // ---- Learn-map API (consumed by the dock) ------------------------------- + + /** Arm/disarm MIDI-learn: the next distinct CC/note is captured as an axis. */ + armLearn(armed: boolean): void { + this.learnArmed = armed; + this.setStatus( + armed + ? { state: 'ready', message: 'Learn armed — move a knob or hit a pad' } + : this.readyStatus(), + ); + } + + isLearnArmed(): boolean { + return this.learnArmed; + } + + getBindings(): ReadonlyArray { + return this.bindings; + } + + /** Remove the binding (axis) at index. */ + clearBinding(index: number): void { + if (index < 0 || index >= this.bindings.length) return; + this.bindings.splice(index, 1); + this.notifyBindings(); + } + + clearAllBindings(): void { + this.bindings = []; + this.notifyBindings(); + } + + onBindingsChange(cb: (b: MidiBinding[]) => void): () => void { + this.bindingsListeners.add(cb); + return () => { + this.bindingsListeners.delete(cb); + }; + } + + // ---- Lifecycle ---------------------------------------------------------- + + async start(): Promise { + if (!this.isAvailable()) { + this.setStatus({ state: 'unavailable', message: 'Web MIDI not supported in this browser' }); + return; + } + this.setStatus({ state: 'connecting', message: 'Requesting MIDI access…' }); + try { + this.access = await navigator.requestMIDIAccess!({ sysex: false }); + this.access.onstatechange = () => this.rewire(); + this.rewire(); + this.setStatus(this.readyStatus()); + } catch (err) { + this.setStatus({ state: 'error', message: `MIDI access denied: ${(err as Error).message}` }); + } + } + + stop(): void { + for (const inp of this.inputs) inp.onmidimessage = null; + this.inputs = []; + if (this.access) this.access.onstatechange = null; + this.access = null; + this.learnArmed = false; + this.setStatus({ state: 'idle', message: 'MIDI input off' }); + } + + /** Enumerate the connected input ports (for the dock readout). */ + listInputs(): { id: string; name: string }[] { + if (!this.access) return []; + const out: { id: string; name: string }[] = []; + this.access.inputs.forEach((inp, id) => out.push({ id, name: inp.name ?? `MIDI Input ${id}` })); + return out; + } + + private rewire(): void { + if (!this.access) return; + for (const inp of this.inputs) inp.onmidimessage = null; + this.inputs = []; + this.access.inputs.forEach((inp) => { + inp.onmidimessage = (e) => this.onMessage(e); + this.inputs.push(inp); + }); + if (this.statusState.state !== 'connecting') this.setStatus(this.readyStatus()); + } + + private readyStatus(): { state: 'ready'; message: string } { + const n = this.inputs.length; + return { + state: 'ready', + message: n ? `MIDI in — ${n} port${n > 1 ? 's' : ''}, ${this.bindings.length} axes` : 'MIDI ready — no input ports', + }; + } + + private onMessage(e: MIDIMessageEvent): void { + const data = e.data; + if (!data || data.length < 2) return; + const status = data[0] & 0xf0; + const channel = (data[0] & 0x0f) + 1; + const d1 = data[1]; + const d2 = data.length > 2 ? data[2] : 0; + + if (status === STATUS_BYTE_CC) { + this.handleBindable('cc', 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 }); + } else if (status === STATUS_BYTE_NOTE_OFF || (status === STATUS_BYTE_NOTE_ON && d2 === 0)) { + this.handleBindable('note', d1, channel, 0, /*onlyUpdate*/ true); + } + } + + /** + * Route an incoming bindable message: update a matching binding's value, or — + * if learn is armed — create a new axis binding for it. + */ + private handleBindable( + kind: MidiBindingKind, + number: number, + channel: number, + value: number, + onlyUpdate = false, + ): void { + const existing = this.bindings.find( + (b) => b.kind === kind && 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.notifyBindings(); + } + } + + private notifyBindings(): void { + const snapshot = this.bindings.map((b) => ({ ...b })); + for (const cb of this.bindingsListeners) cb(snapshot); + } +} diff --git a/manifold/src/inputs/types.ts b/manifold/src/inputs/types.ts new file mode 100644 index 0000000..e14d62d --- /dev/null +++ b/manifold/src/inputs/types.ts @@ -0,0 +1,101 @@ +/** + * Modular INPUT layer — adapter interface (workstream F). + * + * The redesign calls for the user to choose the input SOURCE(s) feeding the ML + * head: the XY pad, a MIDI controller (CCs / notes learned onto axes), or a + * gamepad (single-stick = 2 axes, double-stick = 4). Sources are composable — + * the active sources are concatenated into one N-dim input vector at the head of + * the reactive spine (engine.setInput / setInputs). + * + * Design intent (the canonical spec was `docs/redesign/inputs-spec.md`, which is + * NOT present in this tree — see TODO refs below): + * + * - **Pull-based.** The InputLayer owns a single rAF loop and pulls every + * active source's current state once per frame via `sample(out, offset)`. + * Event-driven sources (MIDI, gamepad) latch their latest state into internal + * fields between frames; the pad pushes directly (`pushAxes`) but still reads + * back through the same path. No source schedules its own engine writes. + * - **N axes + discrete actions.** Each source declares an axis COUNT (its + * dimensionality) and writes its axes into a slice of the shared vector. It + * may also surface momentary discrete ACTIONS (e.g. a MIDI note, a gamepad + * button) that the layer fans out to listeners (used for verdict + * commit/perturb without a keyboard). + * - **Graceful degrade.** A source that the platform can't provide + * (`navigator.requestMIDIAccess` missing, no gamepad connected) reports an + * `unavailable` status and contributes zero axes rather than throwing. + * + * Axis values are normalised to [0,1] (the ML head's input domain); the spine's + * input-pipeline then applies deadzone/zoom/curve/etc. + */ + +/** Lifecycle / availability of a source, surfaced to the dock UI. */ +export type InputSourceState = + | 'idle' // constructed, not yet started + | 'connecting' // async permission / device handshake in flight + | 'ready' // live, producing axes + | 'unavailable' // platform/feature not present (graceful degrade) + | 'error'; // start failed + +export interface InputSourceStatus { + state: InputSourceState; + message: string; +} + +/** Stable identity of a source kind. */ +export type InputSourceKind = 'xy-pad' | 'midi' | 'gamepad'; + +/** + * 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 + * console can bind them to commit / perturb / reroll without the keyboard. + */ +export interface InputAction { + source: InputSourceKind; + /** Stable per-source action id ("note:36", "button:0", …). */ + id: string; + /** Human label for the learn-map UI. */ + label: string; + /** 0..1 velocity / analogue value where meaningful (else 1 for a press). */ + value: number; +} + +/** + * Pull-based input source adapter. + * + * Sources are framework-neutral (NO React). The InputLayer composes them; the + * dock store wraps the layer for React. + */ +export interface InputSource { + readonly kind: InputSourceKind; + /** Short human label for the dock ("XY pad", "MIDI", "Gamepad"). */ + readonly label: string; + + /** + * Number of axes this source currently contributes to the input vector. + * MAY change at runtime (e.g. gamepad single↔double stick, MIDI learn-map + * growing). The layer re-reads this each composition. + */ + axisCount(): number; + + /** Per-axis labels for the channel-layout view (length === axisCount()). */ + axisLabels(): string[]; + + /** + * Write this source's current axes (each ∈ [0,1]) into `out` starting at + * `offset`. Returns the number of axes written (=== axisCount()). Pull-based: + * reads latched internal state, performs NO IO. Hot path — no allocation. + */ + sample(out: Float32Array, offset: number): number; + + /** Begin producing (request permissions, attach listeners). Idempotent. */ + start(): Promise | void; + + /** Stop producing + release resources. Idempotent. */ + stop(): Promise | void; + + status(): InputSourceStatus; + onStatusChange(cb: (s: InputSourceStatus) => void): () => void; + + /** Subscribe to discrete actions (notes / buttons). Returns unsubscribe. */ + onAction(cb: (a: InputAction) => void): () => void; +} diff --git a/manifold/src/inputs/useInputLayer.ts b/manifold/src/inputs/useInputLayer.ts new file mode 100644 index 0000000..8db7700 --- /dev/null +++ b/manifold/src/inputs/useInputLayer.ts @@ -0,0 +1,235 @@ +/** + * 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 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. + * + * Discrete actions (MIDI notes / gamepad buttons) are fanned out via + * `onAction` so the console can later bind them to verdicts (commit/perturb). + */ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { EngineApi } from '../engine'; +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'; + +export interface SourceView { + kind: InputSourceKind; + label: string; + enabled: boolean; + status: InputSourceStatus; + axisCount: number; +} + +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. */ + 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. */ + axisCount: number; + /** Engine input arity (the fixed WASM head = 2). */ + engineInputSize: number; + + // ---- gamepad config ---- + gamepadStickMode: StickMode; + setGamepadStickMode: (m: StickMode) => void; + + // ---- midi learn-map ---- + 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; +} + +export function useInputLayer(engine: EngineApi | null): UseInputLayer { + // One layer + one of each source, created once per engine. + const layerRef = useRef(null); + const padRef = useRef(null); + const midiRef = useRef(null); + const gamepadRef = useRef(null); + + if (!layerRef.current) { + layerRef.current = new InputLayer(); + padRef.current = new XYPadSource(); + midiRef.current = new WebMidiInputSource(); + gamepadRef.current = new GamepadSource(); + } + const layer = layerRef.current!; + const pad = padRef.current!; + 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 [statuses, setStatuses] = useState>({ + 'xy-pad': pad.status(), + midi: midi.status(), + gamepad: gamepad.status(), + }); + const [layoutTick, setLayoutTick] = useState(0); + const [gamepadStickMode, setGamepadStickModeState] = useState('single'); + const [midiLearnArmed, setMidiLearnArmed] = useState(false); + const [midiBindings, setMidiBindings] = useState([]); + const [midiInputs, setMidiInputs] = useState<{ id: string; name: string }[]>([]); + + // Attach to engine; start the pad immediately. Wire status/binding listeners. + useEffect(() => { + if (!engine) return; + layer.attach(engine); + pad.start(); + layer.setSources([pad]); + layer.start(); + + const unsubs: (() => void)[] = []; + const wireStatus = (s: InputSource) => + 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))); + unsubs.push( + midi.onBindingsChange((b) => { + setMidiBindings(b); + setLayoutTick((t) => t + 1); + setMidiLearnArmed(midi.isLearnArmed()); + }), + ); + return () => { + for (const u of unsubs) u(); + layer.dispose(); + void midi.stop(); + gamepad.stop(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [engine]); + + // Recompose the active source set whenever the enabled set 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); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enabled]); + + 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(); + else pad.stop(); + } + }, + [midi, gamepad, pad], + ); + + const setGamepadStickMode = useCallback( + (m: StickMode) => { + gamepad.setStickMode(m); + setGamepadStickModeState(m); + setLayoutTick((t) => t + 1); + }, + [gamepad], + ); + + const armMidiLearn = useCallback( + (armed: boolean) => { + midi.armLearn(armed); + setMidiLearnArmed(armed); + }, + [midi], + ); + + const clearMidiBinding = useCallback( + (i: number) => { + midi.clearBinding(i); + setMidiBindings([...midi.getBindings()]); + setLayoutTick((t) => t + 1); + }, + [midi], + ); + const clearMidiBindings = useCallback(() => { + midi.clearAllBindings(); + setMidiBindings([]); + setLayoutTick((t) => t + 1); + }, [midi]); + + const pushPad = useCallback((x: number, y: number) => pad.pushAxes(x, y), [pad]); + const onAction = useCallback((cb: (a: InputAction) => void) => layer.onAction(cb), [layer]); + + const sources: SourceView[] = useMemo( + () => + ([pad, midi, gamepad] as InputSource[]).map((s) => ({ + kind: s.kind, + label: s.label, + enabled: enabled[s.kind], + status: statuses[s.kind], + axisCount: enabled[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], + ); + + const channelLayout = useMemo( + () => layer.channelLayout(), + // eslint-disable-next-line react-hooks/exhaustive-deps + [layoutTick, enabled], + ); + + return { + pushPad, + sources, + setEnabled, + channelLayout, + axisCount: channelLayout.length, + engineInputSize: engine?.architecture.inputSize ?? 2, + gamepadStickMode, + setGamepadStickMode, + midiLearnArmed, + armMidiLearn, + midiBindings, + clearMidiBinding, + clearMidiBindings, + midiInputs, + onAction, + }; +} diff --git a/manifold/src/inputs/xy-pad-source.ts b/manifold/src/inputs/xy-pad-source.ts new file mode 100644 index 0000000..17a43fd --- /dev/null +++ b/manifold/src/inputs/xy-pad-source.ts @@ -0,0 +1,56 @@ +/** + * XYPadSource — the existing on-screen XY pad / joystick / manifold drag, + * wrapped as a 2-axis InputSource. + * + * Push-based: the pad's `onMove(x, y)` handler calls `pushAxes(x, y)`, which + * latches the values. `sample()` reads them back through the standard pull path + * so the XY pad composes identically with poll-based sources (MIDI/gamepad). + * + * This is always available (it's an on-screen widget), so it starts `ready`. + */ +import { BaseSource } from './base-source'; +import type { InputSourceKind } from './types'; + +export class XYPadSource extends BaseSource { + readonly kind: InputSourceKind = 'xy-pad'; + readonly label = 'XY pad'; + + private x = 0.5; + private y = 0.5; + + constructor() { + super({ state: 'ready', message: 'XY pad ready' }); + } + + axisCount(): number { + return 2; + } + + axisLabels(): string[] { + return ['X', 'Y']; + } + + /** Latch the latest pad position (∈ [0,1]). Called from the pad's onMove. */ + pushAxes(x: number, y: number): void { + this.x = clamp01(x); + this.y = clamp01(y); + } + + sample(out: Float32Array, offset: number): number { + out[offset] = this.x; + out[offset + 1] = this.y; + return 2; + } + + start(): void { + this.setStatus({ state: 'ready', message: 'XY pad ready' }); + } + + stop(): void { + this.setStatus({ state: 'idle', message: 'XY pad off' }); + } +} + +function clamp01(v: number): number { + return v < 0 ? 0 : v > 1 ? 1 : v; +}