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:
parent
8a550361ea
commit
f8983c4806
7 changed files with 775 additions and 31 deletions
|
|
@ -1,7 +1,7 @@
|
|||
// NISPS Immersive — Design A
|
||||
// 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 { C15Bridge } from './synth/c15-bridge.js';
|
||||
import { Arpeggiator } from './synth/arpeggiator.js';
|
||||
|
|
@ -665,7 +665,7 @@ function padPresetOutputs(outputs) {
|
|||
}
|
||||
|
||||
// ---- Init ----
|
||||
function init() {
|
||||
async function init() {
|
||||
// Parse ?tame URL param
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
// tame is handled at groupOverrides init time, no longer needed here
|
||||
|
|
@ -673,8 +673,8 @@ function init() {
|
|||
if (isNaN(spreadLevel)) spreadLevel = 0.6;
|
||||
spreadLevel = Math.max(0, Math.min(1, spreadLevel));
|
||||
|
||||
// IML — fresh random weights each boot, no state restoration
|
||||
iml = new IML(N_INPUTS, N_OUTPUTS, [32, 48, 64], 1000, 1.0, 0.00001);
|
||||
// IML — WASM-backed, fresh random weights each boot
|
||||
iml = await WasmIML.create(N_INPUTS, N_OUTPUTS, [32, 48, 64], 1000, 1.0, 0.00001);
|
||||
iml.setLogger(msg => console.log('[NISPS]', msg));
|
||||
|
||||
// Canvas + Visualizer
|
||||
|
|
@ -1157,17 +1157,9 @@ function onAddExample() {
|
|||
}
|
||||
|
||||
function onTrain() {
|
||||
const loss = trainModel();
|
||||
if (loss !== null) {
|
||||
const outputs = iml.getOutputs();
|
||||
routeOutputs(outputs);
|
||||
updateHeatmap(outputs);
|
||||
syncRawParamsFromOutputs(outputs);
|
||||
updateStatus();
|
||||
drawLossPlot();
|
||||
drawJoyMap();
|
||||
flash('btn-train');
|
||||
}
|
||||
if (iml.isTraining) return;
|
||||
flash('btn-train');
|
||||
trainModelAsync();
|
||||
}
|
||||
|
||||
function onRandomize() {
|
||||
|
|
@ -1203,24 +1195,18 @@ function onClear() {
|
|||
|
||||
// ---- RL mode ----
|
||||
function onThumbsUp() {
|
||||
if (iml.isTraining) return;
|
||||
|
||||
const inputs = [joyX, joyY];
|
||||
const outputs = [...iml.getOutputs()];
|
||||
iml.addExample(inputs, outputs);
|
||||
|
||||
trainModel();
|
||||
const trainedOutputs = iml.getOutputs();
|
||||
routeOutputs(trainedOutputs);
|
||||
updateHeatmap(trainedOutputs);
|
||||
syncRawParamsFromOutputs(trainedOutputs);
|
||||
|
||||
noiseLevel *= rlExplorationDecay;
|
||||
noiseLevel = Math.max(noiseLevel, 0.005);
|
||||
|
||||
updateStatus();
|
||||
drawLossPlot();
|
||||
drawJoyMap();
|
||||
updateNoiseRing();
|
||||
flash('btn-thumbsup');
|
||||
updateNoiseRing();
|
||||
trainModelAsync();
|
||||
}
|
||||
|
||||
function onThumbsDown() {
|
||||
|
|
@ -1239,14 +1225,22 @@ function onThumbsDown() {
|
|||
}
|
||||
|
||||
// ---- Training ----
|
||||
// Sync — used for preset loading and state restore
|
||||
function trainModel() {
|
||||
const loss = iml.train({
|
||||
onIteration: (iter, iterLoss) => {
|
||||
if (iter % 8 !== 0) return;
|
||||
drawLossPlot();
|
||||
},
|
||||
return iml.train();
|
||||
}
|
||||
|
||||
// Async — used for interactive training (thumbs-up, train button)
|
||||
function trainModelAsync(onDone) {
|
||||
iml.trainAsync(({ loss, outputs }) => {
|
||||
routeOutputs(outputs);
|
||||
updateHeatmap(outputs);
|
||||
syncRawParamsFromOutputs(outputs);
|
||||
updateStatus();
|
||||
drawLossPlot();
|
||||
drawJoyMap();
|
||||
if (onDone) onDone();
|
||||
});
|
||||
return loss;
|
||||
}
|
||||
|
||||
// ---- Presets ----
|
||||
|
|
|
|||
122
playground/js/nisps/nisps-wasm-worker.js
Normal file
122
playground/js/nisps/nisps-wasm-worker.js
Normal 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 },
|
||||
});
|
||||
}
|
||||
};
|
||||
405
playground/js/nisps/nisps-wasm.js
Normal file
405
playground/js/nisps/nisps-wasm.js
Normal 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
42
playground/wasm/build.sh
Executable 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
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
BIN
playground/wasm/nisps.wasm
Executable file
Binary file not shown.
179
playground/wasm/nisps_bindings.cpp
Normal file
179
playground/wasm/nisps_bindings.cpp
Normal 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"
|
||||
Loading…
Reference in a new issue