Stream 7 wires nisps/ml + nisps/engines into the playground via Emscripten.
Highlights:
- nisps/wasm/bindings.cpp: flat C API per architecture.md §6.2. Fixed-arch
MLP<2, 10, 14, 18, 126>; engine string→type dispatch table with NoOp
fallback.
- scripts/build-wasm.sh: emcc invocation, MODULARIZE=1, exports listed
explicitly; produces playground/public/nisps.{js,wasm}.
- playground/src/ml/wasm-iml.ts: main-thread MLP host (sync inference,
sync training, RL ops, weights I/O, layer stats, localStorage).
- playground/src/ml/wasm-worker.ts: disposable Web Worker for off-thread
async training, owns its own WASM instance.
- playground/src/ml/dataset.ts: Float32Array-backed FIFO with sample-weight
modes (uniform/global/local/combined). Port of legacy dataset.js.
- playground/src/audio/engine-host.ts: AudioContext + AudioWorkletNode
lifecycle, with start/stop/setEngine/setParams.
- playground/src/audio/worklet/nisps-processor.ts: WASM-loading
AudioWorkletProcessor that runs engine.process_block per 128-sample
block. Loads its own WASM instance from main-thread-supplied bytes
(no fetch in worklet).
- playground/src/stores/ml-store.ts: wired stub methods to WasmIML
singleton; lazy initialize().
- playground/src/debug/probe.ts: window.__nisps now calls real WasmIML
via the store; lazy-init on first use.
Verified:
- bash scripts/build-wasm.sh succeeds (94 KB nisps.wasm)
- bun run typecheck OK
- bun run build OK (production bundle)
- vite dev server serves /nisps.{js,wasm} with COOP/COEP
Known limitation: WASM is fixed at one MLP shape. Multi-arch deferred —
documented in nisps/wasm/README.md.
174 lines
4.9 KiB
TypeScript
174 lines
4.9 KiB
TypeScript
/**
|
|
* Debug probe: window.__nisps
|
|
*
|
|
* Stream 7 wires this to the real WasmIML via mlStore. Methods are
|
|
* synchronous (or return immediately-resolved promises). The probe
|
|
* deliberately bypasses Solid reactivity so tests get deterministic,
|
|
* imperative semantics.
|
|
*
|
|
* The probe self-initialises the ML engine on first use that needs it
|
|
* — Playwright tests can `await window.__nisps.__init()` before driving
|
|
* inference, or just call methods and tolerate a few no-ops while the
|
|
* lazy init resolves. While the init is in flight, `__ready` is false;
|
|
* synchronous methods that need ML are best-effort no-ops.
|
|
*/
|
|
|
|
import { mlStore } from '../stores/ml-store';
|
|
|
|
export interface DebugProbe {
|
|
/** Current 126-element output vector (Float32Array). */
|
|
getOutputs(): Float32Array;
|
|
/** Last training loss, or null if no training has occurred. */
|
|
getLoss(): number | null;
|
|
/** Flat weight array. */
|
|
getWeights(): Float32Array;
|
|
/** Number of training examples currently in the dataset. */
|
|
getExampleCount(): number;
|
|
/** Set joystick X/Y in [0,1] and run inference. */
|
|
setInputs(x: number, y: number): void;
|
|
/** Trigger thumbs-up RL feedback. */
|
|
thumbsUp(): void;
|
|
/** Trigger thumbs-down RL feedback. */
|
|
thumbsDown(): void;
|
|
/** Synchronous training; returns final loss. */
|
|
train(): number;
|
|
/** Async training; returns Promise<loss>. */
|
|
trainAsync(): Promise<number>;
|
|
/** Randomize weights with default spread. */
|
|
randomise(): void;
|
|
/** Clear all training examples. */
|
|
clearExamples(): void;
|
|
/** Force a save to localStorage now (no debounce). */
|
|
saveState(): void;
|
|
/** Non-destructive loss query against current dataset. */
|
|
evalLoss(): number | null;
|
|
/** Batch inference: input is Nx2 array of [x,y] pairs. */
|
|
inferBatch(points: ReadonlyArray<readonly [number, number]>): Float32Array;
|
|
/** Per-layer weight statistics: layerCount * 4 floats (mean|w|, max|w|, dead%, sat%). */
|
|
getLayerStats(): Float32Array;
|
|
/** True once the WASM is fully initialised. */
|
|
readonly __ready: boolean;
|
|
/** Force initialisation. Returns a promise that resolves when the WASM is ready. */
|
|
__init(): Promise<void>;
|
|
}
|
|
|
|
declare global {
|
|
interface Window {
|
|
__nisps?: DebugProbe;
|
|
}
|
|
}
|
|
|
|
const EMPTY_F32 = new Float32Array(0);
|
|
|
|
// We auto-initialise lazily so a test that immediately calls `.train()`
|
|
// after page load doesn't silently no-op. The promise is shared across
|
|
// calls so we don't kick off two simultaneous loads.
|
|
let lazyInitPromise: Promise<void> | null = null;
|
|
function lazyInit(): Promise<void> {
|
|
if (mlStore.iml) return Promise.resolve();
|
|
if (!lazyInitPromise) {
|
|
lazyInitPromise = mlStore.initialize().then(() => undefined);
|
|
}
|
|
return lazyInitPromise;
|
|
}
|
|
|
|
const probe: DebugProbe = {
|
|
get __ready(): boolean {
|
|
return !!mlStore.iml && mlStore.state.ready;
|
|
},
|
|
|
|
__init(): Promise<void> {
|
|
return lazyInit();
|
|
},
|
|
|
|
getOutputs(): Float32Array {
|
|
return mlStore.outputs();
|
|
},
|
|
|
|
getLoss(): number | null {
|
|
return mlStore.state.lastLoss;
|
|
},
|
|
|
|
getWeights(): Float32Array {
|
|
return mlStore.getWeights();
|
|
},
|
|
|
|
getExampleCount(): number {
|
|
return mlStore.state.exampleCount;
|
|
},
|
|
|
|
setInputs(x: number, y: number): void {
|
|
if (!mlStore.iml) {
|
|
void lazyInit();
|
|
return;
|
|
}
|
|
mlStore.iml.inferXY(x, y);
|
|
},
|
|
|
|
thumbsUp(): void {
|
|
if (!mlStore.iml) return;
|
|
// Stream 10 will replace this with the full RL controller; the
|
|
// legacy probe behaviour is "train, then settle". For now we run
|
|
// a sync training step.
|
|
mlStore.iml.train();
|
|
},
|
|
|
|
thumbsDown(): void {
|
|
if (!mlStore.iml) return;
|
|
// Default RL noise burst at the playground's typical spread. Stream
|
|
// 10 will hook the noise cap from the control surface state.
|
|
mlStore.iml.moveWeights(0.1, 0.6);
|
|
},
|
|
|
|
train(): number {
|
|
if (!mlStore.iml) {
|
|
void lazyInit();
|
|
return 0;
|
|
}
|
|
return mlStore.iml.train();
|
|
},
|
|
|
|
async trainAsync(): Promise<number> {
|
|
await lazyInit();
|
|
if (!mlStore.iml) return 0;
|
|
return mlStore.iml.trainAsync();
|
|
},
|
|
|
|
randomise(): void {
|
|
if (!mlStore.iml) return;
|
|
mlStore.iml.randomiseWeights(0.6);
|
|
},
|
|
|
|
clearExamples(): void {
|
|
mlStore.clearExamples();
|
|
},
|
|
|
|
saveState(): void {
|
|
mlStore.saveNow();
|
|
},
|
|
|
|
evalLoss(): number | null {
|
|
if (!mlStore.iml) return null;
|
|
return mlStore.iml.evalLoss();
|
|
},
|
|
|
|
inferBatch(points: ReadonlyArray<readonly [number, number]>): Float32Array {
|
|
if (!mlStore.iml) return new Float32Array(points.length * mlStore.state.outputSize);
|
|
return mlStore.iml.inferBatch(points);
|
|
},
|
|
|
|
getLayerStats(): Float32Array {
|
|
if (!mlStore.iml) return EMPTY_F32;
|
|
return mlStore.iml.getLayerStatsFlat();
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Install the probe on window. Idempotent — the probe object is a
|
|
* singleton, so capturing `window.__nisps` once is safe across hot
|
|
* reloads and re-installs.
|
|
*/
|
|
export function installDebugProbe(): void {
|
|
if (typeof window === 'undefined') return;
|
|
window.__nisps = probe;
|
|
}
|