feat(playground): replace JS ML engine with WASM nisps-core

Compile nisps-core C++ MLP to WASM (36KB) and use it as the ML engine
in the playground, replacing the JavaScript port for inference, training,
and weight manipulation.

- Add extern "C" WASM bindings with spread-aware drawWeights/moveWeights
- WasmIML class is a drop-in replacement for the JS IML
- Inference runs on main thread via WASM (fast, synchronous)
- Training runs in a Web Worker with its own WASM instance (non-blocking)
- Interactive training (thumbs-up, train button) no longer freezes UI/audio
- Preset loading and state restore still use sync training
This commit is contained in:
w1n5t0n 2026-03-23 23:23:07 +02:00
parent 8a550361ea
commit f8983c4806
7 changed files with 775 additions and 31 deletions

View file

@ -1,7 +1,7 @@
// NISPS Immersive — Design A // NISPS Immersive — Design A
// Full-viewport flow field with floating overlays // Full-viewport flow field with floating overlays
import { IML } from './nisps/iml.js'; import { WasmIML } from './nisps/nisps-wasm.js';
import { FlowFieldVisualizer } from './ui/visualizer.js'; import { FlowFieldVisualizer } from './ui/visualizer.js';
import { C15Bridge } from './synth/c15-bridge.js'; import { C15Bridge } from './synth/c15-bridge.js';
import { Arpeggiator } from './synth/arpeggiator.js'; import { Arpeggiator } from './synth/arpeggiator.js';
@ -665,7 +665,7 @@ function padPresetOutputs(outputs) {
} }
// ---- Init ---- // ---- Init ----
function init() { async function init() {
// Parse ?tame URL param // Parse ?tame URL param
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
// tame is handled at groupOverrides init time, no longer needed here // tame is handled at groupOverrides init time, no longer needed here
@ -673,8 +673,8 @@ function init() {
if (isNaN(spreadLevel)) spreadLevel = 0.6; if (isNaN(spreadLevel)) spreadLevel = 0.6;
spreadLevel = Math.max(0, Math.min(1, spreadLevel)); spreadLevel = Math.max(0, Math.min(1, spreadLevel));
// IML — fresh random weights each boot, no state restoration // IML — WASM-backed, fresh random weights each boot
iml = new IML(N_INPUTS, N_OUTPUTS, [32, 48, 64], 1000, 1.0, 0.00001); iml = await WasmIML.create(N_INPUTS, N_OUTPUTS, [32, 48, 64], 1000, 1.0, 0.00001);
iml.setLogger(msg => console.log('[NISPS]', msg)); iml.setLogger(msg => console.log('[NISPS]', msg));
// Canvas + Visualizer // Canvas + Visualizer
@ -1157,17 +1157,9 @@ function onAddExample() {
} }
function onTrain() { function onTrain() {
const loss = trainModel(); if (iml.isTraining) return;
if (loss !== null) {
const outputs = iml.getOutputs();
routeOutputs(outputs);
updateHeatmap(outputs);
syncRawParamsFromOutputs(outputs);
updateStatus();
drawLossPlot();
drawJoyMap();
flash('btn-train'); flash('btn-train');
} trainModelAsync();
} }
function onRandomize() { function onRandomize() {
@ -1203,24 +1195,18 @@ function onClear() {
// ---- RL mode ---- // ---- RL mode ----
function onThumbsUp() { function onThumbsUp() {
if (iml.isTraining) return;
const inputs = [joyX, joyY]; const inputs = [joyX, joyY];
const outputs = [...iml.getOutputs()]; const outputs = [...iml.getOutputs()];
iml.addExample(inputs, outputs); iml.addExample(inputs, outputs);
trainModel();
const trainedOutputs = iml.getOutputs();
routeOutputs(trainedOutputs);
updateHeatmap(trainedOutputs);
syncRawParamsFromOutputs(trainedOutputs);
noiseLevel *= rlExplorationDecay; noiseLevel *= rlExplorationDecay;
noiseLevel = Math.max(noiseLevel, 0.005); noiseLevel = Math.max(noiseLevel, 0.005);
updateStatus();
drawLossPlot();
drawJoyMap();
updateNoiseRing();
flash('btn-thumbsup'); flash('btn-thumbsup');
updateNoiseRing();
trainModelAsync();
} }
function onThumbsDown() { function onThumbsDown() {
@ -1239,14 +1225,22 @@ function onThumbsDown() {
} }
// ---- Training ---- // ---- Training ----
// Sync — used for preset loading and state restore
function trainModel() { function trainModel() {
const loss = iml.train({ return iml.train();
onIteration: (iter, iterLoss) => { }
if (iter % 8 !== 0) return;
// Async — used for interactive training (thumbs-up, train button)
function trainModelAsync(onDone) {
iml.trainAsync(({ loss, outputs }) => {
routeOutputs(outputs);
updateHeatmap(outputs);
syncRawParamsFromOutputs(outputs);
updateStatus();
drawLossPlot(); drawLossPlot();
}, drawJoyMap();
if (onDone) onDone();
}); });
return loss;
} }
// ---- Presets ---- // ---- Presets ----

View file

@ -0,0 +1,122 @@
// Web Worker for async WASM training (module worker)
// Loads its own nisps WASM instance for off-thread training
import NispsModule from '../../wasm/nisps.js';
let mod = null;
let w = null;
let mlp = null;
let currentLayerSizes = null;
async function ensureModule() {
if (mod) return;
mod = await NispsModule();
w = {
mod,
create: mod.cwrap('nisps_mlp_create', 'number', ['number', 'number', 'number', 'number']),
destroy: mod.cwrap('nisps_mlp_destroy', null, ['number']),
weightCount: mod.cwrap('nisps_mlp_weight_count', 'number', ['number']),
getWeights: mod.cwrap('nisps_mlp_get_weights', null, ['number', 'number']),
setWeights: mod.cwrap('nisps_mlp_set_weights', null, ['number', 'number']),
train: mod.cwrap('nisps_mlp_train', 'number', ['number', 'number', 'number', 'number', 'number', 'number', 'number', 'number', 'number']),
alloc: mod.cwrap('nisps_alloc', 'number', ['number']),
free: mod.cwrap('nisps_free', null, ['number']),
allocInt: mod.cwrap('nisps_alloc_int', 'number', ['number']),
freeInt: mod.cwrap('nisps_free_int', null, ['number']),
};
}
function toHeapF32(arr) {
const ptr = w.alloc(arr.length);
w.mod.HEAPF32.set(arr, ptr >> 2);
return ptr;
}
function toHeapI32(arr) {
const ptr = w.allocInt(arr.length);
w.mod.HEAP32.set(arr, ptr >> 2);
return ptr;
}
function fromHeapF32(ptr, length) {
const offset = ptr >> 2;
return Array.from(w.mod.HEAPF32.subarray(offset, offset + length));
}
function arraysEqual(a, b) {
if (!a || !b || a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
return true;
}
self.onmessage = async function(e) {
const { type, payload } = e.data;
if (type === 'train') {
await ensureModule();
const {
layerSizes, activationIds, weights,
features, labels,
nInputs, nOutputs,
learningRate, maxIterations, convergenceThreshold,
} = payload;
// Recreate MLP if architecture changed
if (!mlp || !arraysEqual(currentLayerSizes, layerSizes)) {
if (mlp) w.destroy(mlp);
const layerPtr = toHeapI32(new Int32Array(layerSizes));
const actPtr = toHeapI32(new Int32Array(activationIds));
mlp = w.create(layerPtr, layerSizes.length, actPtr, activationIds.length);
w.freeInt(layerPtr);
w.freeInt(actPtr);
currentLayerSizes = [...layerSizes];
}
// Load weights from main thread
const weightCount = w.weightCount(mlp);
const wPtr = toHeapF32(new Float32Array(weights));
w.setWeights(mlp, wPtr);
w.free(wPtr);
// Build flat training arrays with bias
const featureDim = nInputs + 1;
const nSamples = features.length;
const featFlat = new Float32Array(nSamples * featureDim);
const labFlat = new Float32Array(nSamples * nOutputs);
for (let i = 0; i < nSamples; i++) {
for (let j = 0; j < nInputs; j++) {
featFlat[i * featureDim + j] = features[i][j];
}
featFlat[i * featureDim + nInputs] = 1.0;
for (let j = 0; j < nOutputs; j++) {
labFlat[i * nOutputs + j] = labels[i][j] || 0;
}
}
const featPtr = toHeapF32(featFlat);
const labPtr = toHeapF32(labFlat);
// Train
const loss = w.train(
mlp, featPtr, nSamples, featureDim,
labPtr, nOutputs,
learningRate, maxIterations, convergenceThreshold
);
w.free(featPtr);
w.free(labPtr);
// Extract trained weights
const outPtr = w.alloc(weightCount);
w.getWeights(mlp, outPtr);
const trainedWeights = fromHeapF32(outPtr, weightCount);
w.free(outPtr);
self.postMessage({
type: 'trained',
payload: { weights: trainedWeights, loss },
});
}
};

View file

@ -0,0 +1,405 @@
// WASM-backed IML — drop-in replacement for the JS IML class.
// Uses nisps-core compiled to WASM for inference, training, and weight ops.
// Training runs in a Web Worker for non-blocking operation.
// Activation function IDs matching C++ nisps::ACTIVATION_FUNCTIONS enum
const ACTIVATION = { SIGMOID: 0, TANH: 1, LINEAR: 2, RELU: 3 };
/**
* Load the Emscripten module. Returns the initialized module.
*/
async function loadNispsModule() {
const { default: NispsModule } = await import('../../wasm/nisps.js');
const mod = await NispsModule();
return mod;
}
/**
* Wrap raw Emscripten module with typed JS helpers.
*/
function wrapModule(mod) {
return {
mod,
create: mod.cwrap('nisps_mlp_create', 'number', ['number', 'number', 'number', 'number']),
destroy: mod.cwrap('nisps_mlp_destroy', null, ['number']),
weightCount: mod.cwrap('nisps_mlp_weight_count', 'number', ['number']),
getWeights: mod.cwrap('nisps_mlp_get_weights', null, ['number', 'number']),
setWeights: mod.cwrap('nisps_mlp_set_weights', null, ['number', 'number']),
inference: mod.cwrap('nisps_mlp_inference', null, ['number', 'number', 'number', 'number', 'number']),
train: mod.cwrap('nisps_mlp_train', 'number', ['number', 'number', 'number', 'number', 'number', 'number', 'number', 'number', 'number']),
drawWeightsSpread: mod.cwrap('nisps_mlp_draw_weights_spread', null, ['number', 'number']),
moveWeightsSpread: mod.cwrap('nisps_mlp_move_weights_spread', null, ['number', 'number', 'number']),
alloc: mod.cwrap('nisps_alloc', 'number', ['number']),
free: mod.cwrap('nisps_free', null, ['number']),
allocInt: mod.cwrap('nisps_alloc_int', 'number', ['number']),
freeInt: mod.cwrap('nisps_free_int', null, ['number']),
};
}
/**
* Write a JS array into WASM heap, returning the pointer.
* Caller must free with w.free(ptr).
*/
function toHeapF32(w, arr) {
const ptr = w.alloc(arr.length);
w.mod.HEAPF32.set(arr, ptr >> 2);
return ptr;
}
function toHeapI32(w, arr) {
const ptr = w.allocInt(arr.length);
w.mod.HEAP32.set(arr, ptr >> 2);
return ptr;
}
function fromHeapF32(w, ptr, length) {
const offset = ptr >> 2;
return Array.from(w.mod.HEAPF32.subarray(offset, offset + length));
}
/**
* WASM-backed IML class API-compatible with the JS IML.
*
* Construction is async: use `await WasmIML.create(...)` instead of `new IML(...)`.
*/
export class WasmIML {
/**
* Async factory loads WASM and constructs the IML.
*/
static async create(
nInputs,
nOutputs,
hiddenLayers = [10, 10, 14],
maxIterations = 1000,
learningRate = 1.0,
convergenceThreshold = 0.00001
) {
const mod = await loadNispsModule();
const iml = new WasmIML(mod, nInputs, nOutputs, hiddenLayers,
maxIterations, learningRate, convergenceThreshold);
return iml;
}
constructor(mod, nInputs, nOutputs, hiddenLayers, maxIterations, learningRate, convergenceThreshold) {
this.nInputs = nInputs;
this.nOutputs = nOutputs;
this.maxIterations = maxIterations;
this.learningRate = learningRate;
this.convergenceThreshold = convergenceThreshold;
// Layer sizes: input+bias, hidden..., output
const BIAS = 1;
this.layerSizes = [nInputs + BIAS, ...hiddenLayers, nOutputs];
// Activations: RELU for hidden, SIGMOID for output
this.activationIds = [
...hiddenLayers.map(() => ACTIVATION.RELU),
ACTIVATION.SIGMOID,
];
// WASM module + helpers
this._w = wrapModule(mod);
this._createMLP();
// State (JS-side, same as original IML)
this.inputState = new Array(nInputs).fill(0.5);
this.outputState = new Array(nOutputs).fill(0);
this.performInference = true;
this.inputUpdated = true;
this.storedWeights = null;
this.weightsRandomised = false;
this.lastLoss = null;
this.bestLoss = null;
this.lossHistory = [];
this.totalTrainingIterations = 0;
this.logFn = null;
// Dataset (JS-side for persistence/visualization access)
this.dataset = { features: [], labels: [], maxExamples: 100 };
// Persistent WASM buffers for inference (avoid alloc/free per frame)
const inputDim = nInputs + BIAS;
this._inputPtr = this._w.alloc(inputDim);
this._outputPtr = this._w.alloc(nOutputs);
this._inputDim = inputDim;
// Worker for async training
this._worker = null;
this._training = false;
}
_createMLP() {
const w = this._w;
const layerPtr = toHeapI32(w, new Int32Array(this.layerSizes));
const actPtr = toHeapI32(w, new Int32Array(this.activationIds));
this._mlp = w.create(layerPtr, this.layerSizes.length, actPtr, this.activationIds.length);
w.freeInt(layerPtr);
w.freeInt(actPtr);
this._weightCount = w.weightCount(this._mlp);
}
// ---- Logging ----
setLogger(fn) { this.logFn = fn; }
log(msg) { if (this.logFn) this.logFn(msg); }
// ---- Input / Output ----
setInput(index, value) {
if (index >= this.nInputs) return;
this.inputState[index] = Math.max(0, Math.min(1, value));
this.inputUpdated = true;
}
setInputs(values) {
for (let i = 0; i < values.length && i < this.nInputs; i++) {
this.inputState[i] = Math.max(0, Math.min(1, values[i]));
}
this.inputUpdated = true;
}
getOutputs() { return this.outputState; }
setOutput(index, value) {
if (index >= this.nOutputs) return;
this.outputState[index] = Math.max(0, Math.min(1, value));
}
setOutputs(values) {
for (let i = 0; i < values.length && i < this.nOutputs; i++) {
this.outputState[i] = Math.max(0, Math.min(1, values[i]));
}
}
// ---- Inference (WASM, synchronous — fast) ----
process() {
if (!this.performInference || !this.inputUpdated) return;
// Write input + bias into persistent WASM buffer
const heap = this._w.mod.HEAPF32;
const inOff = this._inputPtr >> 2;
for (let i = 0; i < this.nInputs; i++) {
heap[inOff + i] = this.inputState[i];
}
heap[inOff + this.nInputs] = 1.0; // bias
this._w.inference(this._mlp, this._inputPtr, this._inputDim, this._outputPtr, this.nOutputs);
// Read output
const outOff = this._outputPtr >> 2;
for (let i = 0; i < this.nOutputs; i++) {
this.outputState[i] = heap[outOff + i];
}
this.inputUpdated = false;
}
// ---- Dataset ----
addExample(inputs, outputs) {
const inVec = inputs.slice(0, this.nInputs);
while (inVec.length < this.nInputs) inVec.push(0);
const outVec = outputs.slice(0, this.nOutputs);
while (outVec.length < this.nOutputs) outVec.push(0);
if (this.dataset.features.length >= this.dataset.maxExamples) {
this.dataset.features.shift();
this.dataset.labels.shift();
}
this.dataset.features.push([...inVec]);
this.dataset.labels.push([...outVec]);
}
clearDataset() {
this.dataset.features = [];
this.dataset.labels = [];
this.log('Dataset cleared.');
}
get exampleCount() { return this.dataset.features.length; }
// ---- Training (WASM, synchronous) ----
train(options = {}) {
if (this.weightsRandomised && this.storedWeights) {
this._setFlatWeights(this.storedWeights);
this.weightsRandomised = false;
}
const features = this.dataset.features;
const labels = this.dataset.labels;
if (features.length === 0) {
this.log('Empty dataset, skipping training.');
return null;
}
this.log('Training...');
// Build flat arrays with bias appended to features
const featureDim = this.nInputs + 1; // +bias
const nSamples = features.length;
const featFlat = new Float32Array(nSamples * featureDim);
const labFlat = new Float32Array(nSamples * this.nOutputs);
for (let i = 0; i < nSamples; i++) {
for (let j = 0; j < this.nInputs; j++) {
featFlat[i * featureDim + j] = features[i][j];
}
featFlat[i * featureDim + this.nInputs] = 1.0; // bias
for (let j = 0; j < this.nOutputs; j++) {
labFlat[i * this.nOutputs + j] = labels[i][j] || 0;
}
}
const featPtr = toHeapF32(this._w, featFlat);
const labPtr = toHeapF32(this._w, labFlat);
const loss = this._w.train(
this._mlp, featPtr, nSamples, featureDim,
labPtr, this.nOutputs,
this.learningRate, this.maxIterations, this.convergenceThreshold
);
this._w.free(featPtr);
this._w.free(labPtr);
this.lastLoss = loss;
// We don't have per-iteration history from WASM (single return value),
// so record just the final loss
this.lossHistory.push(loss);
this.totalTrainingIterations += 1;
if (this.lossHistory.length > 1200) {
this.lossHistory = this.lossHistory.slice(this.lossHistory.length - 1200);
}
this.bestLoss = this.bestLoss === null ? loss : Math.min(this.bestLoss, loss);
// Run inference after training
this.inputUpdated = true;
this.process();
this.log(`Training complete. Loss: ${loss.toFixed(6)}`);
return loss;
}
// ---- Weight manipulation ----
randomiseWeights(spread = 0) {
this.storedWeights = this._getFlatWeights();
this._w.drawWeightsSpread(this._mlp, spread);
this.weightsRandomised = true;
this.inputUpdated = true;
this.process();
this.log('Weights randomised.');
}
moveWeights(speed, spread = 0) {
this._w.moveWeightsSpread(this._mlp, speed, spread);
this.inputUpdated = true;
this.process();
}
// ---- Flat weight get/set (for storedWeights save/restore) ----
_getFlatWeights() {
const ptr = this._w.alloc(this._weightCount);
this._w.getWeights(this._mlp, ptr);
const weights = fromHeapF32(this._w, ptr, this._weightCount);
this._w.free(ptr);
return weights;
}
_setFlatWeights(flatWeights) {
const ptr = toHeapF32(this._w, new Float32Array(flatWeights));
this._w.setWeights(this._mlp, ptr);
this._w.free(ptr);
}
// ---- Async training via Web Worker ----
get isTraining() { return this._training; }
trainAsync(onComplete) {
if (this._training) {
this.log('Training already in progress, skipping.');
return Promise.resolve(null);
}
// Restore weights if randomised
if (this.weightsRandomised && this.storedWeights) {
this._setFlatWeights(this.storedWeights);
this.weightsRandomised = false;
}
if (this.dataset.features.length === 0) {
this.log('Empty dataset, skipping training.');
return Promise.resolve(null);
}
this._training = true;
this.log('Training (async)...');
// Snapshot current weights + dataset for the worker
const flatWeights = this._getFlatWeights();
const features = this.dataset.features;
const labels = this.dataset.labels;
// Lazy-init worker
if (!this._worker) {
const workerUrl = new URL('./nisps-wasm-worker.js', import.meta.url);
this._worker = new Worker(workerUrl, { type: 'module' });
}
return new Promise((resolve) => {
const handler = (e) => {
if (e.data.type === 'trained') {
this._worker.removeEventListener('message', handler);
this._training = false;
const { weights, loss } = e.data.payload;
// Swap in trained weights
this._setFlatWeights(weights);
this.lastLoss = loss;
this.lossHistory.push(loss);
this.totalTrainingIterations += 1;
if (this.lossHistory.length > 1200) {
this.lossHistory = this.lossHistory.slice(this.lossHistory.length - 1200);
}
this.bestLoss = this.bestLoss === null ? loss : Math.min(this.bestLoss, loss);
// Run inference with new weights
this.inputUpdated = true;
this.process();
this.log(`Training complete. Loss: ${loss.toFixed(6)}`);
if (onComplete) onComplete({ loss, outputs: [...this.outputState] });
resolve(loss);
}
};
this._worker.addEventListener('message', handler);
this._worker.postMessage({
type: 'train',
payload: {
layerSizes: this.layerSizes,
activationIds: this.activationIds,
weights: flatWeights,
features,
labels,
nInputs: this.nInputs,
nOutputs: this.nOutputs,
learningRate: this.learningRate,
maxIterations: this.maxIterations,
convergenceThreshold: this.convergenceThreshold,
},
});
});
}
// ---- Cleanup ----
destroy() {
if (this._mlp) {
this._w.free(this._inputPtr);
this._w.free(this._outputPtr);
this._w.destroy(this._mlp);
this._mlp = null;
}
if (this._worker) {
this._worker.terminate();
this._worker = null;
}
}
}

42
playground/wasm/build.sh Executable file
View file

@ -0,0 +1,42 @@
#!/bin/bash
# Build nisps-core WASM module for the playground
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
NISPS_CORE="$SCRIPT_DIR/../../nisps-core/include"
OUT_DIR="$SCRIPT_DIR"
echo "Building nisps WASM module..."
emcc "$SCRIPT_DIR/nisps_bindings.cpp" \
-I"$NISPS_CORE" \
-std=c++20 \
-O2 \
-s EXPORTED_FUNCTIONS='[
"_nisps_mlp_create",
"_nisps_mlp_destroy",
"_nisps_mlp_weight_count",
"_nisps_mlp_get_weights",
"_nisps_mlp_set_weights",
"_nisps_mlp_inference",
"_nisps_mlp_train",
"_nisps_mlp_draw_weights_spread",
"_nisps_mlp_move_weights_spread",
"_nisps_alloc",
"_nisps_free",
"_nisps_alloc_int",
"_nisps_free_int",
"_malloc",
"_free"
]' \
-s EXPORTED_RUNTIME_METHODS='["cwrap","HEAPF32","HEAP32"]' \
-s MODULARIZE=1 \
-s EXPORT_NAME=NispsModule \
-s EXPORT_ES6=1 \
-s ENVIRONMENT='web,worker' \
-s ALLOW_MEMORY_GROWTH=1 \
-s INITIAL_MEMORY=16777216 \
-o "$OUT_DIR/nisps.js"
echo "Built: $OUT_DIR/nisps.js + $OUT_DIR/nisps.wasm"
echo "Size: $(du -h "$OUT_DIR/nisps.wasm" | cut -f1)"

2
playground/wasm/nisps.js Normal file

File diff suppressed because one or more lines are too long

BIN
playground/wasm/nisps.wasm Executable file

Binary file not shown.

View file

@ -0,0 +1,179 @@
// WASM bindings for nisps-core MLP engine
// Provides a flat C API for use from JavaScript via Emscripten
#include <emscripten.h>
#include "nisps/mlp.hpp"
#include <cstdlib>
#include <cmath>
extern "C" {
// ---- Lifecycle ----
EMSCRIPTEN_KEEPALIVE
void* nisps_mlp_create(int* layer_sizes, int n_layers, int* activation_ids, int n_activations) {
std::vector<size_t> layers(layer_sizes, layer_sizes + n_layers);
std::vector<nisps::ACTIVATION_FUNCTIONS> activations;
for (int i = 0; i < n_activations; i++) {
activations.push_back(static_cast<nisps::ACTIVATION_FUNCTIONS>(activation_ids[i]));
}
auto* mlp = new nisps::MLP<float>(layers, activations, nisps::loss::LOSS_MSE, false, 0.0f);
return mlp;
}
EMSCRIPTEN_KEEPALIVE
void nisps_mlp_destroy(void* ptr) {
delete static_cast<nisps::MLP<float>*>(ptr);
}
// ---- Weight serialization ----
// Flat format: for each layer, for each node: [w0, w1, ..., wN, bias]
EMSCRIPTEN_KEEPALIVE
int nisps_mlp_weight_count(void* ptr) {
auto* mlp = static_cast<nisps::MLP<float>*>(ptr);
int count = 0;
for (auto& layer : mlp->m_layers) {
for (auto& node : layer.m_nodes) {
count += node.m_weights.size() + 1; // weights + bias
}
}
return count;
}
EMSCRIPTEN_KEEPALIVE
void nisps_mlp_get_weights(void* ptr, float* out) {
auto* mlp = static_cast<nisps::MLP<float>*>(ptr);
int idx = 0;
for (auto& layer : mlp->m_layers) {
for (auto& node : layer.m_nodes) {
for (size_t j = 0; j < node.m_weights.size(); j++) {
out[idx++] = node.m_weights[j];
}
out[idx++] = node.m_bias;
}
}
}
EMSCRIPTEN_KEEPALIVE
void nisps_mlp_set_weights(void* ptr, float* weights) {
auto* mlp = static_cast<nisps::MLP<float>*>(ptr);
int idx = 0;
for (auto& layer : mlp->m_layers) {
for (auto& node : layer.m_nodes) {
for (size_t j = 0; j < node.m_weights.size(); j++) {
node.m_weights[j] = weights[idx++];
}
node.m_bias = weights[idx++];
}
}
}
// ---- Inference ----
EMSCRIPTEN_KEEPALIVE
void nisps_mlp_inference(void* ptr, float* input, int input_dim, float* output, int output_dim) {
auto* mlp = static_cast<nisps::MLP<float>*>(ptr);
std::vector<float> in_vec(input, input + input_dim);
std::vector<float> out_vec;
mlp->GetOutput(in_vec, &out_vec, nullptr, true);
int n = output_dim < (int)out_vec.size() ? output_dim : (int)out_vec.size();
for (int i = 0; i < n; i++) {
output[i] = out_vec[i];
}
}
// ---- Training ----
// Takes flat arrays, builds training pairs, returns final loss
EMSCRIPTEN_KEEPALIVE
float nisps_mlp_train(void* ptr,
float* features_flat, int n_samples, int feature_dim,
float* labels_flat, int label_dim,
float learning_rate, int max_iterations, float min_error) {
auto* mlp = static_cast<nisps::MLP<float>*>(ptr);
std::vector<std::vector<float>> features(n_samples);
std::vector<std::vector<float>> labels(n_samples);
for (int i = 0; i < n_samples; i++) {
features[i].assign(features_flat + i * feature_dim,
features_flat + (i + 1) * feature_dim);
labels[i].assign(labels_flat + i * label_dim,
labels_flat + (i + 1) * label_dim);
}
nisps::MLP<float>::training_pair_t data(features, labels);
return mlp->Train(data, learning_rate, max_iterations, min_error, false);
}
// ---- Weight manipulation with spread ----
// These mirror the JS playground's spread-aware versions which the C++ core
// doesn't have natively.
// DrawWeights: randomize with per-layer Xavier scaling controlled by spread
// spread=0: uniform [-1,1], spread=1: Xavier-scaled per layer
EMSCRIPTEN_KEEPALIVE
void nisps_mlp_draw_weights_spread(void* ptr, float spread) {
auto* mlp = static_cast<nisps::MLP<float>*>(ptr);
for (size_t l = 0; l < mlp->m_layers.size(); l++) {
int fan_in = mlp->m_layers[l].GetInputSize();
float xavier_scale = 1.0f / std::sqrt((float)fan_in);
float scale = 1.0f * (1.0f - spread) + xavier_scale * spread;
for (auto& node : mlp->m_layers[l].GetNodesChangeable()) {
for (size_t j = 0; j < node.m_weights.size(); j++) {
node.m_weights[j] = ((float)rand() / RAND_MAX * 2.0f - 1.0f) * scale;
}
node.m_bias = 0.0f;
}
}
}
// MoveWeights: add per-layer noise with weight decay, controlled by spread
// spread=0: flat noise, no decay. spread=1: Xavier-scaled noise, 10% decay.
EMSCRIPTEN_KEEPALIVE
void nisps_mlp_move_weights_spread(void* ptr, float speed, float spread) {
auto* mlp = static_cast<nisps::MLP<float>*>(ptr);
float decay = 1.0f - 0.1f * spread;
for (size_t l = 0; l < mlp->m_layers.size(); l++) {
int fan_in = mlp->m_layers[l].GetInputSize();
float xavier_scale = 1.0f / std::sqrt((float)fan_in);
float layer_scale = 1.0f * (1.0f - spread) + xavier_scale * spread;
for (auto& node : mlp->m_layers[l].GetNodesChangeable()) {
for (size_t j = 0; j < node.m_weights.size(); j++) {
node.m_weights[j] *= decay;
// gen_randn: sum of 3 uniform randoms
float accum = 0;
for (int n = 0; n < 3; n++) {
accum += (float)rand() / RAND_MAX * 2.0f - 1.0f;
}
node.m_weights[j] += 3.0f * accum * speed * layer_scale;
}
}
}
}
// ---- Memory helpers ----
EMSCRIPTEN_KEEPALIVE
float* nisps_alloc(int n) {
return (float*)malloc(n * sizeof(float));
}
EMSCRIPTEN_KEEPALIVE
void nisps_free(float* ptr) {
free(ptr);
}
EMSCRIPTEN_KEEPALIVE
int* nisps_alloc_int(int n) {
return (int*)malloc(n * sizeof(int));
}
EMSCRIPTEN_KEEPALIVE
void nisps_free_int(int* ptr) {
free(ptr);
}
} // extern "C"