feat(playground): input/output pipelines + curve catalog

Pure-TS ports of the legacy input + output pipelines:
- src/input/pipeline.ts: deadzone → circular clamp → zoom → centered
  power curve → EMA smoothing → momentum-as-zoom. Exposed as a
  pure function processInput(raw, cfg, state, dt) so the input-store
  can hold the state. Math is intentionally bit-equivalent to the
  legacy js/ui/input-pipeline.js implementation.
- src/output/pipeline.ts: global power curve → EMA smoothing →
  slew-rate limit → freeze gate (global + per-output mask).
- src/output/curves.ts: named curve catalog (linear/exp/log/square/
  sqrt/sigmoid/cubic/centered_power) — the TS half of the contract
  defined in nisps/core/math.hpp (stream 1). Golden-vector tests
  in stream 11 will keep them in lockstep.

Stream 8 of the rewrite (meml-911).
This commit is contained in:
w1n5t0n 2026-04-29 15:37:46 +03:00
parent a160b72295
commit 665e224122
3 changed files with 588 additions and 0 deletions

View file

@ -0,0 +1,307 @@
/**
* Input pipeline pure TS port of legacy `js/ui/input-pipeline.js`.
*
* Stages (in order), each input/output in [0,1]:
* 0. Invert (per-axis flip)
* 1. Deadzone (suppress jitter near center, remap live zone to [0,1])
* 2. Circular clamp (constrain to unit disk centered at 0.5,0.5)
* 3. Zoom (narrow window around anchor, modulated by momentum)
* 4. Centered power curve (per-axis exponent)
* 5. EMA smoothing (frame-rate-independent)
* 6. Momentum-as-zoom update (consumed next frame)
*
* `processInput` is a pure function over (raw, cfg, prev): returns the new
* processed coordinate plus the next-frame state. Consumer (input-store)
* holds the state and calls this each frame.
*
* Math is intentionally bit-for-bit equivalent to the legacy implementation.
*/
import { clamp, curveCenteredPower } from '../output/curves';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
export const ZOOM_MIN = 0.01;
export const ZOOM_MAX = 1.0;
export const FREEZE_THRESHOLD = ZOOM_MIN;
export const DEADZONE_MAX = 0.4;
export const INPUT_CURVE_MIN = 0.2;
export const INPUT_CURVE_MAX = 5.0;
export const SMOOTHING_MAX = 0.95;
export const VELOCITY_WINDOW_DEFAULT = 150; // ms
const REFERENCE_DT = 1 / 60;
export type MomentumZoomMode = 'off' | 'gentle' | 'strong';
export type AnchorMode = 'auto' | 'sticky' | 'center';
interface MomentumPreset {
factor: number;
minZoomMul: number;
maxZoomMul: number;
}
const MOMENTUM_PRESETS: Record<MomentumZoomMode, MomentumPreset | null> = {
off: null,
gentle: { factor: 0.6, minZoomMul: 0.3, maxZoomMul: 1.0 },
strong: { factor: 1.5, minZoomMul: 0.15, maxZoomMul: 1.0 },
};
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface InputConfig {
/** Global zoom level [0.01, 1.0] */
zoom: number;
/** Optional per-axis zoom; overrides global when not null */
zoomX: number | null;
zoomY: number | null;
/** Anchor point [0,1]^2 (used in sticky/auto modes) */
anchorX: number;
anchorY: number;
anchorMode: AnchorMode;
/** Deadzone fraction of half-travel [0, 0.4] */
deadzone: number;
/** Centered power curve exponent [0.2, 5.0] (1.0 = linear) */
inputCurve: number;
inputCurveX: number | null;
inputCurveY: number | null;
/** EMA smoothing factor [0, 0.95] */
smoothing: number;
/** Momentum-as-zoom preset */
momentumZoom: MomentumZoomMode;
velocityWindow: number;
/** Per-axis inversion */
invertX: boolean;
invertY: boolean;
}
export interface InputState {
/** Last smoothed output x; seed at 0.5 */
smoothedX: number;
smoothedY: number;
/** Velocity history ring used for momentum-zoom */
velocityHistory: ReadonlyArray<{ x: number; y: number; t: number }>;
/** Most recent momentum-zoom multiplier (1 = no scale) */
momentumZoomMultiplier: number;
/** Whether last process call returned frozen=true */
frozen: boolean;
}
export interface ProcessResult {
x: number;
y: number;
frozen: boolean;
/** Next frame's state — consumer should keep this and pass it back. */
state: InputState;
}
// ---------------------------------------------------------------------------
// Defaults
// ---------------------------------------------------------------------------
export function defaultInputConfig(): InputConfig {
return {
zoom: 1.0,
zoomX: null,
zoomY: null,
anchorX: 0.5,
anchorY: 0.5,
anchorMode: 'center',
deadzone: 0,
inputCurve: 1.0,
inputCurveX: null,
inputCurveY: null,
smoothing: 0,
momentumZoom: 'off',
velocityWindow: VELOCITY_WINDOW_DEFAULT,
invertX: false,
invertY: false,
};
}
export function defaultInputState(): InputState {
return {
smoothedX: 0.5,
smoothedY: 0.5,
velocityHistory: [],
momentumZoomMultiplier: 1,
frozen: false,
};
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function applyDeadzone(input: number, deadzone: number): number {
if (deadzone <= 0) return input;
const offset = input - 0.5;
const absOff = Math.abs(offset);
const halfDz = deadzone * 0.5;
if (absOff <= halfDz) return 0.5;
const sign = offset < 0 ? -1 : 1;
const remapped = ((absOff - halfDz) / (0.5 - halfDz)) * 0.5;
return 0.5 + sign * remapped;
}
function applyZoom(input: number, anchor: number, zoomLevel: number): number {
return clamp(anchor + (input - 0.5) * zoomLevel, 0, 1);
}
function emaSmooth(prev: number, raw: number, smoothing: number, dt: number): number {
if (smoothing <= 0) return raw;
const effectiveDt = dt > 0 ? dt : REFERENCE_DT;
const alpha = 1 - smoothing;
const alphaEff = 1 - Math.pow(1 - alpha, effectiveDt / REFERENCE_DT);
return prev + alphaEff * (raw - prev);
}
function updateMomentumZoomMultiplier(
cfg: InputConfig,
state: InputState,
rawX: number,
rawY: number,
dt: number,
): { multiplier: number; history: InputState['velocityHistory'] } {
const preset = MOMENTUM_PRESETS[cfg.momentumZoom];
if (!preset) {
return { multiplier: 1, history: [] };
}
const now = performance.now();
const window = cfg.velocityWindow;
// Append, drop entries older than `window` ms
const trimmed = state.velocityHistory.filter((p) => now - p.t <= window);
const newHist = [...trimmed, { x: rawX, y: rawY, t: now }];
if (newHist.length < 2) {
return { multiplier: 1, history: newHist };
}
const a = newHist[0]!;
const b = newHist[newHist.length - 1]!;
const dtMs = b.t - a.t;
if (dtMs <= 0) return { multiplier: state.momentumZoomMultiplier, history: newHist };
const dx = b.x - a.x;
const dy = b.y - a.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const speed = dist / (dtMs / 1000); // [0,1]-space units per second
const normSpeed = clamp(speed * preset.factor, 0, 1);
// Higher speed → smaller multiplier (zoom out faster movements)
const target = preset.maxZoomMul - (preset.maxZoomMul - preset.minZoomMul) * normSpeed;
// Smooth toward target so the zoom doesn't jitter
const smoothCoeff = clamp(dt * 6, 0, 1);
const next = state.momentumZoomMultiplier + smoothCoeff * (target - state.momentumZoomMultiplier);
return { multiplier: next, history: newHist };
}
function resolveAnchorX(cfg: InputConfig, state: InputState): number {
if (cfg.anchorMode === 'center') return 0.5;
if (cfg.anchorMode === 'sticky') return cfg.anchorX;
// auto: use stored anchor (input-store updates it on zoom changes)
return cfg.anchorX;
}
function resolveAnchorY(cfg: InputConfig, state: InputState): number {
if (cfg.anchorMode === 'center') return 0.5;
if (cfg.anchorMode === 'sticky') return cfg.anchorY;
return cfg.anchorY;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Process raw 2D input through the pipeline.
*
* @param raw raw input [x, y] in [0,1]
* @param cfg pipeline configuration
* @param state prior state (use {@link defaultInputState} on first call)
* @param dt seconds since last call (default 1/60)
*/
export function processInput(
raw: readonly [number, number],
cfg: InputConfig,
state: InputState,
dt: number = REFERENCE_DT,
): ProcessResult {
const safeDt = Math.max(0, dt);
const baseZoomX = cfg.zoomX ?? cfg.zoom;
const baseZoomY = cfg.zoomY ?? cfg.zoom;
const frozenX = baseZoomX <= FREEZE_THRESHOLD;
const frozenY = baseZoomY <= FREEZE_THRESHOLD;
const fullyFrozen = frozenX && frozenY;
if (fullyFrozen) {
return {
x: state.smoothedX,
y: state.smoothedY,
frozen: true,
state: { ...state, frozen: true },
};
}
let [rawX, rawY] = raw;
// 0. Invert
let x = cfg.invertX ? 1 - rawX : rawX;
let y = cfg.invertY ? 1 - rawY : rawY;
// 1. Deadzone
x = applyDeadzone(x, cfg.deadzone);
y = applyDeadzone(y, cfg.deadzone);
// 2. Circular clamp to unit disk centered at (0.5, 0.5)
{
const cx = x - 0.5;
const cy = y - 0.5;
const dist = Math.sqrt(cx * cx + cy * cy);
if (dist > 0.5 && dist > 1e-12) {
const scale = 0.5 / dist;
x = 0.5 + cx * scale;
y = 0.5 + cy * scale;
}
}
// 3. Zoom around anchor (with momentum modulation)
const anchorX = resolveAnchorX(cfg, state);
const anchorY = resolveAnchorY(cfg, state);
const effZoomX = frozenX
? FREEZE_THRESHOLD
: clamp(baseZoomX * state.momentumZoomMultiplier, ZOOM_MIN, ZOOM_MAX);
const effZoomY = frozenY
? FREEZE_THRESHOLD
: clamp(baseZoomY * state.momentumZoomMultiplier, ZOOM_MIN, ZOOM_MAX);
x = frozenX ? state.smoothedX : applyZoom(x, anchorX, effZoomX);
y = frozenY ? state.smoothedY : applyZoom(y, anchorY, effZoomY);
// 4. Centered power curve
const curveX = cfg.inputCurveX ?? cfg.inputCurve;
const curveY = cfg.inputCurveY ?? cfg.inputCurve;
if (!frozenX) x = curveCenteredPower(x, curveX);
if (!frozenY) y = curveCenteredPower(y, curveY);
// 5. EMA smoothing
const smoothedX = frozenX ? state.smoothedX : emaSmooth(state.smoothedX, x, cfg.smoothing, safeDt);
const smoothedY = frozenY ? state.smoothedY : emaSmooth(state.smoothedY, y, cfg.smoothing, safeDt);
// 6. Update momentum-zoom for next frame
const { multiplier, history } = updateMomentumZoomMultiplier(cfg, state, rawX, rawY, safeDt);
return {
x: smoothedX,
y: smoothedY,
frozen: false,
state: {
smoothedX,
smoothedY,
velocityHistory: history,
momentumZoomMultiplier: multiplier,
frozen: false,
},
};
}

View file

@ -0,0 +1,128 @@
/**
* Curve catalog TypeScript mirror of the named curves from
* nisps/core/math.hpp (forthcoming, stream 1). All inputs and outputs are in
* [0, 1] unless noted otherwise.
*
* IMPORTANT: This file MUST stay in lockstep with the C++ side. The
* authoritative reference is `nisps/core/math.hpp`. Golden-vector tests
* (stream 11) compare WASM-computed vs TS-computed outputs and fail on
* any drift.
*
* Architecture §5.3:
* linear, exp, log, square, sqrt, sigmoid, cubic, centered_power
*
* The "centered_power" variant comes from the legacy input/output pipelines
* and shapes around 0.5 instead of 0.0. Kept as a named curve because both
* input and output pipelines use it.
*/
export type CurveName =
| 'linear'
| 'exp'
| 'log'
| 'square'
| 'sqrt'
| 'sigmoid'
| 'cubic'
| 'centered_power';
/** Hard clamp to [0, 1]. */
export function clamp01(v: number): number {
if (v < 0) return 0;
if (v > 1) return 1;
return v;
}
/** Generic clamp. */
export function clamp(v: number, lo: number, hi: number): number {
if (v < lo) return lo;
if (v > hi) return hi;
return v;
}
/** Linear: identity. */
export function curveLinear(x: number): number {
return clamp01(x);
}
/** Exponential: e^(k*x) - 1, normalized to [0,1] over [0,1] input. */
export function curveExp(x: number, k: number = 4.0): number {
if (x <= 0) return 0;
if (x >= 1) return 1;
const denom = Math.exp(k) - 1.0;
if (denom === 0) return x;
return (Math.exp(k * x) - 1.0) / denom;
}
/** Inverse of curveExp. */
export function curveLog(x: number, k: number = 4.0): number {
if (x <= 0) return 0;
if (x >= 1) return 1;
const denom = Math.exp(k) - 1.0;
if (denom === 0) return x;
return Math.log(1 + x * denom) / k;
}
/** Square: x^2. */
export function curveSquare(x: number): number {
const v = clamp01(x);
return v * v;
}
/** Square-root. */
export function curveSqrt(x: number): number {
return Math.sqrt(clamp01(x));
}
/** Logistic sigmoid mapped onto [0,1] domain (centered at x=0.5). */
export function curveSigmoid(x: number, slope: number = 8.0): number {
// Sigmoid centered at 0.5 with given slope. Output is in (0, 1).
// Normalize so endpoints map exactly to 0 and 1.
const t = (x - 0.5) * slope;
const s = 1 / (1 + Math.exp(-t));
// Anchor: when x=0, t=-slope/2; when x=1, t=+slope/2
const sLo = 1 / (1 + Math.exp(slope / 2));
const sHi = 1 / (1 + Math.exp(-slope / 2));
return (s - sLo) / (sHi - sLo);
}
/** Cubic ease-in-out. */
export function curveCubic(x: number): number {
const v = clamp01(x);
// Smoothstep cubic: 3v^2 - 2v^3
return v * v * (3 - 2 * v);
}
/**
* Centered power curve. Pivots around 0.5.
*
* exponent < 1 push toward extremes
* exponent = 1 identity
* exponent > 1 pull toward center
*/
export function curveCenteredPower(x: number, exponent: number): number {
if (exponent === 1) return clamp01(x);
const offset = x - 0.5;
const sign = offset < 0 ? -1 : 1;
// Range [-0.5, 0.5] -> [-1, 1] for the power op, then halve back.
const shaped = (sign * Math.pow(Math.abs(offset) * 2, exponent)) / 2;
return clamp01(shaped + 0.5);
}
/** Apply by name. `param` interpretation depends on the curve. */
export function applyCurve(name: CurveName, x: number, param?: number): number {
switch (name) {
case 'linear': return curveLinear(x);
case 'exp': return curveExp(x, param ?? 4.0);
case 'log': return curveLog(x, param ?? 4.0);
case 'square': return curveSquare(x);
case 'sqrt': return curveSqrt(x);
case 'sigmoid': return curveSigmoid(x, param ?? 8.0);
case 'cubic': return curveCubic(x);
case 'centered_power': return curveCenteredPower(x, param ?? 1.0);
}
}
export const CURVE_NAMES: ReadonlyArray<CurveName> = [
'linear', 'exp', 'log', 'square', 'sqrt', 'sigmoid', 'cubic', 'centered_power',
];

View file

@ -0,0 +1,153 @@
/**
* Output pipeline pure TS port of legacy `js/ui/output-pipeline.js`.
*
* Stages (in order) for each output:
* 1. Global power curve (raw^exponent, exponent in [0.2, 5.0])
* 2. Per-output EMA smoothing (frame-rate-independent)
* 3. Slew-rate limiting (max change per second per output)
* 4. Freeze gate (global) and per-output freeze mask
*
* `processOutput` is a pure function: takes the raw output vector, the prior
* processed vector (or null on first call), and config; returns a new
* Float32Array. Consumer (output-store) holds prev between frames.
*
* NOTE: Reuses an internal scratch buffer ONLY when a prev buffer of the
* exact same length is supplied AND `cfg.reuseBuffer === true`. Otherwise it
* allocates a fresh Float32Array (safer for cross-component sharing).
*/
import { clamp, clamp01 } from './curves';
export const GLOBAL_CURVE_MIN = 0.2;
export const GLOBAL_CURVE_MAX = 5.0;
export const SMOOTHING_MAX = 0.95;
export const SLEW_RATE_MIN = 0.005;
const REFERENCE_DT = 1 / 60;
export interface OutputConfig {
/** Power curve exponent applied to ALL outputs. 1 = linear. */
globalCurve: number;
/** EMA smoothing factor [0, 0.95]. */
smoothing: number;
/** Max change per second per output. Infinity = unlimited. */
slewRate: number;
/** Global freeze gate. */
freezeOutput: boolean;
/** Per-output freeze mask (1 = frozen). Length must match output vector. */
freezeMask: Uint8Array | null;
/** If true and prev buffer matches length, reuse it for processed output. */
reuseBuffer: boolean;
}
export interface OutputState {
/** Last processed output (kept here for slew/freeze logic). */
prev: Float32Array | null;
/** Last EMA-smoothed values per output. */
smoothed: Float32Array | null;
}
export function defaultOutputConfig(): OutputConfig {
return {
globalCurve: 1.0,
smoothing: 0,
slewRate: Infinity,
freezeOutput: false,
freezeMask: null,
reuseBuffer: false,
};
}
export function defaultOutputState(): OutputState {
return { prev: null, smoothed: null };
}
function emaSmooth(prev: number, raw: number, smoothing: number, dt: number): number {
if (smoothing <= 0) return raw;
const effectiveDt = dt > 0 ? dt : REFERENCE_DT;
const alpha = 1 - smoothing;
const alphaEff = 1 - Math.pow(1 - alpha, effectiveDt / REFERENCE_DT);
return prev + alphaEff * (raw - prev);
}
/**
* Process raw outputs through global curve smoothing slew freeze gate.
*
* @param raw raw output vector (Float32Array of size N)
* @param cfg pipeline config
* @param state prior state (use {@link defaultOutputState} first call)
* @param dtMs time since last call in milliseconds
* @returns { processed, state } with the new outputs and updated state
*/
export function processOutput(
raw: Float32Array,
cfg: OutputConfig,
state: OutputState,
dtMs: number,
): { processed: Float32Array; state: OutputState } {
const n = raw.length;
const dt = Math.max(0, dtMs / 1000);
let prev = state.prev;
let smoothed = state.smoothed;
if (!prev || prev.length !== n) {
prev = new Float32Array(n);
// Seed from raw on first call
for (let i = 0; i < n; i++) prev[i] = clamp01(raw[i] ?? 0);
}
if (!smoothed || smoothed.length !== n) {
smoothed = new Float32Array(n);
for (let i = 0; i < n; i++) smoothed[i] = clamp01(raw[i] ?? 0);
}
let processed: Float32Array;
if (cfg.reuseBuffer && prev.length === n) {
processed = prev;
} else {
processed = new Float32Array(n);
}
// Stage 1: global curve (mutates a working scratch via direct compute)
const exp = cfg.globalCurve;
if (cfg.freezeOutput) {
// Output frozen: hold prior values.
if (processed !== prev) {
processed.set(prev);
}
return { processed, state: { prev: processed, smoothed } };
}
for (let i = 0; i < n; i++) {
const r = clamp01(raw[i] ?? 0);
const curved = exp === 1.0 ? r : Math.pow(r, exp);
// Per-output freeze
if (cfg.freezeMask && cfg.freezeMask[i]) {
processed[i] = prev[i] ?? curved;
continue;
}
// Stage 2: EMA smoothing
let value = emaSmooth(smoothed[i] ?? curved, curved, cfg.smoothing, dt);
smoothed[i] = value;
// Stage 3: slew-rate limit
if (isFinite(cfg.slewRate) && cfg.slewRate > 0) {
const maxDelta = cfg.slewRate * dt;
const delta = value - (prev[i] ?? value);
if (Math.abs(delta) > maxDelta) {
value = (prev[i] ?? value) + Math.sign(delta) * maxDelta;
}
}
processed[i] = clamp01(value);
}
// Update prev for next call
if (processed !== prev) {
prev = new Float32Array(processed); // copy so caller can hold processed buffer freely
}
return { processed, state: { prev, smoothed } };
}