154 lines
4.7 KiB
TypeScript
154 lines
4.7 KiB
TypeScript
|
|
/**
|
||
|
|
* 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 } };
|
||
|
|
}
|