From f26ec923e1e6692603940bef78d6b004e4527d3b Mon Sep 17 00:00:00 2001 From: w1n5t0n Date: Wed, 29 Apr 2026 16:36:29 +0300 Subject: [PATCH] feat(playground/wasm): WASM bridge between C++ core and SolidJS playground (meml-tgm) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stream 7 wires nisps/ml + nisps/engines into the playground via Emscripten. Highlights: - nisps/wasm/bindings.cpp: flat C API per architecture.md §6.2. Fixed-arch MLP<2, 10, 14, 18, 126>; engine string→type dispatch table with NoOp fallback. - scripts/build-wasm.sh: emcc invocation, MODULARIZE=1, exports listed explicitly; produces playground/public/nisps.{js,wasm}. - playground/src/ml/wasm-iml.ts: main-thread MLP host (sync inference, sync training, RL ops, weights I/O, layer stats, localStorage). - playground/src/ml/wasm-worker.ts: disposable Web Worker for off-thread async training, owns its own WASM instance. - playground/src/ml/dataset.ts: Float32Array-backed FIFO with sample-weight modes (uniform/global/local/combined). Port of legacy dataset.js. - playground/src/audio/engine-host.ts: AudioContext + AudioWorkletNode lifecycle, with start/stop/setEngine/setParams. - playground/src/audio/worklet/nisps-processor.ts: WASM-loading AudioWorkletProcessor that runs engine.process_block per 128-sample block. Loads its own WASM instance from main-thread-supplied bytes (no fetch in worklet). - playground/src/stores/ml-store.ts: wired stub methods to WasmIML singleton; lazy initialize(). - playground/src/debug/probe.ts: window.__nisps now calls real WasmIML via the store; lazy-init on first use. Verified: - bash scripts/build-wasm.sh succeeds (94 KB nisps.wasm) - bun run typecheck OK - bun run build OK (production bundle) - vite dev server serves /nisps.{js,wasm} with COOP/COEP Known limitation: WASM is fixed at one MLP shape. Multi-arch deferred — documented in nisps/wasm/README.md. --- nisps/wasm/README.md | 70 ++ nisps/wasm/bindings.cpp | 515 ++++++++++++++ playground/public/nisps.js | 2 + playground/public/nisps.wasm | Bin 0 -> 95864 bytes playground/src/audio/engine-host.ts | 227 ++++++ playground/src/audio/worklet/README.md | 52 ++ .../audio/worklet/audioworklet-globals.d.ts | 26 + .../src/audio/worklet/nisps-processor.ts | 309 +++++++++ playground/src/debug/probe.ts | 164 +++-- playground/src/ml/dataset.ts | 193 ++++++ playground/src/ml/types.ts | 156 +++++ playground/src/ml/wasm-iml.ts | 650 ++++++++++++++++++ playground/src/ml/wasm-worker.ts | 334 +++++++++ playground/src/stores/ml-store.ts | 177 ++++- scripts/build-wasm.sh | 75 ++ 15 files changed, 2860 insertions(+), 90 deletions(-) create mode 100644 nisps/wasm/README.md create mode 100644 nisps/wasm/bindings.cpp create mode 100644 playground/public/nisps.js create mode 100755 playground/public/nisps.wasm create mode 100644 playground/src/audio/engine-host.ts create mode 100644 playground/src/audio/worklet/README.md create mode 100644 playground/src/audio/worklet/audioworklet-globals.d.ts create mode 100644 playground/src/audio/worklet/nisps-processor.ts create mode 100644 playground/src/ml/dataset.ts create mode 100644 playground/src/ml/types.ts create mode 100644 playground/src/ml/wasm-iml.ts create mode 100644 playground/src/ml/wasm-worker.ts create mode 100755 scripts/build-wasm.sh 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 0000000000000000000000000000000000000000..1eecd696149741a8513c5e28ccc39c78688be0a9 GIT binary patch literal 95864 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 literal 0 HcmV?d00001 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"