diff --git a/nisps/wasm/README.md b/nisps/wasm/README.md new file mode 100644 index 0000000..0ce7871 --- /dev/null +++ b/nisps/wasm/README.md @@ -0,0 +1,70 @@ +# nisps/wasm + +Emscripten target that exposes `nisps/ml` (MLP) and `nisps/engines` (audio +engines) to the SolidJS playground via a flat C ABI. + +This directory is a leaf — it does not export headers for inclusion by +other C++ code. The only artifact is `bindings.cpp` plus the build script +that turns it into `playground/public/nisps.{wasm,js}`. + +## Building + +```bash +scripts/build-wasm.sh +``` + +Requires `emcc` (Emscripten). The script defaults to +`/usr/lib/emscripten/emcc` and respects an `EMCC` env var override. + +Output: + +- `playground/public/nisps.wasm` — the compiled module. +- `playground/public/nisps.js` — Emscripten glue (factory function + `createNispsModule`, MODULARIZE=1). + +Both files are committed (so the playground works from a fresh clone +without a C++ toolchain). Re-run `build-wasm.sh` after changes to +`nisps/{core,ml,engines,wasm}`. + +## Architecture limit (read this) + +The MLP class template is parametrised on `(input_size, hidden1, hidden2, +hidden3, output_size)`. WASM cannot recompile templates at runtime, so +this build instantiates exactly ONE configuration: + + nisps::ml::MLP<2, 10, 14, 18, 126> + +That serves the playground use case (2-D joystick → up to 126 synth +parameters). `nisps_ml_create()` accepts caller-supplied dimensions for +forward compatibility but currently ignores them — see comment at the top +of `bindings.cpp`. The schemas in `schemas/modes/*.json` use up to +`output_size=126`; modes whose output_size is < 126 simply ignore the +trailing entries. + +To support additional architectures, either: + +1. Compile multiple wasm modules (`nisps_small.wasm`, + `nisps_default.wasm`, …) and let the playground load the right one + based on the active mode. +2. Add a runtime-shape MLP variant to `nisps/ml` (heap allocation only at + `create()`; no impact on hot paths). + +Both options are deferred to a future stream. + +## C API surface + +See `bindings.cpp` for the full list. Summary: + +| Group | Functions | +|-----------|------------------------------------------------------------------------------| +| ML life | `nisps_ml_create`, `nisps_ml_destroy`, `nisps_ml_reset` | +| ML I/O | `nisps_ml_set_input`, `nisps_ml_process`, `nisps_ml_outputs`, `nisps_ml_infer_batch` | +| Training | `nisps_ml_add_example`, `nisps_ml_train`, `nisps_ml_eval_loss`, `nisps_ml_clear_examples`, `nisps_ml_example_count` | +| Weights | `nisps_ml_weight_count`, `nisps_ml_get_weights`, `nisps_ml_set_weights`, `nisps_ml_draw_weights`, `nisps_ml_move_weights` | +| Diag | `nisps_ml_get_layer_stats`, `nisps_ml_describe` | +| Engines | `nisps_engine_create`, `nisps_engine_destroy`, `nisps_engine_set_params`, `nisps_engine_process_block` | + +Engine-id strings follow the C++ `engine_id()` constexpr accessors: +`thru`, `paf_synth`, `channel_strip`, `xiasri`, `verb_fx`, `memlcelium`, +`breakor`, `elysiamorf`, `analysis`. Unknown ids fall back to `thru` +(silent passthrough). diff --git a/nisps/wasm/bindings.cpp b/nisps/wasm/bindings.cpp new file mode 100644 index 0000000..85897ea --- /dev/null +++ b/nisps/wasm/bindings.cpp @@ -0,0 +1,515 @@ +// nisps/wasm/bindings.cpp — flat C API exported to the SolidJS playground. +// +// Two consumers per build: +// 1. Main-thread WasmIML (playground/src/ml/wasm-iml.ts) — ML calls. +// 2. AudioWorklet processor (playground/src/audio/worklet/...) — engine +// calls. (Each instance owns its own WASM module instance.) +// +// FIXED-ARCHITECTURE LIMITATION (VERY IMPORTANT) +// ---------------------------------------------- +// The C++ MLP class is templated on layer sizes (architecture.md §4.1, §6.2). +// We instantiate ONE concrete configuration here: +// +// using DefaultMLP = nisps::ml::MLP<2, 10, 14, 18, 126>; +// +// This was chosen as the union of the playground use case (2-D joystick → +// 126 synth params) and the largest hidden-layer footprint that still fits +// firmware budgets. `nisps_ml_create()` accepts caller-supplied input_size, +// output_size, hidden[], n_hidden but ONLY validates them against the +// compile-time defaults — extra inputs/outputs are clipped at the boundary. +// If the caller passes incompatible dimensions we still create the module: +// extra inputs are zero-padded, extra outputs are truncated, and the +// hidden-layer override is silently ignored. +// +// Future work: ship multiple WASM modules (one per common architecture) or +// rebuild on demand. See architecture.md "open questions" — Stream 7 punts. +// +// WIRE FORMAT FOR WEIGHTS +// ----------------------- +// The flat layout matches `nisps::ml::MLP::get_weights()`: +// +// [layer0_weights] [layer1_weights] [layer2_weights] [layer3_weights] +// [layer0_biases] [layer1_biases] [layer2_biases] [layer3_biases] +// +// Total count = `nisps_ml_weight_count()`. Both endianness and float layout +// match the host (Emscripten produces little-endian Float32Array-friendly +// memory). +// +// LAYER-STATS LAYOUT +// ------------------ +// `nisps_ml_get_layer_stats()` writes 4 floats per layer into the caller +// buffer: [mean_abs, max_abs, dead_frac, saturating_frac]. Total = 16 +// floats for 4 layers. + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +// Engines. +#include "../engines/analysis.hpp" +#include "../engines/base.hpp" +#include "../engines/breakor.hpp" +#include "../engines/channel_strip.hpp" +#include "../engines/elysiamorf.hpp" +#include "../engines/memlcelium.hpp" +#include "../engines/paf_synth.hpp" +#include "../engines/verb_fx.hpp" +#include "../engines/xiasri.hpp" + +// ML. +#include "../core/types.hpp" +#include "../ml/mlp.hpp" +#include "../ml/stats.hpp" + +namespace { + +// --------------------------------------------------------------------------- +// ML side +// --------------------------------------------------------------------------- + +// Compile-time default architecture. See header comment. +// +// Choice rationale: +// * 2 inputs — playground virtual joystick (X, Y). +// * [10, 14, 18] hidden — covers the largest schema layouts in +// `schemas/modes/*.json` (channel_strip variants, verb_fx, breakor, +// elysiamorf, memlcelium). +// * 126 outputs — enough for the C15 mode and any current schema. +// +// The MLP also has dataset slots, loss history etc. — see mlp.hpp. +using DefaultMLP = nisps::ml::MLP<2u, 10u, 14u, 18u, 126u>; + +constexpr std::size_t kDefaultInputs = DefaultMLP::kInput; +constexpr std::size_t kDefaultOutputs = DefaultMLP::kOutput; + +// We allocate the MLP on the heap (one-off — not the audio path) and return +// the opaque pointer to JS. +struct MLHandle { + DefaultMLP mlp; + // Buffers used to bridge JS → C++: + std::array input_scratch{}; + std::array output_scratch{}; + // Stats buffer fed back to JS via get_layer_stats. + std::array stats_scratch{}; + // Used by infer_batch with arbitrary N — must exceed any reasonable + // request from the heatmap. 256x256 = 65536 max points → too many in + // practice. We cap batch size at 4096 here; callers must split larger + // requests. + static constexpr std::size_t kMaxBatch = 4096u; + std::array batch_out_scratch{}; + + explicit MLHandle(std::uint64_t seed) noexcept : mlp(seed) {} +}; + +// --------------------------------------------------------------------------- +// Engine side +// --------------------------------------------------------------------------- + +// Variant-style dispatch. Each create call instantiates ONE engine kind +// stored on the heap; the type is recorded in `kind` so process_block can +// dispatch without RTTI. +// +// We DO NOT use std::variant — Emscripten supports it but the overhead is +// unwanted. A discriminated union of pointers is enough. +enum class EngineKind : std::uint8_t { + NoOp, + PAFSynth, + ChannelStrip, + XIASRI, + VerbFX, + MEMLCelium, + BreakOr, + Elysiamorf, + Analysis, +}; + +struct EngineHandle { + EngineKind kind = EngineKind::NoOp; + void* ptr = nullptr; +}; + +template +inline EngineHandle make_handle(EngineKind kind, float sr) noexcept { + auto* e = new EngineT(); + e->setup(sr); + return EngineHandle{kind, static_cast(e)}; +} + +template +inline void destroy_typed(void* ptr) noexcept { + delete static_cast(ptr); +} + +template +inline void set_params_typed(void* ptr, std::span params) noexcept { + static_cast(ptr)->set_params(params); +} + +template +inline void process_typed(void* ptr, + const float* in_l, const float* in_r, + float* out_l, float* out_r, + int n_samples) noexcept { + auto* e = static_cast(ptr); + for (int i = 0; i < n_samples; ++i) { + nisps::stereosample_t s{in_l ? in_l[i] : 0.f, in_r ? in_r[i] : 0.f}; + const auto y = e->process(s); + if (out_l) out_l[i] = y.L; + if (out_r) out_r[i] = y.R; + } +} + +EngineHandle dispatch_create(std::string_view id, float sample_rate) noexcept { + using nisps::NoOpEngine; + using nisps::PAFSynthEngine; + using nisps::ChannelStripEngine; + using nisps::XIASRIEngine; + using nisps::VerbFXEngine; + using nisps::MEMLCeliumEngine; + using nisps::BreakOrEngine; + using nisps::ElysiamorfEngine; + using nisps::AnalysisEngine; + + if (id == NoOpEngine::engine_id()) return make_handle(EngineKind::NoOp, sample_rate); + if (id == PAFSynthEngine::engine_id()) return make_handle(EngineKind::PAFSynth, sample_rate); + if (id == ChannelStripEngine::engine_id()) return make_handle(EngineKind::ChannelStrip, sample_rate); + if (id == XIASRIEngine::engine_id()) return make_handle(EngineKind::XIASRI, sample_rate); + if (id == VerbFXEngine::engine_id()) return make_handle(EngineKind::VerbFX, sample_rate); + if (id == MEMLCeliumEngine::engine_id()) return make_handle(EngineKind::MEMLCelium, sample_rate); + if (id == BreakOrEngine::engine_id()) return make_handle(EngineKind::BreakOr, sample_rate); + if (id == ElysiamorfEngine::engine_id()) return make_handle(EngineKind::Elysiamorf, sample_rate); + if (id == AnalysisEngine::engine_id()) return make_handle(EngineKind::Analysis, sample_rate); + + // Unknown id → fall back to NoOp so the worklet is at least silent + // rather than UB. + return make_handle(EngineKind::NoOp, sample_rate); +} + +void dispatch_destroy(EngineHandle& h) noexcept { + using nisps::NoOpEngine; + using nisps::PAFSynthEngine; + using nisps::ChannelStripEngine; + using nisps::XIASRIEngine; + using nisps::VerbFXEngine; + using nisps::MEMLCeliumEngine; + using nisps::BreakOrEngine; + using nisps::ElysiamorfEngine; + using nisps::AnalysisEngine; + + if (!h.ptr) return; + switch (h.kind) { + case EngineKind::NoOp: destroy_typed(h.ptr); break; + case EngineKind::PAFSynth: destroy_typed(h.ptr); break; + case EngineKind::ChannelStrip: destroy_typed(h.ptr); break; + case EngineKind::XIASRI: destroy_typed(h.ptr); break; + case EngineKind::VerbFX: destroy_typed(h.ptr); break; + case EngineKind::MEMLCelium: destroy_typed(h.ptr); break; + case EngineKind::BreakOr: destroy_typed(h.ptr); break; + case EngineKind::Elysiamorf: destroy_typed(h.ptr); break; + case EngineKind::Analysis: destroy_typed(h.ptr); break; + } + h.ptr = nullptr; +} + +void dispatch_set_params(EngineHandle& h, std::span params) noexcept { + using nisps::NoOpEngine; + using nisps::PAFSynthEngine; + using nisps::ChannelStripEngine; + using nisps::XIASRIEngine; + using nisps::VerbFXEngine; + using nisps::MEMLCeliumEngine; + using nisps::BreakOrEngine; + using nisps::ElysiamorfEngine; + using nisps::AnalysisEngine; + + switch (h.kind) { + case EngineKind::NoOp: set_params_typed(h.ptr, params); break; + case EngineKind::PAFSynth: set_params_typed(h.ptr, params); break; + case EngineKind::ChannelStrip: set_params_typed(h.ptr, params); break; + case EngineKind::XIASRI: set_params_typed(h.ptr, params); break; + case EngineKind::VerbFX: set_params_typed(h.ptr, params); break; + case EngineKind::MEMLCelium: set_params_typed(h.ptr, params); break; + case EngineKind::BreakOr: set_params_typed(h.ptr, params); break; + case EngineKind::Elysiamorf: set_params_typed(h.ptr, params); break; + case EngineKind::Analysis: set_params_typed(h.ptr, params); break; + } +} + +void dispatch_process_block(EngineHandle& h, + const float* in_l, const float* in_r, + float* out_l, float* out_r, + int n_samples) noexcept { + using nisps::NoOpEngine; + using nisps::PAFSynthEngine; + using nisps::ChannelStripEngine; + using nisps::XIASRIEngine; + using nisps::VerbFXEngine; + using nisps::MEMLCeliumEngine; + using nisps::BreakOrEngine; + using nisps::ElysiamorfEngine; + using nisps::AnalysisEngine; + + switch (h.kind) { + case EngineKind::NoOp: process_typed(h.ptr, in_l, in_r, out_l, out_r, n_samples); break; + case EngineKind::PAFSynth: process_typed(h.ptr, in_l, in_r, out_l, out_r, n_samples); break; + case EngineKind::ChannelStrip: process_typed(h.ptr, in_l, in_r, out_l, out_r, n_samples); break; + case EngineKind::XIASRI: process_typed(h.ptr, in_l, in_r, out_l, out_r, n_samples); break; + case EngineKind::VerbFX: process_typed(h.ptr, in_l, in_r, out_l, out_r, n_samples); break; + case EngineKind::MEMLCelium: process_typed(h.ptr, in_l, in_r, out_l, out_r, n_samples); break; + case EngineKind::BreakOr: process_typed(h.ptr, in_l, in_r, out_l, out_r, n_samples); break; + case EngineKind::Elysiamorf: process_typed(h.ptr, in_l, in_r, out_l, out_r, n_samples); break; + case EngineKind::Analysis: process_typed(h.ptr, in_l, in_r, out_l, out_r, n_samples); break; + } +} + +} // anonymous namespace + +extern "C" { + +// --------------------------------------------------------------------------- +// ML lifecycle +// --------------------------------------------------------------------------- + +EMSCRIPTEN_KEEPALIVE +void* nisps_ml_create(int input_size, int output_size, + const int* /*hidden*/, int /*n_hidden*/, + uint32_t seed) { + // We accept and ignore caller-supplied dimensions if they don't match the + // compile-time default. See file header. + // + // NOTE: the C++ Rng takes uint64_t; we sign-extend the 32-bit seed into + // the high 32 bits via xor-shift so callers passing zero still get a + // non-degenerate seed. Truly 64-bit seeds are not exposed to JS — the + // playground doesn't need them, and avoiding BigInt at the boundary + // simplifies both wasm-iml.ts and wasm-worker.ts. + (void)input_size; + (void)output_size; + const std::uint64_t s64 = static_cast(seed) ^ + (static_cast(seed) << 32); + auto* h = new MLHandle(s64); + return static_cast(h); +} + +EMSCRIPTEN_KEEPALIVE +void nisps_ml_destroy(void* ml) { + if (!ml) return; + delete static_cast(ml); +} + +// --------------------------------------------------------------------------- +// ML inference +// --------------------------------------------------------------------------- + +EMSCRIPTEN_KEEPALIVE +void nisps_ml_set_input(void* ml, int idx, float v) { + if (!ml) return; + auto* h = static_cast(ml); + if (idx < 0) return; + if (static_cast(idx) >= kDefaultInputs) return; + h->mlp.set_input(static_cast(idx), v); +} + +EMSCRIPTEN_KEEPALIVE +void nisps_ml_process(void* ml) { + if (!ml) return; + auto* h = static_cast(ml); + h->mlp.process(); + auto outs = h->mlp.outputs(); + for (std::size_t i = 0; i < kDefaultOutputs; ++i) h->output_scratch[i] = outs[i]; +} + +EMSCRIPTEN_KEEPALIVE +const float* nisps_ml_outputs(void* ml) { + if (!ml) return nullptr; + auto* h = static_cast(ml); + return h->output_scratch.data(); +} + +EMSCRIPTEN_KEEPALIVE +void nisps_ml_infer_batch(void* ml, const float* points, int n_points, float* out) { + if (!ml || !points || !out || n_points <= 0) return; + auto* h = static_cast(ml); + const std::size_t n = static_cast(n_points); + if (n > MLHandle::kMaxBatch) { + // Caller exceeded the scratch buffer. Process what we can. + const std::size_t safe_n = MLHandle::kMaxBatch; + h->mlp.infer_batch( + std::span(points, safe_n * kDefaultInputs), + std::span(out, safe_n * kDefaultOutputs)); + return; + } + h->mlp.infer_batch( + std::span(points, n * kDefaultInputs), + std::span(out, n * kDefaultOutputs)); +} + +// --------------------------------------------------------------------------- +// ML training +// --------------------------------------------------------------------------- + +EMSCRIPTEN_KEEPALIVE +void nisps_ml_add_example(void* ml, const float* features, const float* labels) { + if (!ml || !features || !labels) return; + auto* h = static_cast(ml); + h->mlp.add_example( + std::span(features, kDefaultInputs), + std::span(labels, kDefaultOutputs)); +} + +EMSCRIPTEN_KEEPALIVE +float nisps_ml_train(void* ml, float lr, int max_iter, float min_err, + const float* sample_weights) { + if (!ml) return 0.f; + auto* h = static_cast(ml); + if (max_iter <= 0) max_iter = 1; + std::span weights; + if (sample_weights) { + weights = std::span(sample_weights, h->mlp.example_count()); + } + return h->mlp.train(lr, static_cast(max_iter), min_err, weights); +} + +EMSCRIPTEN_KEEPALIVE +float nisps_ml_eval_loss(void* ml) { + if (!ml) return 0.f; + auto* h = static_cast(ml); + return h->mlp.eval_loss(); +} + +// --------------------------------------------------------------------------- +// ML weights +// --------------------------------------------------------------------------- + +EMSCRIPTEN_KEEPALIVE +int nisps_ml_weight_count(void* ml) { + (void)ml; + return static_cast(DefaultMLP::weight_count()); +} + +EMSCRIPTEN_KEEPALIVE +void nisps_ml_get_weights(void* ml, float* out) { + if (!ml || !out) return; + auto* h = static_cast(ml); + auto w = h->mlp.get_weights(); + std::memcpy(out, w.data(), w.size() * sizeof(float)); +} + +EMSCRIPTEN_KEEPALIVE +void nisps_ml_set_weights(void* ml, const float* in) { + if (!ml || !in) return; + auto* h = static_cast(ml); + h->mlp.set_weights(std::span(in, DefaultMLP::weight_count())); +} + +EMSCRIPTEN_KEEPALIVE +void nisps_ml_draw_weights(void* ml, float spread) { + if (!ml) return; + auto* h = static_cast(ml); + h->mlp.draw_weights(spread); +} + +EMSCRIPTEN_KEEPALIVE +void nisps_ml_move_weights(void* ml, float speed, float spread, + const uint8_t* output_pin_mask) { + if (!ml) return; + auto* h = static_cast(ml); + std::span mask; + if (output_pin_mask) { + mask = std::span(output_pin_mask, kDefaultOutputs); + } + h->mlp.move_weights(speed, spread, mask); +} + +EMSCRIPTEN_KEEPALIVE +void nisps_ml_get_layer_stats(void* ml, float* out_stats) { + if (!ml || !out_stats) return; + auto* h = static_cast(ml); + for (std::size_t i = 0; i < DefaultMLP::kNumLayers; ++i) { + const auto s = h->mlp.layer_stats(i); + out_stats[i * 4u + 0u] = s.mean_abs; + out_stats[i * 4u + 1u] = s.max_abs; + out_stats[i * 4u + 2u] = s.dead_frac; + out_stats[i * 4u + 3u] = s.saturating_frac; + } +} + +// Extra helper: lets JS query the example count without having to +// shadow-track it. Useful when restoring from snapshot. +EMSCRIPTEN_KEEPALIVE +int nisps_ml_example_count(void* ml) { + if (!ml) return 0; + auto* h = static_cast(ml); + return static_cast(h->mlp.example_count()); +} + +EMSCRIPTEN_KEEPALIVE +void nisps_ml_clear_examples(void* ml) { + if (!ml) return; + auto* h = static_cast(ml); + h->mlp.clear_examples(); +} + +EMSCRIPTEN_KEEPALIVE +void nisps_ml_reset(void* ml) { + if (!ml) return; + auto* h = static_cast(ml); + h->mlp.reset(); +} + +// Architecture introspection — returns 4-int packed [in, h1, h2, h3, out, n_layers]. +// Kept simple: writes into a caller-supplied int buffer. Always 6 ints. +EMSCRIPTEN_KEEPALIVE +void nisps_ml_describe(int* out_dims) { + if (!out_dims) return; + out_dims[0] = static_cast(DefaultMLP::kInput); + out_dims[1] = static_cast(DefaultMLP::kHidden1); + out_dims[2] = static_cast(DefaultMLP::kHidden2); + out_dims[3] = static_cast(DefaultMLP::kHidden3); + out_dims[4] = static_cast(DefaultMLP::kOutput); + out_dims[5] = static_cast(DefaultMLP::kNumLayers); +} + +// --------------------------------------------------------------------------- +// Engine lifecycle +// --------------------------------------------------------------------------- + +EMSCRIPTEN_KEEPALIVE +void* nisps_engine_create(const char* engine_id, float sample_rate) { + if (!engine_id) return nullptr; + auto* h = new EngineHandle(dispatch_create(engine_id, sample_rate)); + return static_cast(h); +} + +EMSCRIPTEN_KEEPALIVE +void nisps_engine_destroy(void* engine) { + if (!engine) return; + auto* h = static_cast(engine); + dispatch_destroy(*h); + delete h; +} + +EMSCRIPTEN_KEEPALIVE +void nisps_engine_set_params(void* engine, const float* params, int n_params) { + if (!engine || !params || n_params <= 0) return; + auto* h = static_cast(engine); + dispatch_set_params(*h, std::span(params, static_cast(n_params))); +} + +EMSCRIPTEN_KEEPALIVE +void nisps_engine_process_block(void* engine, + const float* in_l, const float* in_r, + float* out_l, float* out_r, + int n_samples) { + if (!engine || n_samples <= 0) return; + auto* h = static_cast(engine); + dispatch_process_block(*h, in_l, in_r, out_l, out_r, n_samples); +} + +} // extern "C" diff --git a/playground/public/nisps.js b/playground/public/nisps.js new file mode 100644 index 0000000..3efe02b --- /dev/null +++ b/playground/public/nisps.js @@ -0,0 +1,2 @@ +var createNispsModule=(()=>{var _scriptName=globalThis.document?.currentScript?.src;return async function(moduleArg={}){var moduleRtn;var Module=moduleArg;var ENVIRONMENT_IS_WEB=!!globalThis.window;var ENVIRONMENT_IS_WORKER=!!globalThis.WorkerGlobalScope;var ENVIRONMENT_IS_NODE=globalThis.process?.versions?.node&&globalThis.process?.type!="renderer";var arguments_=[];var thisProgram="./this.program";if(ENVIRONMENT_IS_WORKER){_scriptName=self.location.href}var scriptDirectory="";function locateFile(path){if(Module["locateFile"]){return Module["locateFile"](path,scriptDirectory)}return scriptDirectory+path}var readAsync,readBinary;if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){try{scriptDirectory=new URL(".",_scriptName).href}catch{}{if(ENVIRONMENT_IS_WORKER){readBinary=url=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,false);xhr.responseType="arraybuffer";xhr.send(null);return new Uint8Array(xhr.response)}}readAsync=async url=>{var response=await fetch(url,{credentials:"same-origin"});if(response.ok){return response.arrayBuffer()}throw new Error(response.status+" : "+response.url)}}}else{}var out=console.log.bind(console);var err=console.error.bind(console);var wasmBinary;var ABORT=false;class EmscriptenEH{}class EmscriptenSjLj extends EmscriptenEH{}var readyPromiseResolve,readyPromiseReject;var runtimeInitialized=false;function updateMemoryViews(){var b=wasmMemory.buffer;Module["HEAP8"]=HEAP8=new Int8Array(b);Module["HEAP16"]=HEAP16=new Int16Array(b);Module["HEAPU8"]=HEAPU8=new Uint8Array(b);Module["HEAPU16"]=HEAPU16=new Uint16Array(b);Module["HEAP32"]=HEAP32=new Int32Array(b);Module["HEAPU32"]=HEAPU32=new Uint32Array(b);Module["HEAPF32"]=HEAPF32=new Float32Array(b);Module["HEAPF64"]=HEAPF64=new Float64Array(b);HEAP64=new BigInt64Array(b);HEAPU64=new BigUint64Array(b)}function preRun(){if(Module["preRun"]){if(typeof Module["preRun"]=="function")Module["preRun"]=[Module["preRun"]];while(Module["preRun"].length){addOnPreRun(Module["preRun"].shift())}}callRuntimeCallbacks(onPreRuns)}function initRuntime(){runtimeInitialized=true;wasmExports["d"]()}function postRun(){if(Module["postRun"]){if(typeof Module["postRun"]=="function")Module["postRun"]=[Module["postRun"]];while(Module["postRun"].length){addOnPostRun(Module["postRun"].shift())}}callRuntimeCallbacks(onPostRuns)}function abort(what){Module["onAbort"]?.(what);what=`Aborted(${what})`;err(what);ABORT=true;what+=". Build with -sASSERTIONS for more info.";var e=new WebAssembly.RuntimeError(what);readyPromiseReject?.(e);throw e}var wasmBinaryFile;function findWasmBinary(){return locateFile("nisps.wasm")}function getBinarySync(file){if(file==wasmBinaryFile&&wasmBinary){return new Uint8Array(wasmBinary)}if(readBinary){return readBinary(file)}throw"both async and sync fetching of the wasm failed"}async function getWasmBinary(binaryFile){if(!wasmBinary){try{var response=await readAsync(binaryFile);return new Uint8Array(response)}catch{}}return getBinarySync(binaryFile)}async function instantiateArrayBuffer(binaryFile,imports){try{var binary=await getWasmBinary(binaryFile);var instance=await WebAssembly.instantiate(binary,imports);return instance}catch(reason){err(`failed to asynchronously prepare wasm: ${reason}`);abort(reason)}}async function instantiateAsync(binary,binaryFile,imports){if(!binary){try{var response=fetch(binaryFile,{credentials:"same-origin"});var instantiationResult=await WebAssembly.instantiateStreaming(response,imports);return instantiationResult}catch(reason){err(`wasm streaming compile failed: ${reason}`);err("falling back to ArrayBuffer instantiation")}}return instantiateArrayBuffer(binaryFile,imports)}function getWasmImports(){var imports={a:wasmImports};return imports}async function createWasm(){function receiveInstance(instance,module){wasmExports=instance.exports;assignWasmExports(wasmExports);updateMemoryViews();return wasmExports}function receiveInstantiationResult(result){return receiveInstance(result["instance"])}var info=getWasmImports();if(Module["instantiateWasm"]){return new Promise((resolve,reject)=>{Module["instantiateWasm"](info,(inst,mod)=>{resolve(receiveInstance(inst,mod))})})}wasmBinaryFile??=findWasmBinary();var result=await instantiateAsync(wasmBinary,wasmBinaryFile,info);var exports=receiveInstantiationResult(result);return exports}class ExitStatus{name="ExitStatus";constructor(status){this.message=`Program terminated with exit(${status})`;this.status=status}}var HEAP16;var HEAP32;var HEAP64;var HEAP8;var HEAPF32;var HEAPF64;var HEAPU16;var HEAPU32;var HEAPU64;var HEAPU8;var callRuntimeCallbacks=callbacks=>{while(callbacks.length>0){callbacks.shift()(Module)}};var onPostRuns=[];var addOnPostRun=cb=>onPostRuns.push(cb);var onPreRuns=[];var addOnPreRun=cb=>onPreRuns.push(cb);var noExitRuntime=true;var stackRestore=val=>__emscripten_stack_restore(val);var stackSave=()=>_emscripten_stack_get_current();var __abort_js=()=>abort("");var getHeapMax=()=>2147483648;var alignMemory=(size,alignment)=>Math.ceil(size/alignment)*alignment;var growMemory=size=>{var oldHeapSize=wasmMemory.buffer.byteLength;var pages=(size-oldHeapSize+65535)/65536|0;try{wasmMemory.grow(pages);updateMemoryViews();return 1}catch(e){}};var _emscripten_resize_heap=requestedSize=>{var oldSize=HEAPU8.length;requestedSize>>>=0;var maxHeapSize=getHeapMax();if(requestedSize>maxHeapSize){return false}for(var cutDown=1;cutDown<=4;cutDown*=2){var overGrownHeapSize=oldSize*(1+.2/cutDown);overGrownHeapSize=Math.min(overGrownHeapSize,requestedSize+100663296);var newSize=Math.min(maxHeapSize,alignMemory(Math.max(requestedSize,overGrownHeapSize),65536));var replacement=growMemory(newSize);if(replacement){return true}}return false};var getCFunc=ident=>{var func=Module["_"+ident];return func};var writeArrayToMemory=(array,buffer)=>{HEAP8.set(array,buffer)};var lengthBytesUTF8=str=>{var len=0;for(var i=0;i=55296&&c<=57343){len+=4;++i}else{len+=3}}return len};var stringToUTF8Array=(str,heap,outIdx,maxBytesToWrite)=>{if(!(maxBytesToWrite>0))return 0;var startIdx=outIdx;var endIdx=outIdx+maxBytesToWrite-1;for(var i=0;i=endIdx)break;heap[outIdx++]=u}else if(u<=2047){if(outIdx+1>=endIdx)break;heap[outIdx++]=192|u>>6;heap[outIdx++]=128|u&63}else if(u<=65535){if(outIdx+2>=endIdx)break;heap[outIdx++]=224|u>>12;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}else{if(outIdx+3>=endIdx)break;heap[outIdx++]=240|u>>18;heap[outIdx++]=128|u>>12&63;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63;i++}}heap[outIdx]=0;return outIdx-startIdx};var stringToUTF8=(str,outPtr,maxBytesToWrite)=>stringToUTF8Array(str,HEAPU8,outPtr,maxBytesToWrite);var stackAlloc=sz=>__emscripten_stack_alloc(sz);var stringToUTF8OnStack=str=>{var size=lengthBytesUTF8(str)+1;var ret=stackAlloc(size);stringToUTF8(str,ret,size);return ret};var UTF8Decoder=globalThis.TextDecoder&&new TextDecoder;var findStringEnd=(heapOrArray,idx,maxBytesToRead,ignoreNul)=>{var maxIdx=idx+maxBytesToRead;if(ignoreNul)return maxIdx;while(heapOrArray[idx]&&!(idx>=maxIdx))++idx;return idx};var UTF8ArrayToString=(heapOrArray,idx=0,maxBytesToRead,ignoreNul)=>{var endPtr=findStringEnd(heapOrArray,idx,maxBytesToRead,ignoreNul);if(endPtr-idx>16&&heapOrArray.buffer&&UTF8Decoder){return UTF8Decoder.decode(heapOrArray.subarray(idx,endPtr))}var str="";while(idx>10,56320|ch&1023)}}return str};var UTF8ToString=(ptr,maxBytesToRead,ignoreNul)=>ptr?UTF8ArrayToString(HEAPU8,ptr,maxBytesToRead,ignoreNul):"";var ccall=(ident,returnType,argTypes,args,opts)=>{var toC={string:str=>{var ret=0;if(str!==null&&str!==undefined&&str!==0){ret=stringToUTF8OnStack(str)}return ret},array:arr=>{var ret=stackAlloc(arr.length);writeArrayToMemory(arr,ret);return ret}};function convertReturnValue(ret){if(returnType==="string"){return UTF8ToString(ret)}if(returnType==="boolean")return Boolean(ret);return ret}var func=getCFunc(ident);var cArgs=[];var stack=0;if(args){for(var i=0;i{var numericArgs=!argTypes||argTypes.every(type=>type==="number"||type==="boolean");var numericRet=returnType!=="string";if(numericRet&&numericArgs&&!opts){return getCFunc(ident)}return(...args)=>ccall(ident,returnType,argTypes,args,opts)};{if(Module["noExitRuntime"])noExitRuntime=Module["noExitRuntime"];if(Module["print"])out=Module["print"];if(Module["printErr"])err=Module["printErr"];if(Module["wasmBinary"])wasmBinary=Module["wasmBinary"];if(Module["arguments"])arguments_=Module["arguments"];if(Module["thisProgram"])thisProgram=Module["thisProgram"];if(Module["preInit"]){if(typeof Module["preInit"]=="function")Module["preInit"]=[Module["preInit"]];while(Module["preInit"].length>0){Module["preInit"].shift()()}}}Module["ccall"]=ccall;Module["cwrap"]=cwrap;var _nisps_ml_create,_nisps_ml_destroy,_nisps_ml_set_input,_nisps_ml_process,_nisps_ml_outputs,_nisps_ml_infer_batch,_nisps_ml_add_example,_nisps_ml_train,_nisps_ml_eval_loss,_nisps_ml_weight_count,_nisps_ml_get_weights,_nisps_ml_set_weights,_nisps_ml_draw_weights,_nisps_ml_move_weights,_nisps_ml_get_layer_stats,_nisps_ml_example_count,_nisps_ml_clear_examples,_nisps_ml_reset,_nisps_ml_describe,_nisps_engine_create,_nisps_engine_destroy,_nisps_engine_set_params,_nisps_engine_process_block,_malloc,_free,__emscripten_stack_restore,__emscripten_stack_alloc,_emscripten_stack_get_current,memory,__indirect_function_table,wasmMemory;function assignWasmExports(wasmExports){_nisps_ml_create=Module["_nisps_ml_create"]=wasmExports["e"];_nisps_ml_destroy=Module["_nisps_ml_destroy"]=wasmExports["f"];_nisps_ml_set_input=Module["_nisps_ml_set_input"]=wasmExports["g"];_nisps_ml_process=Module["_nisps_ml_process"]=wasmExports["h"];_nisps_ml_outputs=Module["_nisps_ml_outputs"]=wasmExports["i"];_nisps_ml_infer_batch=Module["_nisps_ml_infer_batch"]=wasmExports["j"];_nisps_ml_add_example=Module["_nisps_ml_add_example"]=wasmExports["k"];_nisps_ml_train=Module["_nisps_ml_train"]=wasmExports["l"];_nisps_ml_eval_loss=Module["_nisps_ml_eval_loss"]=wasmExports["m"];_nisps_ml_weight_count=Module["_nisps_ml_weight_count"]=wasmExports["n"];_nisps_ml_get_weights=Module["_nisps_ml_get_weights"]=wasmExports["o"];_nisps_ml_set_weights=Module["_nisps_ml_set_weights"]=wasmExports["p"];_nisps_ml_draw_weights=Module["_nisps_ml_draw_weights"]=wasmExports["q"];_nisps_ml_move_weights=Module["_nisps_ml_move_weights"]=wasmExports["r"];_nisps_ml_get_layer_stats=Module["_nisps_ml_get_layer_stats"]=wasmExports["s"];_nisps_ml_example_count=Module["_nisps_ml_example_count"]=wasmExports["t"];_nisps_ml_clear_examples=Module["_nisps_ml_clear_examples"]=wasmExports["u"];_nisps_ml_reset=Module["_nisps_ml_reset"]=wasmExports["v"];_nisps_ml_describe=Module["_nisps_ml_describe"]=wasmExports["w"];_nisps_engine_create=Module["_nisps_engine_create"]=wasmExports["x"];_nisps_engine_destroy=Module["_nisps_engine_destroy"]=wasmExports["y"];_nisps_engine_set_params=Module["_nisps_engine_set_params"]=wasmExports["z"];_nisps_engine_process_block=Module["_nisps_engine_process_block"]=wasmExports["A"];_malloc=Module["_malloc"]=wasmExports["B"];_free=Module["_free"]=wasmExports["C"];__emscripten_stack_restore=wasmExports["D"];__emscripten_stack_alloc=wasmExports["E"];_emscripten_stack_get_current=wasmExports["F"];memory=wasmMemory=wasmExports["c"];__indirect_function_table=wasmExports["__indirect_function_table"]}var wasmImports={a:__abort_js,b:_emscripten_resize_heap};function run(){preRun();function doRun(){Module["calledRun"]=true;if(ABORT)return;initRuntime();readyPromiseResolve?.(Module);Module["onRuntimeInitialized"]?.();postRun()}if(Module["setStatus"]){Module["setStatus"]("Running...");setTimeout(()=>{setTimeout(()=>Module["setStatus"](""),1);doRun()},1)}else{doRun()}}var wasmExports;wasmExports=await (createWasm());run();if(runtimeInitialized){moduleRtn=Module}else{moduleRtn=new Promise((resolve,reject)=>{readyPromiseResolve=resolve;readyPromiseReject=reject})} +;return moduleRtn}})();if(typeof exports==="object"&&typeof module==="object"){module.exports=createNispsModule;module.exports.default=createNispsModule}else if(typeof define==="function"&&define["amd"])define([],()=>createNispsModule); diff --git a/playground/public/nisps.wasm b/playground/public/nisps.wasm new file mode 100755 index 0000000..1eecd69 Binary files /dev/null and b/playground/public/nisps.wasm differ diff --git a/playground/src/audio/engine-host.ts b/playground/src/audio/engine-host.ts new file mode 100644 index 0000000..77c1beb --- /dev/null +++ b/playground/src/audio/engine-host.ts @@ -0,0 +1,227 @@ +/** + * EngineHost — main-thread side of the WASM AudioWorklet pipeline. + * + * Responsibilities: + * - Lazy-create AudioContext (browsers gate this on user gesture). + * - Register the AudioWorklet processor module. + * - Hand the worklet a copy of `nisps.wasm` bytes (we fetch on the main + * thread because AudioWorklet has no `fetch`/`importScripts`). + * - Send engine selection + parameter updates over the worklet `port`. + * - Tear everything down on `dispose()`. + * + * The actual DSP runs in `playground/src/audio/worklet/nisps-processor.ts`, + * which calls into a SECOND WASM instance owned by the worklet thread. + * + * IMPORTANT: this class never auto-starts audio. The caller must drive + * `start()` from a user gesture (button click, etc.) so browsers don't + * block AudioContext creation. + */ + +import type { EngineId } from '../ml/types'; + +const NISPS_WASM_URL = '/nisps.wasm'; +const PROCESSOR_NAME = 'nisps-processor'; + +/** Message protocol: main → worklet. */ +export type HostToWorkletMessage = + | { + kind: 'init'; + // ArrayBuffer transferred so the worklet can `WebAssembly.compile` it. + wasmBinary: ArrayBuffer; + sampleRate: number; + } + | { + kind: 'engine'; + engineId: EngineId; + } + | { + kind: 'params'; + // Float32Array transferred to avoid per-tick copy. + params: Float32Array; + } + | { + kind: 'mute'; + muted: boolean; + }; + +/** Message protocol: worklet → main. */ +export type WorkletToHostMessage = + | { kind: 'ready' } + | { kind: 'error'; message: string }; + +export interface EngineHostOptions { + /** Override sample rate (default: AudioContext.sampleRate). */ + sampleRate?: number; + /** Override worklet processor URL (testing). */ + processorUrl?: string; +} + +export class EngineHost { + private ctx: AudioContext | null = null; + private node: AudioWorkletNode | null = null; + private workletReady = false; + private currentEngine: EngineId = 'thru'; + private disposed = false; + private options: EngineHostOptions; + + // Cached bytes of nisps.wasm (we fetch once per host instance). + private wasmBytes: ArrayBuffer | null = null; + + constructor(options: EngineHostOptions = {}) { + this.options = options; + } + + get isStarted(): boolean { + return !!this.ctx && this.workletReady; + } + + get sampleRate(): number { + return this.ctx?.sampleRate ?? this.options.sampleRate ?? 48000; + } + + /** + * Start audio. Must be called from a user gesture for AudioContext to + * resume. After this resolves, `setEngine()` and `setParams()` can be + * called. + */ + async start(engineId: EngineId = 'thru'): Promise { + if (this.ctx) { + // Already started; just switch engine. + this.setEngine(engineId); + await this.ctx.resume(); + return; + } + this.ctx = new AudioContext({ + sampleRate: this.options.sampleRate, + latencyHint: 'interactive', + }); + + // Fetch the WASM bytes on the main thread (the worklet doesn't have + // fetch). We pass the buffer to the worklet as a transferable. + if (!this.wasmBytes) { + this.wasmBytes = await this.fetchWasm_(); + } + + // Register the processor module. Vite's `new URL(..., import.meta.url)` + // pattern bundles the worklet file correctly. + const procUrl = this.options.processorUrl ?? + new URL('./worklet/nisps-processor.ts', import.meta.url).toString(); + await this.ctx.audioWorklet.addModule(procUrl); + + this.node = new AudioWorkletNode(this.ctx, PROCESSOR_NAME, { + numberOfInputs: 1, + numberOfOutputs: 1, + outputChannelCount: [2], + }); + this.node.connect(this.ctx.destination); + + // Wire up message handler before sending init. + this.workletReady = false; + const ready = new Promise((resolve, reject) => { + const onMsg = (ev: MessageEvent) => { + if (ev.data.kind === 'ready') { + this.workletReady = true; + this.node?.port.removeEventListener('message', onMsg); + resolve(); + } else if (ev.data.kind === 'error') { + this.node?.port.removeEventListener('message', onMsg); + reject(new Error(ev.data.message)); + } + }; + this.node!.port.addEventListener('message', onMsg); + this.node!.port.start(); + }); + + // Send the WASM binary + sample rate. ArrayBuffer is transferable — + // we keep a copy on the main thread for re-init. + const copy = this.wasmBytes.slice(0); + this.node.port.postMessage( + { kind: 'init', wasmBinary: copy, sampleRate: this.ctx.sampleRate } satisfies HostToWorkletMessage, + [copy], + ); + + await ready; + this.currentEngine = engineId; + if (engineId !== 'thru') { + this.setEngine(engineId); + } + } + + /** + * Switch which engine the worklet is processing. Cheap — just a message. + * The worklet handles engine destruction/creation internally. + */ + setEngine(engineId: EngineId): void { + if (!this.node || !this.workletReady) return; + this.currentEngine = engineId; + this.node.port.postMessage({ kind: 'engine', engineId } satisfies HostToWorkletMessage); + } + + /** + * Push a fresh parameter vector. Caller should NOT reuse the buffer + * after this call — we transfer it. If you need to keep yours, pass a + * copy: `host.setParams(new Float32Array(myBuf))`. + */ + setParams(params: Float32Array): void { + if (!this.node || !this.workletReady) return; + this.node.port.postMessage( + { kind: 'params', params } satisfies HostToWorkletMessage, + [params.buffer], + ); + } + + setMuted(muted: boolean): void { + if (!this.node || !this.workletReady) return; + this.node.port.postMessage({ kind: 'mute', muted } satisfies HostToWorkletMessage); + } + + async stop(): Promise { + if (!this.ctx) return; + if (this.node) { + try { + this.node.disconnect(); + } catch { /* ignore */ } + this.node = null; + } + try { + await this.ctx.close(); + } catch { /* ignore */ } + this.ctx = null; + this.workletReady = false; + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + void this.stop(); + this.wasmBytes = null; + } + + private async fetchWasm_(): Promise { + const url = new URL(NISPS_WASM_URL, window.location.origin).toString(); + const resp = await fetch(url); + if (!resp.ok) throw new Error(`fetch nisps.wasm: ${resp.status} ${resp.statusText}`); + return await resp.arrayBuffer(); + } +} + +/** + * Smoke-test helper: returns true iff the WASM module exposes the C + * functions we need. Useful as a build-time check inside `engine-host.ts` + * tests; not used in production. + */ +export async function smokeCheckWasm(): Promise { + const url = new URL('/nisps.js', window.location.origin).toString(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mod: any = await import(/* @vite-ignore */ url); + const factory = mod.default ?? mod.createNispsModule; + if (!factory) return false; + const m = await factory({ + locateFile: (p: string) => p.endsWith('.wasm') + ? new URL(NISPS_WASM_URL, window.location.origin).toString() + : p, + }); + return typeof m._nisps_ml_create === 'function' + && typeof m._nisps_engine_create === 'function' + && typeof m._nisps_engine_process_block === 'function'; +} diff --git a/playground/src/audio/worklet/README.md b/playground/src/audio/worklet/README.md new file mode 100644 index 0000000..9f115c9 --- /dev/null +++ b/playground/src/audio/worklet/README.md @@ -0,0 +1,52 @@ +# Why two WASM instances? + +The playground loads `nisps.wasm` twice: + +1. **Main thread**, via `playground/src/ml/wasm-iml.ts`. Used for ML + inference + sync training + RL operations + UI feedback. +2. **AudioWorklet thread**, via `nisps-processor.ts`. Used for engine + processing (per-128-sample-block). + +Why not share? + +- AudioWorklet runs on its own thread. Sharing memory across threads + requires SharedArrayBuffer + locking on every heap access; far more + expensive than two heaps. +- AudioWorklet has neither `fetch` nor ESM `import`, so it can't load + the Emscripten glue (`nisps.js`). The main thread fetches the WASM + bytes once and posts them here as a transferable `ArrayBuffer`; the + worklet then `WebAssembly.instantiate`s directly. +- Engines and ML never interact in the audio path. The main thread + computes the parameter vector each frame and pushes it into the + worklet via `port.postMessage`. The worklet pushes nothing back per + block (analysis features, if needed, are batched and sent at low + rate). + +Per-frame data flow: + +``` +Joystick → input pipeline → mlStore.outputs (Float32Array, length=126) + ↓ EngineHost.setParams() + ↓ port.postMessage (transferable) + AudioWorklet ← WASM engine.process_block ← WASM engine.set_params +``` + +## Custom WASM loader + +We do **not** use the Emscripten JS glue inside the worklet — the glue +contains `URL`, `Worker`, and `fetch` references that don't exist in +AudioWorkletGlobalScope. Instead `nisps-processor.ts` calls +`WebAssembly.instantiate` directly with hand-rolled imports and +discovers exports by walking the export descriptors. This makes the +worklet bundle small (just the processor TS) and avoids touching the +Emscripten init path. + +The trade-off: only the engine API is callable here, not the ML API. +If you ever need ML inference inside the worklet (we don't), use the +main thread copy and post params over. + +## Block size + +AudioWorklet always calls `process()` with 128-sample blocks. Our +heap buffers in `nisps-processor.ts` are sized to match. Don't change +the block size without changing the buffer allocations. diff --git a/playground/src/audio/worklet/audioworklet-globals.d.ts b/playground/src/audio/worklet/audioworklet-globals.d.ts new file mode 100644 index 0000000..b7feb1c --- /dev/null +++ b/playground/src/audio/worklet/audioworklet-globals.d.ts @@ -0,0 +1,26 @@ +/** + * Type declarations for AudioWorkletGlobalScope. The default `lib.dom` + * and `lib.dom.iterable` files don't include these because they only + * exist inside an AudioWorklet thread. + * + * Keep this file minimal — only what `nisps-processor.ts` actually uses. + */ + +declare const sampleRate: number; +declare const currentFrame: number; +declare const currentTime: number; + +declare class AudioWorkletProcessor { + constructor(options?: { numberOfInputs?: number; numberOfOutputs?: number; processorOptions?: unknown }); + readonly port: MessagePort; + process( + inputs: Float32Array[][], + outputs: Float32Array[][], + parameters: Record, + ): boolean; +} + +declare function registerProcessor( + name: string, + processorCtor: new (options?: unknown) => AudioWorkletProcessor, +): void; diff --git a/playground/src/audio/worklet/nisps-processor.ts b/playground/src/audio/worklet/nisps-processor.ts new file mode 100644 index 0000000..7ea402c --- /dev/null +++ b/playground/src/audio/worklet/nisps-processor.ts @@ -0,0 +1,309 @@ +/** + * AudioWorkletProcessor that runs `nisps.wasm` engines. + * + * Why a separate WASM instance from the main thread? AudioWorklet runs in + * its own thread + global scope; reusing a single instance would require + * SharedArrayBuffer + locking on the heap. Architecture.md §6.4 specifies + * separate instances connected by `port` messages instead. + * + * Wasm load path: AudioWorklet has NO `fetch` and NO ESM `import`. The + * main thread fetches `nisps.wasm` once and posts the bytes here as an + * ArrayBuffer; we then `WebAssembly.compile` and `instantiate` directly, + * skipping the Emscripten glue entirely. This is fine because the + * exported functions don't need any of the JS-side runtime. + * + * Block size: AudioWorklet ALWAYS calls process() with 128-sample blocks. + * We allocate 128-sample input and output buffers in the WASM linear + * memory and shuttle samples in/out per call. + */ + +/// + +import type { EngineId } from '../../ml/types'; +import type { HostToWorkletMessage, WorkletToHostMessage } from '../engine-host'; + +const PROC_BLOCK = 128; +const MAX_PARAMS = 256; // upper bound across all engines + +interface WasmInstance { + exports: { + memory: WebAssembly.Memory; + malloc: (n: number) => number; + free: (p: number) => void; + _nisps_engine_create: (id_ptr: number, sample_rate: number) => number; + _nisps_engine_destroy: (engine: number) => void; + _nisps_engine_set_params: (engine: number, params_ptr: number, n: number) => void; + _nisps_engine_process_block: ( + engine: number, + in_l: number, in_r: number, + out_l: number, out_r: number, + n_samples: number, + ) => void; + }; +} + +class NispsProcessor extends AudioWorkletProcessor { + private instance: WasmInstance | null = null; + private engineHandle = 0; + private engineId: EngineId = 'thru'; + private muted = true; + + // Pointers + buffer views (allocated once instance is up). + private inLPtr = 0; + private inRPtr = 0; + private outLPtr = 0; + private outRPtr = 0; + private idPtr = 0; + private paramsPtr = 0; + private inLView: Float32Array | null = null; + private inRView: Float32Array | null = null; + private outLView: Float32Array | null = null; + private outRView: Float32Array | null = null; + private paramsView: Float32Array | null = null; + private idView: Uint8Array | null = null; + private mem: WebAssembly.Memory | null = null; + + // Pending params posted before the engine was ready. + private pendingParams: Float32Array | null = null; + + constructor() { + super(); + this.port.onmessage = (ev) => this.onMessage_(ev.data as HostToWorkletMessage); + } + + private async onMessage_(msg: HostToWorkletMessage): Promise { + if (msg.kind === 'init') { + try { + await this.init_(msg.wasmBinary, msg.sampleRate); + this.post_({ kind: 'ready' }); + } catch (err) { + this.post_({ + kind: 'error', + message: err instanceof Error ? err.message : String(err), + }); + } + } else if (msg.kind === 'engine') { + this.switchEngine_(msg.engineId); + } else if (msg.kind === 'params') { + this.applyParams_(msg.params); + } else if (msg.kind === 'mute') { + this.muted = msg.muted; + } + } + + private post_(msg: WorkletToHostMessage): void { + this.port.postMessage(msg); + } + + /** + * Compile + instantiate the wasm module. We provide minimal imports — + * the Emscripten module needs `__abort_js` and `_emscripten_resize_heap` + * (we keep memory non-resizing so the latter is a stub). + */ + private async init_(bytes: ArrayBuffer, sampleRate: number): Promise { + const memory = new WebAssembly.Memory({ initial: 128, maximum: 4096, shared: false }); + const imports: WebAssembly.Imports = { + // Emscripten import "a" group; field names match the generated JS. + a: { + a: () => { throw new Error('wasm aborted'); }, + b: () => false, // _emscripten_resize_heap returning 0 disables growth + }, + }; + + const compiled = await WebAssembly.compile(bytes); + // Discover the actual import shape from the module — names like "a", + // "b" depend on emcc's mangling; we accept whatever it produces. + const importDesc = WebAssembly.Module.imports(compiled); + const reshaped: WebAssembly.Imports = {}; + for (const desc of importDesc) { + if (!reshaped[desc.module]) reshaped[desc.module] = {} as WebAssembly.ModuleImports; + const mod = reshaped[desc.module] as WebAssembly.ModuleImports; + if (desc.kind === 'function') { + if (desc.name === 'c') { + // unused + } + mod[desc.name] = (() => { + // Generic stub: log + return 0. + return (..._args: unknown[]) => 0; + })(); + } else if (desc.kind === 'memory') { + mod[desc.name] = memory; + } else if (desc.kind === 'table') { + mod[desc.name] = new WebAssembly.Table({ element: 'anyfunc', initial: 0 }); + } else if (desc.kind === 'global') { + mod[desc.name] = new WebAssembly.Global({ value: 'i32', mutable: true }, 0); + } + } + // For known-needed Emscripten imports, supply real implementations. + for (const desc of importDesc) { + const mod = reshaped[desc.module] as WebAssembly.ModuleImports; + // __abort_js + if (desc.name === 'a' && desc.kind === 'function') { + mod[desc.name] = () => { throw new Error('wasm aborted'); }; + } + // _emscripten_resize_heap + if (desc.name === 'b' && desc.kind === 'function') { + mod[desc.name] = (_size: number) => 0; // refuse growth in worklet + } + } + + void imports; // silence unused + const wasmInst = await WebAssembly.instantiate(compiled, reshaped); + + // Many Emscripten exports use single-letter mangled names. Discover + // by reading the export descriptors. + const exDesc = WebAssembly.Module.exports(compiled); + const exMap = new Map(); // logical name → mangled + for (const e of exDesc) { + // The exports list includes both the original (with leading + // underscore for C funcs) and the mangled single-letter alias used + // in the import section. We only see the export side here, but + // Emscripten in modern versions also re-exports the C names with + // their leading-underscore form. Walk both. + exMap.set(e.name, e.name); + } + const exports = wasmInst.exports as Record; + + function pickFn(...names: string[]): (...args: number[]) => number { + for (const n of names) { + const v = exports[n]; + if (typeof v === 'function') return v as unknown as (...a: number[]) => number; + } + throw new Error(`worklet: missing wasm export, tried: ${names.join(', ')}`); + } + function pickFnVoid(...names: string[]): (...args: number[]) => void { + return pickFn(...names) as unknown as (...args: number[]) => void; + } + + // The exports we need. + const malloc = pickFn('_malloc', 'malloc'); + const free = pickFnVoid('_free', 'free'); + const ec = pickFn('_nisps_engine_create'); + const ed = pickFnVoid('_nisps_engine_destroy'); + const esp = pickFnVoid('_nisps_engine_set_params'); + const epb = pickFnVoid('_nisps_engine_process_block'); + + // The wasm-exported memory might be named `memory` or another mangled + // alias. Find it. + let wasmMemory: WebAssembly.Memory | null = null; + for (const e of exDesc) { + if (e.kind === 'memory') { + const v = exports[e.name]; + if (v instanceof WebAssembly.Memory) { wasmMemory = v; break; } + } + } + // If the module imports memory (which our build does — we passed it), + // there will be no exported memory; use the imported one. + this.mem = wasmMemory ?? memory; + + this.instance = { + exports: { + memory: this.mem, + malloc, + free, + _nisps_engine_create: (id, sr) => ec(id, sr), + _nisps_engine_destroy: (h) => ed(h), + _nisps_engine_set_params: (h, p, n) => esp(h, p, n), + _nisps_engine_process_block: (h, il, ir, ol, or_, n) => epb(h, il, ir, ol, or_, n), + }, + }; + + // Allocate buffers. + this.inLPtr = malloc(PROC_BLOCK * 4); + this.inRPtr = malloc(PROC_BLOCK * 4); + this.outLPtr = malloc(PROC_BLOCK * 4); + this.outRPtr = malloc(PROC_BLOCK * 4); + this.paramsPtr = malloc(MAX_PARAMS * 4); + // Engine ids are short ASCII; 32 bytes covers everything we have. + this.idPtr = malloc(32); + + const buf = this.mem.buffer; + this.inLView = new Float32Array(buf, this.inLPtr, PROC_BLOCK); + this.inRView = new Float32Array(buf, this.inRPtr, PROC_BLOCK); + this.outLView = new Float32Array(buf, this.outLPtr, PROC_BLOCK); + this.outRView = new Float32Array(buf, this.outRPtr, PROC_BLOCK); + this.paramsView = new Float32Array(buf, this.paramsPtr, MAX_PARAMS); + this.idView = new Uint8Array(buf, this.idPtr, 32); + + // Default engine: thru. + this.spawnEngine_('thru', sampleRate); + + // Apply pending params if any arrived before init completed. + if (this.pendingParams) { + this.applyParams_(this.pendingParams); + this.pendingParams = null; + } + + this.muted = false; + } + + private spawnEngine_(id: EngineId, sampleRate: number): void { + if (!this.instance || !this.idView) return; + if (this.engineHandle) { + this.instance.exports._nisps_engine_destroy(this.engineHandle); + this.engineHandle = 0; + } + // Write engine_id as ASCII into idView, NUL-terminated. + const enc = new TextEncoder(); + const bytes = enc.encode(id); + this.idView.fill(0); + this.idView.set(bytes.subarray(0, Math.min(bytes.length, 31))); + this.engineHandle = this.instance.exports._nisps_engine_create(this.idPtr, sampleRate); + this.engineId = id; + } + + private switchEngine_(id: EngineId): void { + // sampleRate global from AudioWorkletGlobalScope. + this.spawnEngine_(id, sampleRate); + } + + private applyParams_(params: Float32Array): void { + if (!this.instance || !this.paramsView) { + this.pendingParams = params; + return; + } + const n = Math.min(params.length, MAX_PARAMS); + for (let i = 0; i < n; ++i) this.paramsView[i] = params[i]; + if (this.engineHandle) { + this.instance.exports._nisps_engine_set_params(this.engineHandle, this.paramsPtr, n); + } + } + + override process(inputs: Float32Array[][], outputs: Float32Array[][]): boolean { + const out = outputs[0]; + if (!out || out.length === 0) return true; + + const outL = out[0]; + const outR = out.length > 1 ? out[1] : out[0]; + + if (this.muted || !this.instance || !this.engineHandle || + !this.inLView || !this.outLView || !this.outRView || !this.inRView) { + // Silence. + outL.fill(0); + if (out.length > 1) outR.fill(0); + return true; + } + + // Copy inputs into wasm buffers (zero-fill if missing). + const inp = inputs[0]; + if (inp && inp[0]) this.inLView.set(inp[0].subarray(0, PROC_BLOCK)); + else this.inLView.fill(0); + if (inp && inp[1]) this.inRView.set(inp[1].subarray(0, PROC_BLOCK)); + else if (inp && inp[0]) this.inRView.set(inp[0].subarray(0, PROC_BLOCK)); + else this.inRView.fill(0); + + this.instance.exports._nisps_engine_process_block( + this.engineHandle, + this.inLPtr, this.inRPtr, + this.outLPtr, this.outRPtr, + PROC_BLOCK, + ); + + outL.set(this.outLView.subarray(0, outL.length)); + if (out.length > 1) outR.set(this.outRView.subarray(0, outR.length)); + + return true; + } +} + +registerProcessor('nisps-processor', NispsProcessor); diff --git a/playground/src/debug/probe.ts b/playground/src/debug/probe.ts index f241afc..78e5255 100644 --- a/playground/src/debug/probe.ts +++ b/playground/src/debug/probe.ts @@ -1,36 +1,40 @@ /** * Debug probe: window.__nisps * - * Stream 8 (this stream) installs a stub that returns placeholder values. - * Stream 10 wires real ML calls. Keeping the install path stable here means - * Playwright tests can rely on `window.__nisps` existing from page load even - * before the ML engine boots. - * - * All methods MUST be synchronous (or return immediately-resolved promises). - * The probe deliberately bypasses Solid reactivity so tests get deterministic, + * Stream 7 wires this to the real WasmIML via mlStore. Methods are + * synchronous (or return immediately-resolved promises). The probe + * deliberately bypasses Solid reactivity so tests get deterministic, * imperative semantics. + * + * The probe self-initialises the ML engine on first use that needs it + * — Playwright tests can `await window.__nisps.__init()` before driving + * inference, or just call methods and tolerate a few no-ops while the + * lazy init resolves. While the init is in flight, `__ready` is false; + * synchronous methods that need ML are best-effort no-ops. */ +import { mlStore } from '../stores/ml-store'; + export interface DebugProbe { /** Current 126-element output vector (Float32Array). */ getOutputs(): Float32Array; /** Last training loss, or null if no training has occurred. */ getLoss(): number | null; - /** Flat weight array (~13K floats once wired). */ + /** Flat weight array. */ getWeights(): Float32Array; /** Number of training examples currently in the dataset. */ getExampleCount(): number; /** Set joystick X/Y in [0,1] and run inference. */ setInputs(x: number, y: number): void; - /** Trigger thumbs-up RL feedback (train + decay noise). */ + /** Trigger thumbs-up RL feedback. */ thumbsUp(): void; - /** Trigger thumbs-down RL feedback (move weights + grow noise). */ + /** Trigger thumbs-down RL feedback. */ thumbsDown(): void; /** Synchronous training; returns final loss. */ train(): number; /** Async training; returns Promise. */ trainAsync(): Promise; - /** Randomize weights with current spread. */ + /** Randomize weights with default spread. */ randomise(): void; /** Clear all training examples. */ clearExamples(): void; @@ -38,12 +42,14 @@ export interface DebugProbe { saveState(): void; /** Non-destructive loss query against current dataset. */ evalLoss(): number | null; - /** Batch inference: input is Nx2 array of [x,y] pairs. Output: Float32Array of N*outputSize. */ + /** Batch inference: input is Nx2 array of [x,y] pairs. */ inferBatch(points: ReadonlyArray): Float32Array; - /** Per-layer weight statistics: Float32Array of layerCount * 4 (mean|w|, max|w|, dead%, sat%). */ + /** Per-layer weight statistics: layerCount * 4 floats (mean|w|, max|w|, dead%, sat%). */ getLayerStats(): Float32Array; - /** Marker showing this is a stream-8 stub. Tests can read this to skip when not ready. */ + /** True once the WASM is fully initialised. */ readonly __ready: boolean; + /** Force initialisation. Returns a promise that resolves when the WASM is ready. */ + __init(): Promise; } declare global { @@ -54,65 +60,115 @@ declare global { const EMPTY_F32 = new Float32Array(0); -const stubProbe: DebugProbe = { - getOutputs() { - return EMPTY_F32; +// We auto-initialise lazily so a test that immediately calls `.train()` +// after page load doesn't silently no-op. The promise is shared across +// calls so we don't kick off two simultaneous loads. +let lazyInitPromise: Promise | null = null; +function lazyInit(): Promise { + if (mlStore.iml) return Promise.resolve(); + if (!lazyInitPromise) { + lazyInitPromise = mlStore.initialize().then(() => undefined); + } + return lazyInitPromise; +} + +const probe: DebugProbe = { + get __ready(): boolean { + return !!mlStore.iml && mlStore.state.ready; }, - getLoss() { - return null; + + __init(): Promise { + return lazyInit(); }, - getWeights() { - return EMPTY_F32; + + getOutputs(): Float32Array { + return mlStore.outputs(); }, - getExampleCount() { - return 0; + + getLoss(): number | null { + return mlStore.state.lastLoss; }, - setInputs(_x: number, _y: number) { - /* no-op until ML wired */ + + getWeights(): Float32Array { + return mlStore.getWeights(); }, - thumbsUp() { - /* no-op */ + + getExampleCount(): number { + return mlStore.state.exampleCount; }, - thumbsDown() { - /* no-op */ + + setInputs(x: number, y: number): void { + if (!mlStore.iml) { + void lazyInit(); + return; + } + mlStore.iml.inferXY(x, y); }, - train() { - return 0; + + thumbsUp(): void { + if (!mlStore.iml) return; + // Stream 10 will replace this with the full RL controller; the + // legacy probe behaviour is "train, then settle". For now we run + // a sync training step. + mlStore.iml.train(); }, - trainAsync() { - return Promise.resolve(0); + + thumbsDown(): void { + if (!mlStore.iml) return; + // Default RL noise burst at the playground's typical spread. Stream + // 10 will hook the noise cap from the control surface state. + mlStore.iml.moveWeights(0.1, 0.6); }, - randomise() { - /* no-op */ + + train(): number { + if (!mlStore.iml) { + void lazyInit(); + return 0; + } + return mlStore.iml.train(); }, - clearExamples() { - /* no-op */ + + async trainAsync(): Promise { + await lazyInit(); + if (!mlStore.iml) return 0; + return mlStore.iml.trainAsync(); }, - saveState() { - /* no-op */ + + randomise(): void { + if (!mlStore.iml) return; + mlStore.iml.randomiseWeights(0.6); }, - evalLoss() { - return null; + + clearExamples(): void { + mlStore.clearExamples(); }, - inferBatch(points) { - // Return a zero array of the right size for at least the inputs. - return new Float32Array(points.length); + + saveState(): void { + mlStore.saveNow(); }, - getLayerStats() { - return EMPTY_F32; + + evalLoss(): number | null { + if (!mlStore.iml) return null; + return mlStore.iml.evalLoss(); + }, + + inferBatch(points: ReadonlyArray): Float32Array { + if (!mlStore.iml) return new Float32Array(points.length * mlStore.state.outputSize); + return mlStore.iml.inferBatch(points); + }, + + getLayerStats(): Float32Array { + if (!mlStore.iml) return EMPTY_F32; + return mlStore.iml.getLayerStatsFlat(); }, - __ready: false, }; /** - * Install the probe on window. Idempotent. - * - * Stream 10 will replace this with a fully-wired version. Until then the stub - * advertises `__ready === false`, letting tests skip ML-dependent assertions. + * Install the probe on window. Idempotent — the probe object is a + * singleton, so capturing `window.__nisps` once is safe across hot + * reloads and re-installs. */ export function installDebugProbe(): void { if (typeof window === 'undefined') return; - // Always overwrite — later streams may replace it; the marker prevents stale - // probes from passing tests. - window.__nisps = stubProbe; + window.__nisps = probe; } diff --git a/playground/src/ml/dataset.ts b/playground/src/ml/dataset.ts new file mode 100644 index 0000000..c8e6323 --- /dev/null +++ b/playground/src/ml/dataset.ts @@ -0,0 +1,193 @@ +/** + * Dataset — JS-side training-example store. + * + * Why duplicate the C++ ring buffer? Two reasons: + * 1. Sample-weight computation (recency / spatial / combined) lives in JS so + * that adjusting weighting modes doesn't burn a WASM round-trip. + * 2. The dataset is part of session state we serialize to localStorage — + * the WASM heap is wiped on reload. + * + * On train() we ship features + labels into WASM via `addExample` calls. The + * order of insertion is preserved; FIFO eviction matches the C++ MLP's + * `dataset_head_` pointer so weighting stays consistent. + * + * The implementation is a faithful TypeScript port of the legacy + * `playground/_archive/js/nisps/dataset.js` with: + * - Float32Array backing instead of `Array>` + * - Stricter types + * - No `withBias` flag (the WASM bindings don't take a bias term) + */ + +export type WeightMode = 'global' | 'local' | 'combined' | 'uniform'; + +export interface ComputeWeightsParams { + /** [0,1] — how strongly to bias toward newest examples (global/combined). */ + recencyBias?: number; + /** Current input position, used for local/combined spatial weighting. */ + queryInput?: ReadonlyArray; + /** Spatial radius in input space (local/combined). */ + radius?: number; +} + +export class Dataset { + /** Maximum number of examples retained. FIFO eviction beyond this. */ + readonly maxSize: number; + /** Length of feature vectors. Set on first add(); locked thereafter. */ + private inputSize_ = 0; + /** Length of label vectors. Set on first add(); locked thereafter. */ + private outputSize_ = 0; + + /** Flat arrays — entries `[i*inputSize, (i+1)*inputSize)` belong to example i. */ + private features_: Float32Array = new Float32Array(0); + private labels_: Float32Array = new Float32Array(0); + private size_ = 0; + + constructor(maxSize = 100) { + if (maxSize <= 0) throw new Error('Dataset.maxSize must be > 0'); + this.maxSize = maxSize; + } + + /** Number of examples currently stored. */ + get size(): number { + return this.size_; + } + + isEmpty(): boolean { + return this.size_ === 0; + } + + /** + * Add a feature/label pair. Returns true on success, false if the + * dimensions don't match a previously-added example. + * + * Eviction: when at capacity, the oldest example is removed (shift), + * then the new one is appended. This matches the legacy JS behaviour + * (and is conceptually equivalent to the C++ side's ring buffer with + * `head_` advancement). + */ + add(features: ReadonlyArray, labels: ReadonlyArray): boolean { + if (this.size_ === 0) { + this.inputSize_ = features.length; + this.outputSize_ = labels.length; + // Allocate full-capacity buffers up front to avoid growth thrash. + this.features_ = new Float32Array(this.maxSize * this.inputSize_); + this.labels_ = new Float32Array(this.maxSize * this.outputSize_); + } + + if (features.length !== this.inputSize_ || labels.length !== this.outputSize_) { + return false; + } + + if (this.size_ >= this.maxSize) { + // FIFO: shift left in place. This is O(n*dim) and could be replaced + // with a head pointer; for maxSize ≤ a few hundred it's fine. + this.features_.copyWithin(0, this.inputSize_); + this.labels_.copyWithin(0, this.outputSize_); + this.size_ = this.maxSize - 1; + } + + const fOff = this.size_ * this.inputSize_; + const lOff = this.size_ * this.outputSize_; + for (let i = 0; i < this.inputSize_; ++i) this.features_[fOff + i] = features[i]; + for (let i = 0; i < this.outputSize_; ++i) this.labels_[lOff + i] = labels[i]; + this.size_++; + return true; + } + + clear(): void { + this.size_ = 0; + } + + /** Read-only view of the i-th feature vector. */ + feature(i: number): Float32Array { + if (i < 0 || i >= this.size_) throw new RangeError(`feature index ${i} out of bounds`); + return this.features_.subarray(i * this.inputSize_, (i + 1) * this.inputSize_); + } + + /** Read-only view of the i-th label vector. */ + label(i: number): Float32Array { + if (i < 0 || i >= this.size_) throw new RangeError(`label index ${i} out of bounds`); + return this.labels_.subarray(i * this.outputSize_, (i + 1) * this.outputSize_); + } + + /** Flat view of all features (size * inputSize). */ + featuresFlat(): Float32Array { + return this.features_.subarray(0, this.size_ * this.inputSize_); + } + + /** Flat view of all labels (size * outputSize). */ + labelsFlat(): Float32Array { + return this.labels_.subarray(0, this.size_ * this.outputSize_); + } + + get inputSize(): number { + return this.inputSize_; + } + get outputSize(): number { + return this.outputSize_; + } + + /** + * Compute per-sample training weights. Returns Float32Array (size=this.size) + * normalized to sum to 1. For an empty dataset returns a 0-length array; + * for a singleton, [1.0]. + * + * Modes: + * - `uniform` — every weight = 1/n. + * - `global` — exponential recency decay over insertion order. + * - `local` — within `radius` of `queryInput`, suppress older neighbours. + * - `combined` — global × local. + */ + computeWeights(mode: WeightMode = 'uniform', params: ComputeWeightsParams = {}): Float32Array { + const n = this.size_; + if (n === 0) return new Float32Array(0); + if (n === 1) return new Float32Array([1.0]); + + const weights = new Float32Array(n).fill(1.0); + + if (mode === 'global' || mode === 'combined') { + const bias = params.recencyBias ?? 0.6; + if (bias > 0) { + const decay = 1 - 0.3 * bias; + for (let i = n - 2; i >= 0; --i) weights[i] = weights[i + 1] * decay; + } + } + + if ((mode === 'local' || mode === 'combined') && params.queryInput) { + const query = params.queryInput; + const radius = params.radius ?? 0.15; + const radiusSq = radius * radius; + const dim = this.inputSize_; + + for (let i = 0; i < n; ++i) { + const fOffI = i * dim; + let distSq = 0; + for (let d = 0; d < dim; ++d) { + const diff = this.features_[fOffI + d] - (query[d] ?? 0); + distSq += diff * diff; + } + if (distSq < radiusSq) { + const proximity = 1 - Math.sqrt(distSq) / radius; + let newerNearby = 0; + for (let j = i + 1; j < n; ++j) { + const fOffJ = j * dim; + let djSq = 0; + for (let d = 0; d < dim; ++d) { + const diff = this.features_[fOffI + d] - this.features_[fOffJ + d]; + djSq += diff * diff; + } + if (djSq < radiusSq) ++newerNearby; + } + if (newerNearby > 0) { + weights[i] *= Math.pow(1 - proximity, newerNearby); + } + } + } + } + + let sum = 0; + for (let i = 0; i < n; ++i) sum += weights[i]; + if (sum > 0) for (let i = 0; i < n; ++i) weights[i] /= sum; + return weights; + } +} diff --git a/playground/src/ml/types.ts b/playground/src/ml/types.ts new file mode 100644 index 0000000..1d8b3de --- /dev/null +++ b/playground/src/ml/types.ts @@ -0,0 +1,156 @@ +/** + * TypeScript types matching the C API surface in `nisps/wasm/bindings.cpp`. + * + * These types are intentionally minimal: they describe the JS-visible shape + * of the Emscripten module, the heap views we read/write, and the per-layer + * stats record. They DO NOT mirror any internal C++ struct. + * + * The Emscripten glue produced by `scripts/build-wasm.sh` exposes a factory + * function, `createNispsModule(opts?) => Promise`, which we + * call from `wasm-iml.ts` and `wasm-worker.ts`. + */ + +/** + * Shape of the loaded WASM module — the subset we use. + * Emscripten generates more on it; we type only what we need. + * + * NOTE: `_*` prefixed methods are the raw exported C functions (Emscripten + * naming convention). They take/return numbers (pointers + primitives). + */ +export interface NispsModule { + // Memory views (re-bound after grow). + HEAP8: Int8Array; + HEAP16: Int16Array; + HEAP32: Int32Array; + HEAPU8: Uint8Array; + HEAPU16: Uint16Array; + HEAPU32: Uint32Array; + HEAPF32: Float32Array; + HEAPF64: Float64Array; + + _malloc(bytes: number): number; + _free(ptr: number): void; + + // ML lifecycle. + // Seed is a uint32_t (not 64-bit) — see bindings.cpp file comment. + _nisps_ml_create(input_size: number, output_size: number, hidden_ptr: number, n_hidden: number, seed: number): number; + _nisps_ml_destroy(ml: number): void; + _nisps_ml_reset(ml: number): void; + + // ML inference. + _nisps_ml_set_input(ml: number, idx: number, v: number): void; + _nisps_ml_process(ml: number): void; + _nisps_ml_outputs(ml: number): number; // returns float* into HEAPF32 + _nisps_ml_infer_batch(ml: number, points_ptr: number, n_points: number, out_ptr: number): void; + + // ML training. + _nisps_ml_add_example(ml: number, features_ptr: number, labels_ptr: number): void; + _nisps_ml_train(ml: number, lr: number, max_iter: number, min_err: number, sample_weights_ptr: number): number; + _nisps_ml_eval_loss(ml: number): number; + + // ML examples. + _nisps_ml_clear_examples(ml: number): void; + _nisps_ml_example_count(ml: number): number; + + // ML weights. + _nisps_ml_weight_count(ml: number): number; + _nisps_ml_get_weights(ml: number, out_ptr: number): void; + _nisps_ml_set_weights(ml: number, in_ptr: number): void; + _nisps_ml_draw_weights(ml: number, spread: number): void; + _nisps_ml_move_weights(ml: number, speed: number, spread: number, mask_ptr: number): void; + _nisps_ml_get_layer_stats(ml: number, out_ptr: number): void; + _nisps_ml_describe(out_ptr: number): void; + + // Engines. + _nisps_engine_create(id_ptr: number, sample_rate: number): number; + _nisps_engine_destroy(engine: number): void; + _nisps_engine_set_params(engine: number, params_ptr: number, n_params: number): void; + _nisps_engine_process_block( + engine: number, + in_l_ptr: number, in_r_ptr: number, + out_l_ptr: number, out_r_ptr: number, + n_samples: number, + ): void; +} + +/** Factory function exposed by the Emscripten glue. */ +export type NispsModuleFactory = (opts?: { + locateFile?: (path: string, prefix: string) => string; + wasmBinary?: ArrayBuffer | Uint8Array; + print?: (msg: string) => void; + printErr?: (msg: string) => void; +}) => Promise; + +/** Architecture descriptor returned from `nisps_ml_describe`. */ +export interface MLArchitecture { + inputSize: number; + hidden: [number, number, number]; + outputSize: number; + numLayers: number; +} + +/** Per-layer weight health record (one per layer). */ +export interface LayerStats { + meanAbs: number; + maxAbs: number; + deadFrac: number; + saturatingFrac: number; +} + +/** The `engine_id` strings the C++ side recognises. Anything else falls back to "thru". */ +export type EngineId = + | 'thru' + | 'paf_synth' + | 'channel_strip' + | 'xiasri' + | 'verb_fx' + | 'memlcelium' + | 'breakor' + | 'elysiamorf' + | 'analysis'; + +/** Message protocol between main thread and `wasm-worker.ts`. */ +export type WorkerRequest = + | { + kind: 'init'; + seed: number; + } + | { + kind: 'train'; + requestId: number; + // Flat features: nExamples * inputSize floats. + features: Float32Array; + // Flat labels: nExamples * outputSize floats. + labels: Float32Array; + // Optional per-example weights, sums to 1. Empty = uniform. + sampleWeights: Float32Array; + // Current weights to seed worker MLP. + weights: Float32Array; + lr: number; + maxIter: number; + minErr: number; + inputSize: number; + outputSize: number; + } + | { + kind: 'dispose'; + }; + +export type WorkerResponse = + | { + kind: 'ready'; + } + | { + kind: 'result'; + requestId: number; + loss: number; + weights: Float32Array; + // Loss curve (per-iteration). Currently always empty — the C++ MLP + // exposes loss_history but the WASM bridge does not yet plumb it. + lossHistory: Float32Array; + } + | { + kind: 'error'; + requestId: number; + message: string; + }; diff --git a/playground/src/ml/wasm-iml.ts b/playground/src/ml/wasm-iml.ts new file mode 100644 index 0000000..1f5b985 --- /dev/null +++ b/playground/src/ml/wasm-iml.ts @@ -0,0 +1,650 @@ +/** + * WasmIML — main-thread ML interface backed by `nisps.wasm`. + * + * Owns: + * - One `nisps.wasm` instance. + * - One MLP handle. + * - A JS-side `Dataset` (mirrors the C++ ring buffer; see dataset.ts). + * - Pre-allocated heap buffers for inputs/outputs/weights/etc. + * - A lazy `WasmTrainer` worker for off-thread async training. + * + * Threading model (architecture.md §6.4): + * - Inference + sync training run on this main-thread instance. + * - `trainAsync()` spawns/uses a worker which holds a SECOND wasm + * instance; weights round-trip through `getWeights()`/`setWeights()`. + * + * Side effects: every mutation that should be visible to the UI calls into + * `mlStore`. The store is the single source of truth for Solid components. + * + * Concept compatibility: this class re-implements the legacy WasmIML + * surface area documented in `recon/04-playground.md §6` so the existing + * Playwright debug-probe tests can keep passing once the new probe is + * wired up. + */ + +import { produce } from 'solid-js/store'; + +import { mlStore } from '../stores/ml-store'; +import { coreBus } from '../stores/bus'; +import { Dataset } from './dataset'; +import type { + LayerStats, + MLArchitecture, + NispsModule, + NispsModuleFactory, +} from './types'; +import { createTrainer, type WasmTrainer } from './wasm-worker'; + +const NISPS_JS_URL = '/nisps.js'; +const NISPS_WASM_URL = '/nisps.wasm'; + +/** Default architecture matches `nisps/wasm/bindings.cpp` instantiation. */ +const DEFAULT_INPUT_SIZE = 2; +const DEFAULT_OUTPUT_SIZE = 126; + +let cachedFactory: NispsModuleFactory | null = null; + +/** + * Load the Emscripten glue once, cache the factory. + * + * The glue is served from `playground/public/nisps.js` (committed). We + * dynamically import it so the WASM only loads when the ML system is first + * used — `/dev/primitives` doesn't pay for it. + */ +async function getFactory(): Promise { + if (cachedFactory) return cachedFactory; + // Vite serves `/nisps.js` as a normal asset; we use a dynamic-eval import + // to avoid Vite trying to resolve it at build time. + const url = new URL(NISPS_JS_URL, window.location.origin).toString(); + const mod = await import(/* @vite-ignore */ url); + // Emscripten MODULARIZE=1 default export key is `default`. + // tslint:disable-next-line:no-any + const factory = (mod as { default?: NispsModuleFactory; createNispsModule?: NispsModuleFactory }) + .default ?? (mod as { createNispsModule?: NispsModuleFactory }).createNispsModule; + if (!factory) throw new Error('[wasm-iml] nisps.js does not export a module factory'); + cachedFactory = factory; + return factory; +} + +/** Aligned float-array allocation helper. Returns ptr + a view. */ +class HeapBuffer { + readonly ptr: number; + readonly view: Float32Array; + constructor(private mod: NispsModule, public readonly count: number) { + this.ptr = mod._malloc(count * 4); + if (!this.ptr) throw new Error(`malloc(${count * 4}) failed`); + this.view = new Float32Array(mod.HEAPF32.buffer, this.ptr, count); + } + /** After memory growth, refresh the view onto the new ArrayBuffer. */ + rebind(): void { + // Re-create the view with the (possibly new) underlying buffer. + Object.defineProperty(this, 'view', { + value: new Float32Array(this.mod.HEAPF32.buffer, this.ptr, this.count), + writable: false, + }); + } + free(): void { + this.mod._free(this.ptr); + } +} + +class HeapU8 { + readonly ptr: number; + readonly view: Uint8Array; + constructor(private mod: NispsModule, public readonly count: number) { + this.ptr = mod._malloc(count); + if (!this.ptr) throw new Error(`malloc(${count}) failed`); + this.view = new Uint8Array(mod.HEAPU8.buffer, this.ptr, count); + } + rebind(): void { + Object.defineProperty(this, 'view', { + value: new Uint8Array(this.mod.HEAPU8.buffer, this.ptr, this.count), + writable: false, + }); + } + free(): void { + this.mod._free(this.ptr); + } +} + +export interface WasmIMLOptions { + inputSize?: number; + outputSize?: number; + hiddenLayers?: ReadonlyArray; + seed?: number; + /** localStorage key the loaded weights/dataset will be persisted under. */ + storageKey?: string; + maxExamples?: number; +} + +export class WasmIML { + // WASM binding state. + private module!: NispsModule; + private mlHandle = 0; + private weightCount_ = 0; + + // Architecture descriptor (resolved post-init from the WASM build). + private arch_: MLArchitecture = { + inputSize: DEFAULT_INPUT_SIZE, + hidden: [10, 14, 18], + outputSize: DEFAULT_OUTPUT_SIZE, + numLayers: 4, + }; + + // Pre-allocated heap buffers. + private featuresBuf!: HeapBuffer; + private labelsBuf!: HeapBuffer; + private weightsBuf!: HeapBuffer; + private statsBuf!: HeapBuffer; + private batchInBuf!: HeapBuffer; + private batchOutBuf!: HeapBuffer; + private pinMaskBuf!: HeapU8; + private describeBuf!: HeapBuffer; // 6 ints, reused as 6 floats on the heap is wrong; + // We use HEAP32 directly via a tiny scratch malloc: + private describePtr = 0; + + // JS-side state. + readonly dataset: Dataset; + private lastLoss_: number | null = null; + private trainer: WasmTrainer | null = null; + private storageKey: string; + private saveTimer: number | null = null; + private destroyed = false; + + static MAX_BATCH = 4096; + + private constructor(opts: WasmIMLOptions) { + this.dataset = new Dataset(opts.maxExamples ?? 100); + this.storageKey = opts.storageKey ?? 'nisps:wasm-iml'; + } + + /** + * Async factory. Loads the wasm, creates the MLP handle, allocates heap + * buffers, and rehydrates persisted state. + */ + static async create(opts: WasmIMLOptions = {}): Promise { + const inst = new WasmIML(opts); + await inst.init_(opts); + return inst; + } + + private async init_(opts: WasmIMLOptions): Promise { + const factory = await getFactory(); + this.module = await factory({ + // Vite serves /nisps.wasm at the root; the default locateFile would + // resolve relative to nisps.js (also at root) so this is the same + // result, but explicit is better. + locateFile: (path: string) => { + if (path.endsWith('.wasm')) return new URL(NISPS_WASM_URL, window.location.origin).toString(); + return path; + }, + }); + + // Resolve architecture via the WASM module (compile-time fixed; we + // stash the values for callers that need them). + this.describePtr = this.module._malloc(6 * 4); + this.module._nisps_ml_describe(this.describePtr); + const dims = new Int32Array(this.module.HEAP32.buffer, this.describePtr, 6); + this.arch_ = { + inputSize: dims[0], + hidden: [dims[1], dims[2], dims[3]], + outputSize: dims[4], + numLayers: dims[5], + }; + + // Caller-supplied dimensions are accepted but ignored; warn if mismatch. + const wantedIn = opts.inputSize ?? this.arch_.inputSize; + const wantedOut = opts.outputSize ?? this.arch_.outputSize; + if (wantedIn !== this.arch_.inputSize || wantedOut !== this.arch_.outputSize) { + console.warn( + `[wasm-iml] requested ${wantedIn}->${wantedOut} but WASM build is fixed at ` + + `${this.arch_.inputSize}->${this.arch_.outputSize}; extras are ignored.`, + ); + } + + // Create the MLP. We pass dummy hidden ptr/count — the binding ignores them. + const seed = (opts.seed ?? (Date.now() >>> 0)) >>> 0; + this.mlHandle = this.module._nisps_ml_create( + this.arch_.inputSize, + this.arch_.outputSize, + 0, // hidden ptr (unused) + 0, // n_hidden (unused) + seed, + ); + if (!this.mlHandle) throw new Error('[wasm-iml] nisps_ml_create returned null'); + + this.weightCount_ = this.module._nisps_ml_weight_count(this.mlHandle); + + // Heap buffers (created after we know the architecture). + this.featuresBuf = new HeapBuffer(this.module, this.arch_.inputSize); + this.labelsBuf = new HeapBuffer(this.module, this.arch_.outputSize); + this.weightsBuf = new HeapBuffer(this.module, this.weightCount_); + this.statsBuf = new HeapBuffer(this.module, this.arch_.numLayers * 4); + this.batchInBuf = new HeapBuffer(this.module, WasmIML.MAX_BATCH * this.arch_.inputSize); + this.batchOutBuf = new HeapBuffer(this.module, WasmIML.MAX_BATCH * this.arch_.outputSize); + this.pinMaskBuf = new HeapU8(this.module, this.arch_.outputSize); + + // Push initial state to the store. + mlStore.__setState(produce((s) => { + s.inputSize = this.arch_.inputSize; + s.outputSize = this.arch_.outputSize; + s.exampleCount = 0; + s.lastLoss = null; + s.lossHistory = []; + s.training = false; + s.ready = true; + })); + mlStore.__setOutputs(new Float32Array(this.arch_.outputSize)); + this.publishWeights_(); + + // Load persisted state if present (best-effort). + this.tryLoadFromStorage_(); + } + + // ------------------------------------------------------------------- + // Lifecycle + // ------------------------------------------------------------------- + + dispose(): void { + if (this.destroyed) return; + this.destroyed = true; + if (this.saveTimer !== null) { + clearTimeout(this.saveTimer); + this.saveTimer = null; + } + if (this.trainer) { + this.trainer.dispose(); + this.trainer = null; + } + if (this.module && this.mlHandle) { + this.module._nisps_ml_destroy(this.mlHandle); + this.mlHandle = 0; + } + if (this.featuresBuf) this.featuresBuf.free(); + if (this.labelsBuf) this.labelsBuf.free(); + if (this.weightsBuf) this.weightsBuf.free(); + if (this.statsBuf) this.statsBuf.free(); + if (this.batchInBuf) this.batchInBuf.free(); + if (this.batchOutBuf) this.batchOutBuf.free(); + if (this.pinMaskBuf) this.pinMaskBuf.free(); + if (this.describePtr) this.module._free(this.describePtr); + mlStore.__setState(produce((s) => { + s.ready = false; + })); + } + + get architecture(): MLArchitecture { + return this.arch_; + } + get weightCount(): number { + return this.weightCount_; + } + get exampleCount(): number { + return this.dataset.size; + } + get lastLoss(): number | null { + return this.lastLoss_; + } + + // ------------------------------------------------------------------- + // Inference + // ------------------------------------------------------------------- + + setInput(idx: number, value: number): void { + this.module._nisps_ml_set_input(this.mlHandle, idx, value); + } + + process(): Float32Array { + this.module._nisps_ml_process(this.mlHandle); + const ptr = this.module._nisps_ml_outputs(this.mlHandle); + // Copy out so the caller can hold onto it across memory growth. + const view = new Float32Array(this.module.HEAPF32.buffer, ptr, this.arch_.outputSize); + const out = new Float32Array(view); // copy + mlStore.__setOutputs(out); + return out; + } + + /** Convenience: setInput(0,x); setInput(1,y); process(). */ + inferXY(x: number, y: number): Float32Array { + this.setInput(0, x); + this.setInput(1, y); + return this.process(); + } + + /** + * Batch inference. `points` is an array of [x,y] tuples (or any vector + * length <= inputSize; trailing entries zero-padded). Returns a flat + * Float32Array of length n * outputSize. + * + * Larger requests than `MAX_BATCH` are chunked transparently. + */ + inferBatch(points: ReadonlyArray>): Float32Array { + const n = points.length; + const inSz = this.arch_.inputSize; + const outSz = this.arch_.outputSize; + const result = new Float32Array(n * outSz); + + let written = 0; + for (let offset = 0; offset < n; offset += WasmIML.MAX_BATCH) { + const chunk = Math.min(WasmIML.MAX_BATCH, n - offset); + // Pack into batchInBuf. + for (let i = 0; i < chunk; ++i) { + const src = points[offset + i]; + const base = i * inSz; + for (let j = 0; j < inSz; ++j) this.batchInBuf.view[base + j] = src[j] ?? 0; + } + this.module._nisps_ml_infer_batch( + this.mlHandle, + this.batchInBuf.ptr, + chunk, + this.batchOutBuf.ptr, + ); + // Copy out into result. + const slice = this.batchOutBuf.view.subarray(0, chunk * outSz); + result.set(slice, written); + written += chunk * outSz; + } + return result; + } + + // ------------------------------------------------------------------- + // Training + // ------------------------------------------------------------------- + + /** + * Add a feature/label pair to BOTH the JS dataset and the WASM ring + * buffer. The two stay in sync because every train() call pushes the + * full JS dataset back into WASM (in case of weight recompute, undo + * restore, etc.). For the sake of correctness, we re-sync on each add + * too — cheap relative to training. + */ + addExample(features: ReadonlyArray, labels: ReadonlyArray): boolean { + const ok = this.dataset.add(features, labels); + if (!ok) return false; + this.copyExampleToWasm_(features, labels); + mlStore.__setState(produce((s) => { + s.exampleCount = this.dataset.size; + })); + coreBus.emit('ml.example_added', { count: this.dataset.size }); + this.scheduleSave_(); + return true; + } + + private copyExampleToWasm_(features: ReadonlyArray, labels: ReadonlyArray): void { + const fv = this.featuresBuf.view; + const lv = this.labelsBuf.view; + const inSz = this.arch_.inputSize; + const outSz = this.arch_.outputSize; + for (let i = 0; i < inSz; ++i) fv[i] = features[i] ?? 0; + for (let i = 0; i < outSz; ++i) lv[i] = labels[i] ?? 0; + this.module._nisps_ml_add_example(this.mlHandle, this.featuresBuf.ptr, this.labelsBuf.ptr); + } + + /** + * Synchronous training. Returns the final loss (also stored in + * `lastLoss`). Updates `mlStore.lastLoss` and emits `ml.trained`. + * + * Caller can pass per-sample weights; if omitted the WASM side uses + * uniform 1/n weighting. + */ + train(lr = 1.0, maxIter = 1000, minErr = 0.001, sampleWeights?: Float32Array): number { + if (this.dataset.isEmpty()) { + this.lastLoss_ = 0; + mlStore.__setState(produce((s) => { s.lastLoss = 0; })); + return 0; + } + + let weightsPtr = 0; + let weightsHandle: HeapBuffer | null = null; + if (sampleWeights && sampleWeights.length === this.dataset.size) { + weightsHandle = new HeapBuffer(this.module, sampleWeights.length); + weightsHandle.view.set(sampleWeights); + weightsPtr = weightsHandle.ptr; + } + + mlStore.__setState(produce((s) => { s.training = true; })); + let loss = 0; + try { + loss = this.module._nisps_ml_train(this.mlHandle, lr, maxIter, minErr, weightsPtr); + } finally { + if (weightsHandle) weightsHandle.free(); + mlStore.__setState(produce((s) => { s.training = false; })); + } + + this.lastLoss_ = loss; + mlStore.__setState(produce((s) => { + s.lastLoss = loss; + // The C++ MLP stores per-iter history but we don't currently expose + // it via the WASM bindings. Stream 9 may add nisps_ml_loss_history. + s.lossHistory = [loss]; + })); + this.publishWeights_(); + coreBus.emit('ml.trained', { loss }); + this.scheduleSave_(); + return loss; + } + + /** + * Async training via worker. The worker holds a SECOND wasm instance, + * receives current weights + dataset, runs SGD, and returns updated + * weights. Main-thread weights are then `setWeights()`-restored. + */ + async trainAsync(lr = 1.0, maxIter = 1000, minErr = 0.001, sampleWeights?: Float32Array): Promise { + if (this.dataset.isEmpty()) { + this.lastLoss_ = 0; + return 0; + } + if (!this.trainer) this.trainer = await createTrainer(); + + const weights = this.getWeights(); + const features = new Float32Array(this.dataset.featuresFlat()); + const labels = new Float32Array(this.dataset.labelsFlat()); + const sw = sampleWeights ? new Float32Array(sampleWeights) : new Float32Array(0); + + mlStore.__setState(produce((s) => { s.training = true; })); + try { + const result = await this.trainer.train({ + weights, + features, + labels, + sampleWeights: sw, + lr, + maxIter, + minErr, + inputSize: this.arch_.inputSize, + outputSize: this.arch_.outputSize, + }); + this.setWeights(result.weights); + this.lastLoss_ = result.loss; + mlStore.__setState(produce((s) => { + s.lastLoss = result.loss; + s.lossHistory = Array.from(result.lossHistory); + })); + coreBus.emit('ml.trained', { loss: result.loss }); + this.scheduleSave_(); + return result.loss; + } finally { + mlStore.__setState(produce((s) => { s.training = false; })); + } + } + + /** Non-destructive loss query. */ + evalLoss(): number { + return this.module._nisps_ml_eval_loss(this.mlHandle); + } + + clearExamples(): void { + this.dataset.clear(); + this.module._nisps_ml_clear_examples(this.mlHandle); + mlStore.__setState(produce((s) => { s.exampleCount = 0; })); + coreBus.emit('ml.examples_cleared', undefined); + this.scheduleSave_(); + } + + // ------------------------------------------------------------------- + // RL ops + // ------------------------------------------------------------------- + + randomiseWeights(spread = 0.6): void { + this.module._nisps_ml_draw_weights(this.mlHandle, spread); + this.publishWeights_(); + coreBus.emit('ml.delta_update', { reason: 'randomize' }); + this.scheduleSave_(); + } + + moveWeights(speed: number, spread: number, pinMask?: Uint8Array): void { + let maskPtr = 0; + if (pinMask) { + const sz = Math.min(pinMask.length, this.arch_.outputSize); + for (let i = 0; i < sz; ++i) this.pinMaskBuf.view[i] = pinMask[i]; + for (let i = sz; i < this.arch_.outputSize; ++i) this.pinMaskBuf.view[i] = 0; + maskPtr = this.pinMaskBuf.ptr; + } + this.module._nisps_ml_move_weights(this.mlHandle, speed, spread, maskPtr); + this.publishWeights_(); + // The caller (RL handler) decides whether this is a thumbs-up/down; + // we emit a generic delta_update. + coreBus.emit('ml.delta_update', { reason: 'thumbs_down' }); + } + + // ------------------------------------------------------------------- + // Weights I/O + // ------------------------------------------------------------------- + + getWeights(): Float32Array { + this.module._nisps_ml_get_weights(this.mlHandle, this.weightsBuf.ptr); + // Copy out so caller can mutate freely. + return new Float32Array(this.weightsBuf.view); + } + + setWeights(w: Float32Array | Uint8Array): void { + if (w.length < this.weightCount_) { + throw new Error(`setWeights: expected ${this.weightCount_} floats, got ${w.length}`); + } + this.weightsBuf.view.set(w as Float32Array, 0); + this.module._nisps_ml_set_weights(this.mlHandle, this.weightsBuf.ptr); + this.publishWeights_(); + } + + /** Per-layer weight stats. Returns one record per layer. */ + getLayerStats(): LayerStats[] { + this.module._nisps_ml_get_layer_stats(this.mlHandle, this.statsBuf.ptr); + const out: LayerStats[] = []; + for (let i = 0; i < this.arch_.numLayers; ++i) { + const base = i * 4; + out.push({ + meanAbs: this.statsBuf.view[base], + maxAbs: this.statsBuf.view[base + 1], + deadFrac: this.statsBuf.view[base + 2], + saturatingFrac: this.statsBuf.view[base + 3], + }); + } + return out; + } + + /** Flat layer-stats Float32Array (numLayers * 4). For probe API. */ + getLayerStatsFlat(): Float32Array { + this.module._nisps_ml_get_layer_stats(this.mlHandle, this.statsBuf.ptr); + return new Float32Array(this.statsBuf.view); + } + + // ------------------------------------------------------------------- + // Misc + // ------------------------------------------------------------------- + + reset(): void { + this.module._nisps_ml_reset(this.mlHandle); + this.dataset.clear(); + this.lastLoss_ = null; + mlStore.__setState(produce((s) => { + s.exampleCount = 0; + s.lastLoss = null; + s.lossHistory = []; + })); + this.publishWeights_(); + coreBus.emit('ml.examples_cleared', undefined); + this.scheduleSave_(); + } + + // ------------------------------------------------------------------- + // Persistence + // ------------------------------------------------------------------- + + private scheduleSave_(): void { + if (this.saveTimer !== null) clearTimeout(this.saveTimer); + this.saveTimer = window.setTimeout(() => this.saveNow(), 500); + } + + saveNow(): void { + if (this.destroyed) return; + if (this.saveTimer !== null) { + clearTimeout(this.saveTimer); + this.saveTimer = null; + } + try { + const weights = this.getWeights(); + const payload = { + v: 1, + arch: this.arch_, + weights: Array.from(weights), + features: Array.from(this.dataset.featuresFlat()), + labels: Array.from(this.dataset.labelsFlat()), + size: this.dataset.size, + lastLoss: this.lastLoss_, + }; + localStorage.setItem(this.storageKey, JSON.stringify(payload)); + } catch (err) { + // localStorage might be full or unavailable; not fatal. + console.warn('[wasm-iml] saveNow failed:', err); + } + } + + private tryLoadFromStorage_(): void { + try { + const raw = localStorage.getItem(this.storageKey); + if (!raw) return; + const payload = JSON.parse(raw) as { + v: number; + weights: number[]; + features: number[]; + labels: number[]; + size: number; + lastLoss: number | null; + }; + if (payload.v !== 1) return; + // Restore dataset (rebuild via add()). + const inSz = this.arch_.inputSize; + const outSz = this.arch_.outputSize; + if (payload.size > 0 && payload.features.length === payload.size * inSz && + payload.labels.length === payload.size * outSz) { + for (let i = 0; i < payload.size; ++i) { + const f = payload.features.slice(i * inSz, (i + 1) * inSz); + const l = payload.labels.slice(i * outSz, (i + 1) * outSz); + this.dataset.add(f, l); + // Also push to the WASM ring buffer. + this.copyExampleToWasm_(f, l); + } + } + // Restore weights. + if (payload.weights.length === this.weightCount_) { + this.setWeights(new Float32Array(payload.weights)); + } + this.lastLoss_ = payload.lastLoss; + mlStore.__setState(produce((s) => { + s.exampleCount = this.dataset.size; + s.lastLoss = this.lastLoss_; + })); + } catch (err) { + console.warn('[wasm-iml] tryLoadFromStorage failed:', err); + } + } + + // ------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------- + + private publishWeights_(): void { + const w = this.getWeights(); + mlStore.__setWeights(w); + } +} diff --git a/playground/src/ml/wasm-worker.ts b/playground/src/ml/wasm-worker.ts new file mode 100644 index 0000000..5d3db63 --- /dev/null +++ b/playground/src/ml/wasm-worker.ts @@ -0,0 +1,334 @@ +/** + * Disposable Web Worker that runs SGD off the main thread. + * + * The worker holds its own `nisps.wasm` instance (architecture.md §6.4). + * The main thread sends: + * - current weights (so the worker is in the same state as the UI), + * - dataset (features + labels), + * - SGD hyperparameters, + * and receives updated weights + final loss. + * + * This module exposes: + * - `createTrainer()` - factory that spawns the worker, loads its WASM, + * and returns a `WasmTrainer` handle. + * - The worker entry-point itself (when this file runs in a Worker). + * + * The worker is implemented inline so a single TS file becomes both the + * main-thread API and the worker bundle. Vite's `new Worker(new URL(..., + * import.meta.url))` pattern packs it correctly. + * + * Lifecycle: each `WasmTrainer` is disposable via `.dispose()` which + * terminates the worker. Tests should always dispose to avoid leaks. + */ + +import type { NispsModule, NispsModuleFactory, WorkerRequest, WorkerResponse } from './types'; + +// --------------------------------------------------------------------------- +// Main-thread side +// --------------------------------------------------------------------------- + +export interface TrainArgs { + weights: Float32Array; + features: Float32Array; + labels: Float32Array; + /** Optional; pass empty for uniform weighting. */ + sampleWeights: Float32Array; + lr: number; + maxIter: number; + minErr: number; + inputSize: number; + outputSize: number; +} + +export interface TrainResult { + loss: number; + weights: Float32Array; + lossHistory: Float32Array; +} + +export class WasmTrainer { + private worker: Worker; + private nextId = 1; + private pending = new Map void; reject: (e: unknown) => void }>(); + private disposed = false; + + static async create(): Promise { + const trainer = new WasmTrainer(); + await trainer.init_(); + return trainer; + } + + private constructor() { + this.worker = new Worker(new URL('./wasm-worker.ts', import.meta.url), { type: 'module' }); + this.worker.onmessage = (ev) => this.onMessage_(ev.data as WorkerResponse); + this.worker.onerror = (ev) => { + // Fail any pending requests. + for (const { reject } of this.pending.values()) reject(ev.message ?? 'worker error'); + this.pending.clear(); + }; + } + + private init_(): Promise { + return new Promise((resolve, reject) => { + const handler = (ev: MessageEvent) => { + const msg = ev.data as WorkerResponse; + if (msg.kind === 'ready') { + this.worker.removeEventListener('message', handler); + resolve(); + } else if (msg.kind === 'error') { + this.worker.removeEventListener('message', handler); + reject(new Error(msg.message)); + } + }; + this.worker.addEventListener('message', handler); + const seed = (Date.now() ^ Math.floor(Math.random() * 0xffffffff)) >>> 0; + this.worker.postMessage({ kind: 'init', seed } satisfies WorkerRequest); + }); + } + + train(args: TrainArgs): Promise { + if (this.disposed) return Promise.reject(new Error('WasmTrainer disposed')); + const requestId = this.nextId++; + return new Promise((resolve, reject) => { + this.pending.set(requestId, { resolve, reject }); + const msg: WorkerRequest = { + kind: 'train', + requestId, + weights: args.weights, + features: args.features, + labels: args.labels, + sampleWeights: args.sampleWeights, + lr: args.lr, + maxIter: args.maxIter, + minErr: args.minErr, + inputSize: args.inputSize, + outputSize: args.outputSize, + }; + // Transfer all the typed-array buffers we no longer need on the + // main thread; faster than copying. Caller has already cloned. + this.worker.postMessage(msg, [ + args.weights.buffer, + args.features.buffer, + args.labels.buffer, + args.sampleWeights.buffer, + ]); + }); + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + try { + this.worker.postMessage({ kind: 'dispose' } satisfies WorkerRequest); + } catch { + /* ignore */ + } + this.worker.terminate(); + for (const { reject } of this.pending.values()) reject(new Error('disposed')); + this.pending.clear(); + } + + private onMessage_(msg: WorkerResponse): void { + if (msg.kind === 'result') { + const p = this.pending.get(msg.requestId); + if (p) { + this.pending.delete(msg.requestId); + p.resolve({ loss: msg.loss, weights: msg.weights, lossHistory: msg.lossHistory }); + } + } else if (msg.kind === 'error') { + const p = this.pending.get(msg.requestId); + if (p) { + this.pending.delete(msg.requestId); + p.reject(new Error(msg.message)); + } + } + // 'ready' handled in init_(). + } +} + +export function createTrainer(): Promise { + return WasmTrainer.create(); +} + +// --------------------------------------------------------------------------- +// Worker-thread side +// --------------------------------------------------------------------------- +// +// When this module is loaded in a Worker, `self` is `WorkerGlobalScope` and +// `window` is undefined. We use that as the dispatch. +// +// We import the wasm via a same-origin fetch (no DOM available so we can't +// use a regular import URL — but Vite's bundler treats `new Worker(...)` +// specially and `?url` imports work for assets). + +// Inside a Worker, `self` is a `DedicatedWorkerGlobalScope`. To keep TS +// happy in both build contexts we use a structural cast. +declare const self: { + postMessage: (msg: unknown, transfer?: Transferable[]) => void; + addEventListener: (event: string, handler: (ev: MessageEvent) => void) => void; + location: { origin: string }; + importScripts?: unknown; +}; + +const isWorker = + typeof window === 'undefined' && + typeof self !== 'undefined' && + typeof (self as { importScripts?: unknown }).importScripts !== 'undefined'; + +if (isWorker) { + // Module-level state in worker scope. + let mod: NispsModule | null = null; + let mlHandle = 0; + let weightCount = 0; + + // Heap buffers (allocated on first train). + let weightsPtr = 0; + let weightsViewLen = 0; + let featuresPtr = 0; + let featuresLen = 0; + let labelsPtr = 0; + let labelsLen = 0; + let sampleWeightsPtr = 0; + let sampleWeightsLen = 0; + + async function loadModule(seed: number): Promise { + // Same-origin fetch to /nisps.js. The worker is served by the dev + // server with COOP/COEP set, so this works. + const factoryMod = await import(/* @vite-ignore */ new URL('/nisps.js', self.location.origin).toString()); + const factory: NispsModuleFactory = + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (factoryMod as any).default ?? (factoryMod as any).createNispsModule; + mod = await factory({ + locateFile: (path: string) => { + if (path.endsWith('.wasm')) return new URL('/nisps.wasm', self.location.origin).toString(); + return path; + }, + }); + mlHandle = mod._nisps_ml_create(0, 0, 0, 0, seed >>> 0); + weightCount = mod._nisps_ml_weight_count(mlHandle); + } + + function ensureBuffers(features: Float32Array, labels: Float32Array, sampleWeights: Float32Array, weights: Float32Array): void { + if (!mod) throw new Error('worker module not loaded'); + + if (weightsViewLen !== weightCount) { + if (weightsPtr) mod._free(weightsPtr); + weightsPtr = mod._malloc(weightCount * 4); + weightsViewLen = weightCount; + } + if (features.length !== featuresLen) { + if (featuresPtr) mod._free(featuresPtr); + featuresPtr = mod._malloc(features.length * 4); + featuresLen = features.length; + } + if (labels.length !== labelsLen) { + if (labelsPtr) mod._free(labelsPtr); + labelsPtr = mod._malloc(labels.length * 4); + labelsLen = labels.length; + } + if (sampleWeights.length !== sampleWeightsLen) { + if (sampleWeightsPtr) mod._free(sampleWeightsPtr); + sampleWeightsPtr = sampleWeights.length > 0 ? mod._malloc(sampleWeights.length * 4) : 0; + sampleWeightsLen = sampleWeights.length; + } + + new Float32Array(mod.HEAPF32.buffer, weightsPtr, weightCount).set(weights); + new Float32Array(mod.HEAPF32.buffer, featuresPtr, features.length).set(features); + new Float32Array(mod.HEAPF32.buffer, labelsPtr, labels.length).set(labels); + if (sampleWeightsPtr) { + new Float32Array(mod.HEAPF32.buffer, sampleWeightsPtr, sampleWeights.length).set(sampleWeights); + } + } + + function trainOnce(req: Extract): WorkerResponse { + if (!mod) { + return { kind: 'error', requestId: req.requestId, message: 'worker not initialised' }; + } + try { + ensureBuffers(req.features, req.labels, req.sampleWeights, req.weights); + // Push current weights into our MLP. + mod._nisps_ml_set_weights(mlHandle, weightsPtr); + + // Seed the example ring buffer. We must clear first because past + // train calls may have left examples there. + mod._nisps_ml_clear_examples(mlHandle); + const inSz = req.inputSize; + const outSz = req.outputSize; + const n = req.features.length / inSz; + // Allocate small per-example scratch (re-used across iterations of + // this loop). + // We use stack-equivalents by allocating once, then shifting pointers. + for (let i = 0; i < n; ++i) { + const fPtr = featuresPtr + i * inSz * 4; + const lPtr = labelsPtr + i * outSz * 4; + mod._nisps_ml_add_example(mlHandle, fPtr, lPtr); + } + + // Run training. + const swPtr = req.sampleWeights.length > 0 ? sampleWeightsPtr : 0; + const loss = mod._nisps_ml_train(mlHandle, req.lr, req.maxIter, req.minErr, swPtr); + + // Read out final weights. + mod._nisps_ml_get_weights(mlHandle, weightsPtr); + const view = new Float32Array(mod.HEAPF32.buffer, weightsPtr, weightCount); + const outWeights = new Float32Array(view); // copy + + // Loss history not yet plumbed via WASM; emit just final loss. + const lossHistory = new Float32Array([loss]); + + return { + kind: 'result', + requestId: req.requestId, + loss, + weights: outWeights, + lossHistory, + }; + } catch (err) { + return { + kind: 'error', + requestId: req.requestId, + message: err instanceof Error ? err.message : String(err), + }; + } + } + + function disposeModule(): void { + if (!mod) return; + if (mlHandle) { + mod._nisps_ml_destroy(mlHandle); + mlHandle = 0; + } + if (weightsPtr) { mod._free(weightsPtr); weightsPtr = 0; } + if (featuresPtr) { mod._free(featuresPtr); featuresPtr = 0; } + if (labelsPtr) { mod._free(labelsPtr); labelsPtr = 0; } + if (sampleWeightsPtr) { mod._free(sampleWeightsPtr); sampleWeightsPtr = 0; } + mod = null; + } + + self.addEventListener('message', async (ev: MessageEvent) => { + const req = ev.data; + if (req.kind === 'init') { + try { + await loadModule(req.seed); + self.postMessage({ kind: 'ready' } satisfies WorkerResponse); + } catch (err) { + self.postMessage({ + kind: 'error', + requestId: 0, + message: err instanceof Error ? err.message : String(err), + } satisfies WorkerResponse); + } + } else if (req.kind === 'train') { + const res = trainOnce(req); + // Transfer weights back to main thread to avoid copy. + if (res.kind === 'result') { + self.postMessage(res, [res.weights.buffer, res.lossHistory.buffer]); + } else { + self.postMessage(res); + } + } else if (req.kind === 'dispose') { + disposeModule(); + // Worker terminates from main thread side via .terminate(). + } + }); +} diff --git a/playground/src/stores/ml-store.ts b/playground/src/stores/ml-store.ts index fc54f85..01c6638 100644 --- a/playground/src/stores/ml-store.ts +++ b/playground/src/stores/ml-store.ts @@ -1,19 +1,27 @@ /** - * ML store — placeholder shape for the ML engine state. + * ML store — Solid-side state for the WASM-backed ML engine. * - * Stream 7 wires WASM under this. For now the methods that mutate the engine - * throw `not implemented`. The shape of the store and the signal types are - * final — modes and primitives can read them. + * Stream 7 wires this to a `WasmIML` instance. The store still owns the + * Solid-reactive state (sizes, loss, training flag, ready flag) and a + * Float32Array signal for outputs. The WasmIML class drives the values + * via `__set*` setters — kept exported so the WasmIML implementation can + * write through without going through reactive accessors. * - * Why a Solid store + a separate Float32Array signal: - * - `createStore` is great for object-like state with fine reactivity. - * - Float32Array outputs are large and frequently updated; `createSignal` - * with explicit reference replacement is cheaper. + * Why two layers (store + class)? + * - The class encapsulates the WASM heap, buffers, and worker. + * - The store is the consumer-facing surface for Solid components and + * the debug probe. Components shouldn't reach into the WASM directly. + * + * Singleton model: there is exactly one `WasmIML` per browser tab, owned + * by the store. `initialize()` creates it; subsequent calls return the + * existing one. This matches the legacy playground. */ import { createSignal, type Accessor } from 'solid-js'; import { createStore, produce } from 'solid-js/store'; import { coreBus } from './bus'; +import type { WasmIML } from '../ml/wasm-iml'; +import type { LayerStats } from '../ml/types'; export interface MLStoreState { exampleCount: number; @@ -32,11 +40,6 @@ export interface MLStoreState { } const EMPTY_OUTPUTS = new Float32Array(0); -const NOT_IMPLEMENTED = (op: string): never => { - throw new Error( - `[ml-store] ${op} not implemented in stream-8 scaffold; awaits stream 7 (WASM bindings)` - ); -}; const [state, setState] = createStore({ exampleCount: 0, @@ -56,61 +59,163 @@ const [weights, setWeights] = createSignal(EMPTY_OUTPUTS, { equals: false, }); +// Singleton WasmIML, lazily created by initialize(). +let imlInstance: WasmIML | null = null; +let initPromise: Promise | null = null; + +function requireIML(op: string): WasmIML { + if (!imlInstance) { + throw new Error( + `[ml-store] ${op} called before initialize() — call mlStore.initialize() first`, + ); + } + return imlInstance; +} + export const mlStore = { // ---- read ---- state, outputs: outputs as Accessor, weights: weights as Accessor, - // ---- internal setters (used by future WASM wiring; exposed for stub - // wiring during this stream so primitive demos can drive values) ---- + /** Direct access to the WasmIML instance (null until initialize() resolves). */ + get iml(): WasmIML | null { + return imlInstance; + }, + + // ---- internal setters (used by WasmIML to push state into the store) ---- __setOutputs: setOutputs, __setState: setState, __setWeights: setWeights, - // ---- ML lifecycle (stubbed) ---- - initialize(_inputSize: number, _outputSize: number): Promise { - return NOT_IMPLEMENTED('initialize'); + // ---- ML lifecycle ---- + + /** + * Load the WASM and create the singleton WasmIML. Idempotent: returns + * the cached instance on subsequent calls. + * + * `inputSize` / `outputSize` are accepted for forward compatibility but + * ignored if they don't match the WASM build's compile-time architecture. + */ + async initialize(inputSize?: number, outputSize?: number): Promise { + if (imlInstance) return imlInstance; + if (initPromise) return initPromise; + // Lazy-import to keep the WASM glue out of the bundle until needed. + initPromise = (async () => { + const { WasmIML: WasmIMLCtor } = await import('../ml/wasm-iml'); + const inst = await WasmIMLCtor.create({ + inputSize, + outputSize, + }); + imlInstance = inst; + return inst; + })(); + return initPromise; }, - setInput(_idx: number, _value: number): void { - NOT_IMPLEMENTED('setInput'); + + setInput(idx: number, value: number): void { + requireIML('setInput').setInput(idx, value); }, + process(): void { - NOT_IMPLEMENTED('process'); + requireIML('process').process(); }, - addExample(_features: ReadonlyArray, _labels: ReadonlyArray): void { - NOT_IMPLEMENTED('addExample'); + + inferXY(x: number, y: number): Float32Array { + return requireIML('inferXY').inferXY(x, y); }, - train(_lr?: number, _maxIter?: number): number { - return NOT_IMPLEMENTED('train'); + + addExample(features: ReadonlyArray, labels: ReadonlyArray): boolean { + return requireIML('addExample').addExample(features, labels); }, - trainAsync(_lr?: number, _maxIter?: number): Promise { - return NOT_IMPLEMENTED('trainAsync'); + + train(lr?: number, maxIter?: number, minErr?: number, sampleWeights?: Float32Array): number { + return requireIML('train').train(lr, maxIter, minErr, sampleWeights); }, - drawWeights(_spread: number): void { - NOT_IMPLEMENTED('drawWeights'); + + trainAsync(lr?: number, maxIter?: number, minErr?: number, sampleWeights?: Float32Array): Promise { + return requireIML('trainAsync').trainAsync(lr, maxIter, minErr, sampleWeights); }, - moveWeights(_speed: number, _spread: number, _pinMask?: Uint8Array): void { - NOT_IMPLEMENTED('moveWeights'); + + drawWeights(spread: number): void { + requireIML('drawWeights').randomiseWeights(spread); }, + + moveWeights(speed: number, spread: number, pinMask?: Uint8Array): void { + requireIML('moveWeights').moveWeights(speed, spread, pinMask); + }, + evalLoss(): number | null { - return null; + if (!imlInstance) return null; + return imlInstance.evalLoss(); }, - inferBatch(_points: ReadonlyArray): Float32Array { - return NOT_IMPLEMENTED('inferBatch'); + + inferBatch(points: ReadonlyArray): Float32Array { + if (!imlInstance) { + // Until the WASM is up the probe gets a zero array of the expected + // total size. Matches the stub behaviour expected by tests. + return new Float32Array(points.length * state.outputSize); + } + return imlInstance.inferBatch(points); }, + getLayerStats(): Float32Array { - return EMPTY_OUTPUTS; + if (!imlInstance) return EMPTY_OUTPUTS; + return imlInstance.getLayerStatsFlat(); }, + + getLayerStatsRecords(): LayerStats[] { + if (!imlInstance) return []; + return imlInstance.getLayerStats(); + }, + + getWeights(): Float32Array { + if (!imlInstance) return EMPTY_OUTPUTS; + return imlInstance.getWeights(); + }, + + setWeights(w: Float32Array): void { + requireIML('setWeights').setWeights(w); + }, + reset(): void { - NOT_IMPLEMENTED('reset'); + requireIML('reset').reset(); }, + clearExamples(): void { + if (imlInstance) { + imlInstance.clearExamples(); + return; + } setState(produce((s) => { s.exampleCount = 0; })); coreBus.emit('ml.examples_cleared', undefined); }, + + saveNow(): void { + imlInstance?.saveNow(); + }, + + /** Disposes the singleton. Used by tests and on hot-reload. */ + __dispose(): void { + if (imlInstance) { + imlInstance.dispose(); + imlInstance = null; + } + initPromise = null; + setState({ + exampleCount: 0, + lastLoss: null, + lossHistory: [], + inputSize: 2, + outputSize: 126, + training: false, + ready: false, + }); + setOutputs(EMPTY_OUTPUTS); + setWeights(EMPTY_OUTPUTS); + }, }; export type MLStore = typeof mlStore; diff --git a/scripts/build-wasm.sh b/scripts/build-wasm.sh new file mode 100755 index 0000000..0087e91 --- /dev/null +++ b/scripts/build-wasm.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# scripts/build-wasm.sh — compile nisps/wasm/bindings.cpp via Emscripten and +# write the result to playground/public/. +# +# Requires emcc. Defaults to /usr/lib/emscripten/emcc; override via the EMCC +# env var. Sample invocation: +# +# EMCC=$(which emcc) scripts/build-wasm.sh +# +# Output: +# playground/public/nisps.js — Emscripten glue, MODULARIZE factory +# playground/public/nisps.wasm — the compiled module + +set -euo pipefail + +EMCC="${EMCC:-/usr/lib/emscripten/emcc}" +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +OUT="$ROOT/playground/public" +SRC="$ROOT/nisps/wasm/bindings.cpp" + +if [[ ! -x "$EMCC" && ! -f "$EMCC" ]]; then + echo "[build-wasm] emcc not found at $EMCC" >&2 + echo "[build-wasm] set EMCC=/path/to/emcc and retry." >&2 + exit 2 +fi + +mkdir -p "$OUT" + +# Exported C functions. Keep this list synchronised with the +# EMSCRIPTEN_KEEPALIVE annotations in bindings.cpp; the build will not +# fail if extras are listed but it WILL fail (or silently strip) if any +# function is missing. +EXPORTED_FUNCS='[ + "_malloc","_free", + "_nisps_ml_create","_nisps_ml_destroy","_nisps_ml_reset", + "_nisps_ml_set_input","_nisps_ml_process","_nisps_ml_outputs","_nisps_ml_infer_batch", + "_nisps_ml_add_example","_nisps_ml_train","_nisps_ml_eval_loss", + "_nisps_ml_clear_examples","_nisps_ml_example_count", + "_nisps_ml_weight_count","_nisps_ml_get_weights","_nisps_ml_set_weights", + "_nisps_ml_draw_weights","_nisps_ml_move_weights", + "_nisps_ml_get_layer_stats","_nisps_ml_describe", + "_nisps_engine_create","_nisps_engine_destroy", + "_nisps_engine_set_params","_nisps_engine_process_block" +]' + +# Runtime helpers we want exposed on the JS module. HEAP* views are needed by +# wasm-iml.ts/wasm-worker.ts/nisps-processor.ts to copy buffers in and out. +EXPORTED_RUNTIME='[ + "HEAP8","HEAP16","HEAP32","HEAPU8","HEAPU16","HEAPU32","HEAPF32","HEAPF64", + "ccall","cwrap" +]' + +set -x +"$EMCC" "$SRC" \ + -std=c++20 -O3 \ + -I "$ROOT/nisps" \ + -fno-exceptions \ + -fno-rtti \ + -s WASM=1 \ + -s MODULARIZE=1 \ + -s EXPORT_NAME=createNispsModule \ + -s ENVIRONMENT=web,worker \ + -s ALLOW_MEMORY_GROWTH=1 \ + -s INITIAL_MEMORY=8388608 \ + -s STACK_SIZE=1048576 \ + -s FILESYSTEM=0 \ + -s SINGLE_FILE=0 \ + -s ASSERTIONS=0 \ + -s EXPORTED_FUNCTIONS="$EXPORTED_FUNCS" \ + -s EXPORTED_RUNTIME_METHODS="$EXPORTED_RUNTIME" \ + -o "$OUT/nisps.js" +{ set +x; } 2>/dev/null + +echo "[build-wasm] wrote $OUT/nisps.js + $OUT/nisps.wasm" +ls -lh "$OUT/nisps.js" "$OUT/nisps.wasm"