feat(inputs): modular input layer — adapter interface + XY/MIDI/gamepad sources
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).
This commit is contained in:
parent
19b7f7eee8
commit
091cfe2716
8 changed files with 1078 additions and 0 deletions
56
manifold/src/inputs/base-source.ts
Normal file
56
manifold/src/inputs/base-source.ts
Normal file
|
|
@ -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> | void;
|
||||
abstract stop(): Promise<void> | 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);
|
||||
}
|
||||
}
|
||||
162
manifold/src/inputs/gamepad-source.ts
Normal file
162
manifold/src/inputs/gamepad-source.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
24
manifold/src/inputs/index.ts
Normal file
24
manifold/src/inputs/index.ts
Normal file
|
|
@ -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';
|
||||
225
manifold/src/inputs/input-layer.ts
Normal file
225
manifold/src/inputs/input-layer.ts
Normal file
|
|
@ -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<number>): 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<InputSource, () => 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<InputSource> {
|
||||
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<number>(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<number>(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();
|
||||
}
|
||||
}
|
||||
219
manifold/src/inputs/midi-input-source.ts
Normal file
219
manifold/src/inputs/midi-input-source.ts
Normal file
|
|
@ -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<MidiBinding> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
101
manifold/src/inputs/types.ts
Normal file
101
manifold/src/inputs/types.ts
Normal file
|
|
@ -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> | void;
|
||||
|
||||
/** Stop producing + release resources. Idempotent. */
|
||||
stop(): Promise<void> | void;
|
||||
|
||||
status(): InputSourceStatus;
|
||||
onStatusChange(cb: (s: InputSourceStatus) => void): () => void;
|
||||
|
||||
/** Subscribe to discrete actions (notes / buttons). Returns unsubscribe. */
|
||||
onAction(cb: (a: InputAction) => void): () => void;
|
||||
}
|
||||
235
manifold/src/inputs/useInputLayer.ts
Normal file
235
manifold/src/inputs/useInputLayer.ts
Normal file
|
|
@ -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<InputLayer | null>(null);
|
||||
const padRef = useRef<XYPadSource | null>(null);
|
||||
const midiRef = useRef<WebMidiInputSource | null>(null);
|
||||
const gamepadRef = useRef<GamepadSource | null>(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<Record<InputSourceKind, boolean>>({
|
||||
'xy-pad': true,
|
||||
midi: false,
|
||||
gamepad: false,
|
||||
});
|
||||
const [statuses, setStatuses] = useState<Record<InputSourceKind, InputSourceStatus>>({
|
||||
'xy-pad': pad.status(),
|
||||
midi: midi.status(),
|
||||
gamepad: gamepad.status(),
|
||||
});
|
||||
const [layoutTick, setLayoutTick] = useState(0);
|
||||
const [gamepadStickMode, setGamepadStickModeState] = useState<StickMode>('single');
|
||||
const [midiLearnArmed, setMidiLearnArmed] = useState(false);
|
||||
const [midiBindings, setMidiBindings] = useState<MidiBinding[]>([]);
|
||||
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,
|
||||
};
|
||||
}
|
||||
56
manifold/src/inputs/xy-pad-source.ts
Normal file
56
manifold/src/inputs/xy-pad-source.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in a new issue