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:
parent
a160b72295
commit
665e224122
3 changed files with 588 additions and 0 deletions
307
playground/src/input/pipeline.ts
Normal file
307
playground/src/input/pipeline.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
128
playground/src/output/curves.ts
Normal file
128
playground/src/output/curves.ts
Normal 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',
|
||||
];
|
||||
153
playground/src/output/pipeline.ts
Normal file
153
playground/src/output/pipeline.ts
Normal 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 } };
|
||||
}
|
||||
Loading…
Reference in a new issue