memlnaut-nisps/manifold/tests/wasm-load.ts

113 lines
5.2 KiB
TypeScript
Raw Normal View History

feat(manifold)!: route input/output pipelines + curves through the WASM core (P4.3/P4.4) One-core-engine P4.3/P4.4: the input/output pipeline processing and the curve catalog now live in the C++/WASM core (nisps/pipeline/*, nisps/core/math.hpp). The TS ports are deleted and the browser drives the WASM chains. Engine: - WasmIML owns a nisps_pipeline_create handle + bridge buffers and exposes setInputConfig (TS InputConfig → 15-float wire), processInput, resetInput, setOutputConfig (Infinity slew → 0), setOutputFreezeMask, processOutput (in place), resetOutput, curveApply, curveApplyBatch (chunked). Handle + buffers created in init_, freed in dispose, output-sized buffers realloc'd on reshape. - Spine routes setInputs through iml.processInput/processOutput (state lives C++-side); config source-of-truth stays TS-side and is pushed on attach / setInputConfig / setOutputConfig. Preserves ?debug=1 fixed-dt determinism (same dt fed to the WASM calls). EngineApi gains setInputConfig/ setOutputConfig/curveApply/curveApplyBatch. - New types-only modules: pipeline-types.ts (InputConfig/OutputConfig + defaults + wire int mappers) and curve-catalog.ts (CurveName + name→id). types.ts declares the pipeline/curve C ABI. engine barrel updated. - DELETED src/engine/{input-pipeline,output-pipeline,curves}.ts. Tests (P4.4 gate — recorded-gesture regression): - pipeline-golden.test.ts now loads the built WASM (indirect-eval shim, tests/wasm-load.ts) and drives the frozen gesture/output fixtures through the C++ chains, honouring the per-event dt clock contract. Tolerance 1e-5 (non-momentum drift <5e-7). The 3 momentum configs carry 1e-2: proven-inherent f32 drift (a byte-faithful f32 port of the exact original algorithm matches the WASM to <6e-8 while both diverge from the f64 capture by ~7-9e-3), NOT a core bug. - curves-golden.json: linear/square/sqrt/centered_power kept as the original f64 captures (C++ matches within <3e-8); exp/log/sigmoid/cubic RE-BASELINED from the WASM (deliberate switch to firmware-exact k=1 exp/log, slope-6 sigmoid, true cubic x^3). Provenance recorded in-file. - _generate.ts rebuilt as the WASM curve re-baseline tool; pipeline-golden-lib trimmed to pure data builders. Docs: fixtures/README.md + manifold/ONBOARDING.md updated. Gates: typecheck, bun test (9), vite build, playwright e2e (27) all green.
2026-07-18 12:21:35 +02:00
/**
* Load the built nisps WASM module under `bun test` / a Bun script, without a
* DOM. The Emscripten glue (`manifold/public/nisps.js`) is MODULARIZE output
* WITHOUT ES exports it assigns a global `createNispsModule` with only
* CommonJS/AMD fallbacks. Neither `require()` nor `import()` extracts the
* factory cleanly (the public/ dir is a `type:module` sub-package), so we read
* the glue as text and indirect-eval it inside a thin shim that returns the
* factory the same technique as tests/cpp/parity_wasm.mjs.
*
* Returns a friendly wrapper around the pipeline + curve C ABI used by the
* golden tests (the ML surface is exercised elsewhere).
*/
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
interface EmModule {
HEAPF32: Float32Array;
HEAPU8: Uint8Array;
_malloc(bytes: number): number;
_free(ptr: number): void;
cwrap(name: string, ret: string | null, args: string[]): (...a: number[]) => number;
}
type Factory = (opts: { wasmBinary: Uint8Array }) => Promise<EmModule>;
export interface PipelineWasm {
module: EmModule;
pipelineCreate(): number;
pipelineDestroy(p: number): void;
inputSetConfig(p: number, cfg: ArrayLike<number>): void;
/** Returns { x, y, frozen }. */
inputProcess(p: number, x: number, y: number, dtSeconds: number): { x: number; y: number; frozen: boolean };
inputReset(p: number): void;
outputSetConfig(p: number, globalCurve: number, smoothing: number, slewRate: number, freeze: boolean): void;
outputSetFreezeMask(p: number, mask: Uint8Array | null): void;
/** Process `vec` in place (first vec.length floats). */
outputProcess(p: number, vec: Float32Array, dtSeconds: number): void;
outputReset(p: number): void;
curveApply(id: number, x: number, param?: number): number;
}
let cached: PipelineWasm | null = null;
export async function loadPipelineWasm(): Promise<PipelineWasm> {
if (cached) return cached;
const dir = dirname(fileURLToPath(import.meta.url));
const gluePath = join(dir, '..', 'public', 'nisps.js');
const wasmPath = join(dir, '..', 'public', 'nisps.wasm');
const source = readFileSync(gluePath, 'utf8');
// eslint-disable-next-line @typescript-eslint/no-implied-eval
const factory = new Function(
'module', 'exports',
`${source}\n;return typeof createNispsModule === 'function' ? createNispsModule : null;`,
)({ exports: {} }, {}) as Factory | null;
if (typeof factory !== 'function') throw new Error('[wasm-load] createNispsModule not found in glue');
const wasmBinary = readFileSync(wasmPath);
const M = await factory({ wasmBinary });
const cPipelineCreate = M.cwrap('nisps_pipeline_create', 'number', []);
const cPipelineDestroy = M.cwrap('nisps_pipeline_destroy', null, ['number']);
const cInputSetConfig = M.cwrap('nisps_input_set_config', null, ['number', 'number', 'number']);
const cInputProcess = M.cwrap('nisps_input_process', 'number', ['number', 'number', 'number', 'number', 'number']);
const cInputReset = M.cwrap('nisps_input_reset', null, ['number']);
const cOutputSetConfig = M.cwrap('nisps_output_set_config', null, ['number', 'number', 'number', 'number', 'number']);
const cOutputSetFreezeMask = M.cwrap('nisps_output_set_freeze_mask', null, ['number', 'number', 'number']);
const cOutputProcess = M.cwrap('nisps_output_process', null, ['number', 'number', 'number', 'number']);
const cOutputReset = M.cwrap('nisps_output_reset', null, ['number']);
const cCurveApply = M.cwrap('nisps_curve_apply', 'number', ['number', 'number', 'number']);
// Reusable scratch buffers (sized to the fixtures' needs).
const cfgPtr = M._malloc(15 * 4);
const xyPtr = M._malloc(2 * 4);
const OUT_CAP = 64;
const outPtr = M._malloc(OUT_CAP * 4);
const maskPtr = M._malloc(OUT_CAP);
cached = {
module: M,
pipelineCreate: () => cPipelineCreate(),
pipelineDestroy: (p) => cPipelineDestroy(p),
inputSetConfig(p, cfg) {
const v = new Float32Array(M.HEAPF32.buffer, cfgPtr, 15);
for (let i = 0; i < 15; i++) v[i] = cfg[i] ?? 0;
cInputSetConfig(p, cfgPtr, 15);
},
inputProcess(p, x, y, dtSeconds) {
const frozen = cInputProcess(p, x, y, dtSeconds, xyPtr);
const v = new Float32Array(M.HEAPF32.buffer, xyPtr, 2);
return { x: v[0]!, y: v[1]!, frozen: frozen === 1 };
},
inputReset: (p) => cInputReset(p),
outputSetConfig(p, globalCurve, smoothing, slewRate, freeze) {
const slew = Number.isFinite(slewRate) ? slewRate : 0;
cOutputSetConfig(p, globalCurve, smoothing, slew, freeze ? 1 : 0);
},
outputSetFreezeMask(p, mask) {
if (!mask || mask.length === 0) { cOutputSetFreezeMask(p, 0, 0); return; }
const n = Math.min(mask.length, OUT_CAP);
new Uint8Array(M.HEAPU8.buffer, maskPtr, n).set(mask.subarray(0, n));
cOutputSetFreezeMask(p, maskPtr, n);
},
outputProcess(p, vec, dtSeconds) {
const n = Math.min(vec.length, OUT_CAP);
new Float32Array(M.HEAPF32.buffer, outPtr, n).set(vec.subarray(0, n));
cOutputProcess(p, outPtr, n, dtSeconds);
vec.set(new Float32Array(M.HEAPF32.buffer, outPtr, n).subarray(0, n));
},
outputReset: (p) => cOutputReset(p),
curveApply: (id, x, param = 0) => cCurveApply(id, x, param),
};
return cached;
}