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
|
// 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) {
|
flash('btn-train');
|
||||||
const outputs = iml.getOutputs();
|
trainModelAsync();
|
||||||
routeOutputs(outputs);
|
|
||||||
updateHeatmap(outputs);
|
|
||||||
syncRawParamsFromOutputs(outputs);
|
|
||||||
updateStatus();
|
|
||||||
drawLossPlot();
|
|
||||||
drawJoyMap();
|
|
||||||
flash('btn-train');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
|
||||||
drawLossPlot();
|
// 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 ----
|
// ---- 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