// nisps/wasm/bindings.cpp — flat C API exported to the Manifold browser app. // // Two consumers per build: // 1. Main-thread WasmIML (manifold/src/engine/wasm-iml.ts) — ML calls. // 2. AudioWorklet processor (manifold/src/engine/worklet/...) — engine // calls. (Each instance owns its own WASM module instance.) // // ARCHITECTURE (runtime-shaped since one-core-engine-refactor P2) // --------------------------------------------------------------------- // The browser MLP is `MLPCore`: `nisps_ml_create()` HONOURS // its caller-supplied input_size / output_size / hidden[3] arguments. The // topology stays fixed at 4 layers (ReLU×3 + Sigmoid); only the dimensions // are runtime. Non-positive / missing arguments fall back to the historical // compiled defaults (32 → [10, 14, 18] → 126), which keeps every pre-P2 // caller — including the parity harness — bit-identical (FixedStorage and // DynamicStorage are bit-parity-tested for equal shapes/seeds). // // Reshape = `nisps_ml_reshape()`: a NEW instance at the new dimensions, // warm-started by copying the overlapping weight region from the old net // (nisps/ml/warm_start.hpp); weights outside the overlap keep the fresh // spread-initialised values. The feedback controller is re-created at the // new dimensions (its exploration state resets — the front-end shows a // reset-on-reshape modal). // // 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/dynamic_storage.hpp" #include "../ml/feedback.hpp" #include "../ml/mlp.hpp" #include "../ml/stats.hpp" #include "../ml/warm_start.hpp" namespace { // --------------------------------------------------------------------------- // ML side // --------------------------------------------------------------------------- // Historical compiled defaults. Non-positive / missing create() args fall // back to these, keeping every pre-P2 caller bit-identical. // * 32 inputs — MAX composed input axes the manifold front-end // feeds (matches MAX_AXES in manifold/src/inputs/input-layer.ts). // * [10, 14, 18] hidden — covers the largest schema layouts in // `schemas/modes/*.json`. // * 126 outputs — enough for the C15 mode and any current schema. constexpr std::size_t kDefaultInputs = 32u; constexpr std::size_t kDefaultHidden[3] = {10u, 14u, 18u}; constexpr std::size_t kDefaultOutputs = 126u; // Sanity ceiling per dimension — create/reshape reject anything larger. constexpr std::size_t kMaxDim = 4096u; constexpr std::uint64_t kFeedbackSalt = 0xFEEDBACC0DEull; using BrowserMLP = nisps::ml::MLPCore; using BrowserFeedback = nisps::ml::FeedbackControllerCore; constexpr std::size_t kFeedbackUndoDepth = 4u; struct MlDims { std::size_t n_in; std::size_t hidden[3]; std::size_t n_out; bool ok; }; // Sanitise caller-supplied dims. Non-positive input/output and missing / // non-3-entry hidden lists fall back to the defaults; out-of-range values // make the request invalid. MlDims sanitise_dims(int input_size, int output_size, const int* hidden, int n_hidden) noexcept { MlDims d{kDefaultInputs, {kDefaultHidden[0], kDefaultHidden[1], kDefaultHidden[2]}, kDefaultOutputs, true}; if (input_size > 0) d.n_in = static_cast(input_size); if (output_size > 0) d.n_out = static_cast(output_size); if (hidden && n_hidden == 3) { for (std::size_t i = 0; i < 3u; ++i) { if (hidden[i] <= 0) { d.ok = false; return d; } d.hidden[i] = static_cast(hidden[i]); } } else if (hidden && n_hidden != 0) { d.ok = false; // the 4-layer topology needs exactly 3 hidden sizes return d; } if (d.n_in > kMaxDim || d.n_out > kMaxDim || d.hidden[0] > kMaxDim || d.hidden[1] > kMaxDim || d.hidden[2] > kMaxDim) { d.ok = false; } return d; } // We allocate the MLP on the heap (one-off — not the audio path) and return // the opaque pointer to JS. struct MLHandle { std::uint64_t seed64; BrowserMLP mlp; // "Down Action" negative-feedback controller (Avoid/RandomiseOutputs/ // RandomiseMlp/ExploreAndPlace). Seeded off the MLP seed XOR a salt so // its static-output RNG stream is independent of the MLP's RNG. BrowserFeedback feedback; // Buffers used to bridge JS → C++ (sized to the instance's dims): std::vector output_scratch; // Stats buffer fed back to JS via get_layer_stats. std::array stats_scratch{}; // Static-output buffer for the RandomiseOutputs bypass path. std::vector feedback_static_scratch; // infer_batch cap; callers must split larger requests. static constexpr std::size_t kMaxBatch = 4096u; MLHandle(std::uint64_t seed, const MlDims& d) noexcept : seed64(seed), mlp(seed, d.n_in, std::span(d.hidden, 3u), d.n_out), feedback(seed ^ kFeedbackSalt, d.n_out, mlp.weight_count(), kFeedbackUndoDepth), output_scratch(d.n_out, 0.f), feedback_static_scratch(d.n_out, 0.f) {} bool valid() const noexcept { return mlp.valid() && feedback.valid(); } std::size_t n_in() const noexcept { return mlp.n_in(); } std::size_t n_out() const noexcept { return mlp.n_out(); } }; // --------------------------------------------------------------------------- // 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) { // Dimensions are HONOURED (runtime-shaped MLP); non-positive/missing args // fall back to the historical defaults. 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 // front-end doesn't need them, and avoiding BigInt at the boundary // simplifies both wasm-iml.ts and wasm-worker.ts. const MlDims d = sanitise_dims(input_size, output_size, hidden, n_hidden); if (!d.ok) return nullptr; const std::uint64_t s64 = static_cast(seed) ^ (static_cast(seed) << 32); auto* h = new MLHandle(s64, d); if (!h->valid()) { delete h; return nullptr; } return static_cast(h); } // Reshape: construct a NEW net at the requested dimensions (same seed // stream restart, fresh spread-init), warm-start it with the overlapping // weights of the current net, then swap it in. The feedback controller is // re-created at the new dims (exploration state resets). Returns 1 on // success; 0 leaves the existing net untouched. EMSCRIPTEN_KEEPALIVE int nisps_ml_reshape(void* ml, int input_size, int output_size, const int* hidden, int n_hidden, float spread) { if (!ml) return 0; auto* h = static_cast(ml); const MlDims d = sanitise_dims(input_size, output_size, hidden, n_hidden); if (!d.ok) return 0; BrowserMLP fresh(h->seed64, d.n_in, std::span(d.hidden, 3u), d.n_out); if (!fresh.valid()) return 0; fresh.draw_weights(spread); nisps::ml::warm_start_copy(fresh, h->mlp); BrowserFeedback fb(h->seed64 ^ kFeedbackSalt, d.n_out, fresh.weight_count(), kFeedbackUndoDepth); if (!fb.valid()) return 0; h->mlp = static_cast(fresh); h->feedback = static_cast(fb); h->output_scratch.assign(d.n_out, 0.f); h->feedback_static_scratch.assign(d.n_out, 0.f); return 1; } 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) >= h->n_in()) 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(); const std::size_t n_out = h->n_out(); for (std::size_t i = 0; i < n_out; ++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_in = h->n_in(); const std::size_t n_out = h->n_out(); const std::size_t n = static_cast(n_points); if (n > MLHandle::kMaxBatch) { // Caller exceeded the batch cap. Process what we can. const std::size_t safe_n = MLHandle::kMaxBatch; h->mlp.infer_batch( std::span(points, safe_n * n_in), std::span(out, safe_n * n_out)); return; } h->mlp.infer_batch( std::span(points, n * n_in), std::span(out, n * n_out)); } // --------------------------------------------------------------------------- // 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, h->n_in()), std::span(labels, h->n_out())); } 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) { if (!ml) { // Handle-less callers get the default shape's count. const MlDims d = sanitise_dims(0, 0, nullptr, 0); return static_cast( d.n_in * d.hidden[0] + d.hidden[0] * d.hidden[1] + d.hidden[1] * d.hidden[2] + d.hidden[2] * d.n_out + d.hidden[0] + d.hidden[1] + d.hidden[2] + d.n_out); } return static_cast(static_cast(ml)->mlp.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, h->mlp.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, h->n_out()); } h->mlp.move_weights(speed, spread, mask); } // --------------------------------------------------------------------------- // ML feedback — the "Down Action" state machine (Avoid / RandomiseOutputs / // RandomiseMlp). The controller decides WHAT transition happened (returns a // FeedbackAction int); JS performs the side effect (store example, grow noise, // train). See nisps/ml/feedback.hpp. Mode ints: 0=Avoid 1=RandOut 2=RandMlp. // Action ints mirror nisps::ml::FeedbackAction. // // CALLER CONTRACT (commit ordering — important): // On a "keep" (up) or drag-commit while exploring RandomiseMlp, the controller // RESTORES the original net before returning. The output the user is hearing // comes from the *temporary* (randomised) net, so you MUST capture the current // output (nisps_ml_outputs / nisps_ml_feedback_static_output) BEFORE calling // nisps_ml_feedback_up / _drag, then store THAT captured vector as the +1 // example. Reading the output AFTER the call yields the restored (wrong) net. // nisps_ml_feedback_down with current_out should pass the full n_out // live vector (RandomiseOutputs freezes unfocused dims at those values). // --------------------------------------------------------------------------- EMSCRIPTEN_KEEPALIVE void nisps_ml_feedback_set_mode(void* ml, int mode) { if (!ml) return; auto* h = static_cast(ml); nisps::ml::FeedbackMode m = nisps::ml::FeedbackMode::Avoid; if (mode == 1) m = nisps::ml::FeedbackMode::RandomiseOutputs; else if (mode == 2) m = nisps::ml::FeedbackMode::RandomiseMlp; else if (mode == 3) m = nisps::ml::FeedbackMode::ExploreAndPlace; h->feedback.set_mode(m, h->mlp); } EMSCRIPTEN_KEEPALIVE int nisps_ml_feedback_get_mode(void* ml) { if (!ml) return 0; return static_cast(static_cast(ml)->feedback.mode()); } EMSCRIPTEN_KEEPALIVE int nisps_ml_feedback_exploring(void* ml) { if (!ml) return 0; return static_cast(ml)->feedback.exploring() ? 1 : 0; } EMSCRIPTEN_KEEPALIVE int nisps_ml_feedback_learning_paused(void* ml) { if (!ml) return 0; return static_cast(ml)->feedback.learning_paused() ? 1 : 0; } EMSCRIPTEN_KEEPALIVE void nisps_ml_feedback_set_focus(void* ml, const uint8_t* mask, int n) { if (!ml) return; auto* h = static_cast(ml); if (!mask || n <= 0) { h->feedback.clear_focus_mask(); return; } h->feedback.set_focus_mask( std::span(mask, static_cast(n))); } // current_out = n_out floats the user is hearing (may be null). // pin_mask may be null. Returns the FeedbackAction int. EMSCRIPTEN_KEEPALIVE int nisps_ml_feedback_down(void* ml, const float* current_out, float speed, float spread, const uint8_t* pin_mask) { if (!ml) return 0; auto* h = static_cast(ml); std::span out; if (current_out) out = std::span(current_out, h->n_out()); std::span mask; if (pin_mask) mask = std::span(pin_mask, h->n_out()); return static_cast(h->feedback.on_down(h->mlp, out, speed, spread, mask)); } EMSCRIPTEN_KEEPALIVE int nisps_ml_feedback_up(void* ml) { if (!ml) return 0; auto* h = static_cast(ml); return static_cast(h->feedback.on_up(h->mlp)); } EMSCRIPTEN_KEEPALIVE int nisps_ml_feedback_drag(void* ml) { if (!ml) return 0; auto* h = static_cast(ml); return static_cast(h->feedback.on_drag(h->mlp)); } // If returns 1, `out` (n_out floats) holds the static bypass vector // and the caller should NOT call nisps_ml_process(); if 0, run process(). EMSCRIPTEN_KEEPALIVE int nisps_ml_feedback_static_output(void* ml, float* out) { if (!ml || !out) return 0; auto* h = static_cast(ml); const bool bypass = h->feedback.static_output(std::span( h->feedback_static_scratch.data(), h->feedback_static_scratch.size())); if (bypass) { std::memcpy(out, h->feedback_static_scratch.data(), h->n_out() * sizeof(float)); } return bypass ? 1 : 0; } // --------------------------------------------------------------------------- // ML feedback — ExploreAndPlace lifecycle (Idle → Exploring → Placing → Idle). // Granular transitions so the SAME shared core drives both the browser (which // also uses on_down/on_up via _down/_up) and firmware (which maps buttons to // these directly). Set mode 3 (ExploreAndPlace) via nisps_ml_feedback_set_mode. // // CALLER CONTRACT (commit ordering): on _commit_place the controller restores // the REAL net; the caller then reads nisps_ml_feedback_committed_output and // adds it as the +1 example label at the chosen input, then trains. // --------------------------------------------------------------------------- // Idle→Exploring: snapshot the real net, randomise a scratchpad. EMSCRIPTEN_KEEPALIVE void nisps_ml_feedback_enter_explore(void* ml, float spread) { if (!ml) return; auto* h = static_cast(ml); h->feedback.enter_explore(h->mlp, spread); } // Exploring→Idle: restore the real net, discard scratchpad. EMSCRIPTEN_KEEPALIVE void nisps_ml_feedback_exit_explore(void* ml) { if (!ml) return; auto* h = static_cast(ml); h->feedback.exit_explore(h->mlp); } // Exploring scratchpad op: re-randomise. EMSCRIPTEN_KEEPALIVE void nisps_ml_feedback_reroll(void* ml, float spread) { if (!ml) return; auto* h = static_cast(ml); h->feedback.reroll(h->mlp, spread); } // Exploring scratchpad op: bounded nudge (amount = noise stddev, e.g. 0.05). EMSCRIPTEN_KEEPALIVE void nisps_ml_feedback_nudge(void* ml, float amount) { if (!ml) return; auto* h = static_cast(ml); h->feedback.nudge(h->mlp, amount); } // Exploring scratchpad op: undo last reroll/nudge. EMSCRIPTEN_KEEPALIVE void nisps_ml_feedback_undo(void* ml) { if (!ml) return; auto* h = static_cast(ml); h->feedback.undo(h->mlp); } // Exploring→Placing: freeze the scratchpad output at its CURRENT input. EMSCRIPTEN_KEEPALIVE void nisps_ml_feedback_like(void* ml) { if (!ml) return; auto* h = static_cast(ml); h->feedback.begin_place(h->mlp); } // Placing→Idle: restore the real net. Caller then reads committed_output and // stores the +1 example at the chosen input. EMSCRIPTEN_KEEPALIVE void nisps_ml_feedback_commit_place(void* ml) { if (!ml) return; auto* h = static_cast(ml); h->feedback.commit_place(h->mlp); } // Placing→Exploring: back out of placing (no store). EMSCRIPTEN_KEEPALIVE void nisps_ml_feedback_cancel_place(void* ml) { if (!ml) return; auto* h = static_cast(ml); h->feedback.cancel_place(); } // 1 if currently Placing (audition is the frozen vector), else 0. EMSCRIPTEN_KEEPALIVE int nisps_ml_feedback_placing(void* ml) { if (!ml) return 0; return static_cast(ml)->feedback.placing() ? 1 : 0; } // ExploreState int: 0=Idle 1=Exploring 2=Placing. EMSCRIPTEN_KEEPALIVE int nisps_ml_feedback_state(void* ml) { if (!ml) return 0; return static_cast(static_cast(ml)->feedback.explore_state()); } // Scratchpad undo-ring depth currently available to pop. EMSCRIPTEN_KEEPALIVE int nisps_ml_feedback_undo_depth(void* ml) { if (!ml) return 0; return static_cast(static_cast(ml)->feedback.undo_depth()); } // Writes the committed/placed output vector (n_out floats) into `out`. // Returns 1 if a vector was written (placing OR a fresh commit), else 0. Reads // committed_output() (valid post-commit) falling back to placed_output() (while // placing) so the caller can grab the label either before or after commit. EMSCRIPTEN_KEEPALIVE int nisps_ml_feedback_placed_output(void* ml, float* out) { if (!ml || !out) return 0; auto* h = static_cast(ml); std::span v = h->feedback.committed_output(); if (v.empty()) v = h->feedback.placed_output(); if (v.empty()) return 0; const std::size_t n = (v.size() < h->n_out()) ? v.size() : h->n_out(); std::memcpy(out, v.data(), n * sizeof(float)); return 1; } 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 < BrowserMLP::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 — writes [in, h1, h2, h3, out, n_layers] into a // caller-supplied int buffer. Always 6 ints. With a null handle it reports // the DEFAULT shape (what create() yields for non-positive args); with a // handle it reports that instance's actual runtime shape. EMSCRIPTEN_KEEPALIVE void nisps_ml_describe(void* ml, int* out_dims) { if (!out_dims) return; if (!ml) { out_dims[0] = static_cast(kDefaultInputs); out_dims[1] = static_cast(kDefaultHidden[0]); out_dims[2] = static_cast(kDefaultHidden[1]); out_dims[3] = static_cast(kDefaultHidden[2]); out_dims[4] = static_cast(kDefaultOutputs); out_dims[5] = static_cast(BrowserMLP::kNumLayers); return; } auto* h = static_cast(ml); out_dims[0] = static_cast(h->mlp.n_in()); out_dims[1] = static_cast(h->mlp.fan_out(0u)); out_dims[2] = static_cast(h->mlp.fan_out(1u)); out_dims[3] = static_cast(h->mlp.fan_out(2u)); out_dims[4] = static_cast(h->mlp.n_out()); out_dims[5] = static_cast(BrowserMLP::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"