From e3b94d644fbf08144806c0bab5e957d7197bb864 Mon Sep 17 00:00:00 2001 From: w1n5t0n Date: Tue, 24 Mar 2026 01:57:59 +0200 Subject: [PATCH] feat(shapeseq): implement Layer 0 foundation modules Five independent modules with no interdependencies: - event-bus.js: namespaced pub/sub (seq.*/ml.*/ui.*) with wildcard subscriptions and automatic AudioContext/performance.now timestamps - prng.js: seedable mulberry32 PRNG with pure-functional API, fork() for independent per-primitive streams, serializable state for freeze - pattern.js: symbolic pattern description data structure with create/clone/merge/validate, additive and multiplicative merge modes - param-map.js: maps fixed 16-output MLP to variable-count primitive params via linear interpolation, with optional per-param min/max scaling - seq-iml.js: factory for second WasmIML instance (2 inputs, 16 outputs, [16,16,16] hidden layers) following existing imlJoy/imlHand pattern All modules are ES modules with no build step. PRNG, pattern, and param-map are port-ready (explicit state, typed arrays, no closures). --- playground/js/shapeseq/event-bus.js | 117 +++++++++++++++ playground/js/shapeseq/param-map.js | 94 ++++++++++++ playground/js/shapeseq/pattern.js | 214 ++++++++++++++++++++++++++++ playground/js/shapeseq/prng.js | 137 ++++++++++++++++++ playground/js/shapeseq/seq-iml.js | 76 ++++++++++ 5 files changed, 638 insertions(+) create mode 100644 playground/js/shapeseq/event-bus.js create mode 100644 playground/js/shapeseq/param-map.js create mode 100644 playground/js/shapeseq/pattern.js create mode 100644 playground/js/shapeseq/prng.js create mode 100644 playground/js/shapeseq/seq-iml.js diff --git a/playground/js/shapeseq/event-bus.js b/playground/js/shapeseq/event-bus.js new file mode 100644 index 0000000..eaf22ab --- /dev/null +++ b/playground/js/shapeseq/event-bus.js @@ -0,0 +1,117 @@ +// ShapeSeq Namespaced Event Bus +// Lightweight pub/sub with seq.*/ml.*/ui.* namespaces and wildcard support. + +// ── Event name constants ──────────────────────────────────────────── + +export const SEQ = Object.freeze({ + STEP: 'seq.step', + NOTE_ON: 'seq.noteOn', + NOTE_OFF: 'seq.noteOff', + PARAM_CHANGE: 'seq.paramChange', + LOOP_START: 'seq.loopStart', +}); + +export const ML = Object.freeze({ + TRAINED: 'ml.trained', + FROZEN: 'ml.frozen', + UNFROZEN: 'ml.unfrozen', + DELTA_UPDATE: 'ml.deltaUpdate', +}); + +export const UI = Object.freeze({ + PARAM_SELECT: 'ui.paramSelect', + CHAIN_EDIT: 'ui.chainEdit', + PRESET_LOAD: 'ui.presetLoad', + FREEZE_TOGGLE: 'ui.freezeToggle', +}); + +// ── Bus implementation ────────────────────────────────────────────── + +export class EventBus { + /** @param {AudioContext} [audioCtx] - optional, used for seq.* timestamps */ + constructor(audioCtx) { + this._listeners = new Map(); // event -> Set + this._wildcards = new Map(); // namespace prefix (e.g. "seq") -> Set + this._audioCtx = audioCtx ?? null; + } + + /** Provide or replace the AudioContext used for seq.* timestamps. */ + setAudioContext(ctx) { + this._audioCtx = ctx; + } + + /** + * Subscribe to an event or a wildcard namespace. + * on('seq.step', cb) — exact match + * on('seq.*', cb) — all events in the seq namespace + */ + on(event, callback) { + if (event.endsWith('.*')) { + const ns = event.slice(0, -2); // "seq.*" -> "seq" + if (!this._wildcards.has(ns)) this._wildcards.set(ns, new Set()); + this._wildcards.get(ns).add(callback); + } else { + if (!this._listeners.has(event)) this._listeners.set(event, new Set()); + this._listeners.get(event).add(callback); + } + } + + /** Unsubscribe. Mirrors the on() signature. */ + off(event, callback) { + if (event.endsWith('.*')) { + const ns = event.slice(0, -2); + const set = this._wildcards.get(ns); + if (set) { + set.delete(callback); + if (set.size === 0) this._wildcards.delete(ns); + } + } else { + const set = this._listeners.get(event); + if (set) { + set.delete(callback); + if (set.size === 0) this._listeners.delete(event); + } + } + } + + /** + * Emit an event. + * A `timestamp` field is added automatically: + * - seq.* events use AudioContext.currentTime (seconds) + * - all others use performance.now() (milliseconds) + */ + emit(event, data = {}) { + const ns = event.split('.')[0]; + const stamped = Object.assign({ timestamp: this._stamp(ns) }, data); + + // Exact listeners + const exact = this._listeners.get(event); + if (exact) { + for (const cb of exact) cb(stamped, event); + } + + // Wildcard listeners for this namespace + const wild = this._wildcards.get(ns); + if (wild) { + for (const cb of wild) cb(stamped, event); + } + } + + // ── private ── + + _stamp(ns) { + if (ns === 'seq' && this._audioCtx) return this._audioCtx.currentTime; + return performance.now(); + } +} + +// ── Singleton convenience ─────────────────────────────────────────── + +let _default = null; + +/** Return (and lazily create) the shared default bus. */ +export function getDefaultBus(audioCtx) { + if (!_default) _default = new EventBus(audioCtx); + else if (audioCtx) _default.setAudioContext(audioCtx); + return _default; +} diff --git a/playground/js/shapeseq/param-map.js b/playground/js/shapeseq/param-map.js new file mode 100644 index 0000000..f82cd2d --- /dev/null +++ b/playground/js/shapeseq/param-map.js @@ -0,0 +1,94 @@ +// ShapeSeq param mapping layer +// Maps fixed-size MLP outputs (16 values [0,1]) to variable-count primitive params +// +// The sequence MLP always outputs a fixed number of values (default 16). +// The primitive chain has a variable number of params depending on which +// primitives are active. This module bridges the two via automatic distribution: +// - N <= mlpOutputCount: each param gets one dedicated output +// - N > mlpOutputCount: outputs distributed via linear interpolation +// +// Port-ready: Float32Array, no closures, pure functions. + +/** + * Create a param mapper instance for a given MLP output count. + * @param {number} mlpOutputCount - number of MLP outputs (e.g. 16) + * @returns {{ map: Function, mapWithSchema: Function, mlpOutputCount: number }} + */ +export function createParamMap(mlpOutputCount) { + if (!Number.isInteger(mlpOutputCount) || mlpOutputCount < 1) { + throw new Error('mlpOutputCount must be a positive integer'); + } + + return { + mlpOutputCount, + map, + mapWithSchema, + }; +} + +/** + * Map MLP outputs to N primitive params via automatic distribution. + * + * If paramCount <= mlpOutputCount, each param gets one dedicated output + * (first paramCount outputs used, rest ignored). + * + * If paramCount > mlpOutputCount, outputs are distributed via linear + * interpolation so that the first param maps to the first output and the + * last param maps to the last output, with intermediate params interpolated. + * + * @param {Float32Array|number[]} mlpOutputs - MLP output values [0,1] + * @param {number} paramCount - number of primitive params to produce + * @returns {Float32Array} mapped values [0,1], length = paramCount + */ +export function map(mlpOutputs, paramCount) { + const mlpCount = mlpOutputs.length; + const result = new Float32Array(paramCount); + + if (paramCount === 0) return result; + + if (paramCount <= mlpCount) { + // Direct mapping: each param gets one dedicated output + for (let i = 0; i < paramCount; i++) { + result[i] = mlpOutputs[i]; + } + } else { + // Interpolated mapping: spread mlpCount outputs across paramCount params + // param[i] maps to a fractional position in the output array + // param[0] -> output[0], param[paramCount-1] -> output[mlpCount-1] + const scale = paramCount > 1 ? (mlpCount - 1) / (paramCount - 1) : 0; + + for (let i = 0; i < paramCount; i++) { + const pos = i * scale; + const lo = pos | 0; // floor + const hi = lo + 1 < mlpCount ? lo + 1 : lo; + const frac = pos - lo; + result[i] = mlpOutputs[lo] + (mlpOutputs[hi] - mlpOutputs[lo]) * frac; + } + } + + return result; +} + +/** + * Map MLP outputs to primitive params and apply per-param min/max scaling. + * + * Each param schema defines { min, max } (both [0,1]). The mapped [0,1] + * value is scaled into [min, max] for each param. + * + * @param {Float32Array|number[]} mlpOutputs - MLP output values [0,1] + * @param {Array<{ min: number, max: number }>} paramSchemas - per-param range definitions + * @returns {Float32Array} scaled values, length = paramSchemas.length + */ +export function mapWithSchema(mlpOutputs, paramSchemas) { + const paramCount = paramSchemas.length; + const mapped = map(mlpOutputs, paramCount); + + for (let i = 0; i < paramCount; i++) { + const schema = paramSchemas[i]; + const min = schema.min !== undefined ? schema.min : 0; + const max = schema.max !== undefined ? schema.max : 1; + mapped[i] = min + mapped[i] * (max - min); + } + + return mapped; +} diff --git a/playground/js/shapeseq/pattern.js b/playground/js/shapeseq/pattern.js new file mode 100644 index 0000000..48415b5 --- /dev/null +++ b/playground/js/shapeseq/pattern.js @@ -0,0 +1,214 @@ +/** + * ShapeSeq Pattern Description Data Structure + * + * The foundational data type for the ShapeSeq sequencing system. + * A pattern description is the symbolic output of the primitive chain -- + * a complete loop description that the clock engine steps through. + * + * Port-ready: typed arrays where possible, explicit construction, no closures. + * + * @module shapeseq/pattern + */ + +// --- Step defaults --- + +const DEFAULT_TRIGGER = false; +const DEFAULT_PITCH = 0.5; +const DEFAULT_VELOCITY = 0.7; +const DEFAULT_ACCENT = false; +const DEFAULT_TIME_OFFSET = 0.0; +const DEFAULT_SUBDIVISIONS = 1; + +/** + * Create a single step with default values. + * + * @returns {{ trigger: boolean, pitch: number, velocity: number, accent: boolean, timeOffset: number, subdivisions: number }} + */ +export function createStep() { + return { + trigger: DEFAULT_TRIGGER, + pitch: DEFAULT_PITCH, + velocity: DEFAULT_VELOCITY, + accent: DEFAULT_ACCENT, + timeOffset: DEFAULT_TIME_OFFSET, + subdivisions: DEFAULT_SUBDIVISIONS, + }; +} + +/** + * Create a pattern description with the given step count, all steps at defaults. + * + * @param {number} stepCount - Number of steps (must be positive integer) + * @returns {{ steps: Array, stepCount: number, metadata: Object }} + */ +export function createPattern(stepCount) { + const count = stepCount | 0; // coerce to int + if (count < 1) { + throw new RangeError('stepCount must be >= 1, got ' + stepCount); + } + + const steps = new Array(count); + for (let i = 0; i < count; i++) { + steps[i] = createStep(); + } + + return { + steps: steps, + stepCount: count, + metadata: {}, + }; +} + +/** + * Deep clone a pattern description. + * Needed for chain processing where each primitive transforms a copy. + * + * @param {{ steps: Array, stepCount: number, metadata: Object }} pattern + * @returns {{ steps: Array, stepCount: number, metadata: Object }} + */ +export function clonePattern(pattern) { + const count = pattern.stepCount; + const srcSteps = pattern.steps; + const steps = new Array(count); + + for (let i = 0; i < count; i++) { + const s = srcSteps[i]; + steps[i] = { + trigger: s.trigger, + pitch: s.pitch, + velocity: s.velocity, + accent: s.accent, + timeOffset: s.timeOffset, + subdivisions: s.subdivisions, + }; + } + + // Shallow clone metadata (one level deep for plain-object metadata) + const srcMeta = pattern.metadata; + const metadata = {}; + const keys = Object.keys(srcMeta); + for (let i = 0; i < keys.length; i++) { + metadata[keys[i]] = srcMeta[keys[i]]; + } + + return { + steps: steps, + stepCount: count, + metadata: metadata, + }; +} + +/** + * Merge two patterns together. + * + * Both patterns must have the same stepCount. + * Returns a new pattern (does not mutate inputs). + * + * @param {{ steps: Array, stepCount: number, metadata: Object }} patternA + * @param {{ steps: Array, stepCount: number, metadata: Object }} patternB + * @param {'additive'|'multiplicative'} mode + * - 'additive': triggers OR'd, pitch/velocity averaged + * - 'multiplicative': triggers AND'd, pitch/velocity averaged + * @returns {{ steps: Array, stepCount: number, metadata: Object }} + */ +export function mergePatterns(patternA, patternB, mode) { + if (patternA.stepCount !== patternB.stepCount) { + throw new RangeError( + 'Cannot merge patterns with different step counts: ' + + patternA.stepCount + ' vs ' + patternB.stepCount + ); + } + if (mode !== 'additive' && mode !== 'multiplicative') { + throw new TypeError("mode must be 'additive' or 'multiplicative', got '" + mode + "'"); + } + + const count = patternA.stepCount; + const stepsA = patternA.steps; + const stepsB = patternB.steps; + const steps = new Array(count); + const isAdditive = mode === 'additive'; + + for (let i = 0; i < count; i++) { + const a = stepsA[i]; + const b = stepsB[i]; + + const trigger = isAdditive ? (a.trigger || b.trigger) : (a.trigger && b.trigger); + + steps[i] = { + trigger: trigger, + pitch: (a.pitch + b.pitch) * 0.5, + velocity: (a.velocity + b.velocity) * 0.5, + accent: isAdditive ? (a.accent || b.accent) : (a.accent && b.accent), + timeOffset: (a.timeOffset + b.timeOffset) * 0.5, + subdivisions: Math.max(a.subdivisions, b.subdivisions), + }; + } + + return { + steps: steps, + stepCount: count, + metadata: {}, + }; +} + +/** + * Update a specific step in a pattern (mutates the pattern in place). + * + * Only the fields present in stepData are updated; others remain unchanged. + * + * @param {{ steps: Array, stepCount: number, metadata: Object }} pattern + * @param {number} index - Step index (0-based) + * @param {Object} stepData - Partial step data to apply + */ +export function setStep(pattern, index, stepData) { + const idx = index | 0; + if (idx < 0 || idx >= pattern.stepCount) { + throw new RangeError('Step index ' + index + ' out of range [0, ' + (pattern.stepCount - 1) + ']'); + } + + const step = pattern.steps[idx]; + + if (stepData.trigger !== undefined) step.trigger = !!stepData.trigger; + if (stepData.pitch !== undefined) step.pitch = +stepData.pitch; + if (stepData.velocity !== undefined) step.velocity = +stepData.velocity; + if (stepData.accent !== undefined) step.accent = !!stepData.accent; + if (stepData.timeOffset !== undefined) step.timeOffset = +stepData.timeOffset; + if (stepData.subdivisions !== undefined) step.subdivisions = stepData.subdivisions | 0; +} + +/** + * Validate a pattern description structure. + * + * Checks: + * - pattern is an object with steps array, stepCount int, metadata object + * - steps.length === stepCount + * - Each step has all required fields with correct types and in-range values + * + * @param {*} pattern + * @returns {boolean} + */ +export function validatePattern(pattern) { + if (pattern == null || typeof pattern !== 'object') return false; + if (typeof pattern.stepCount !== 'number' || (pattern.stepCount | 0) < 1) return false; + if (pattern.stepCount !== (pattern.stepCount | 0)) return false; + if (!Array.isArray(pattern.steps)) return false; + if (pattern.steps.length !== pattern.stepCount) return false; + if (pattern.metadata == null || typeof pattern.metadata !== 'object') return false; + + const steps = pattern.steps; + const count = pattern.stepCount; + + for (let i = 0; i < count; i++) { + const s = steps[i]; + if (s == null || typeof s !== 'object') return false; + if (typeof s.trigger !== 'boolean') return false; + if (typeof s.pitch !== 'number' || s.pitch < 0 || s.pitch > 1) return false; + if (typeof s.velocity !== 'number' || s.velocity < 0 || s.velocity > 1) return false; + if (typeof s.accent !== 'boolean') return false; + if (typeof s.timeOffset !== 'number' || s.timeOffset < -0.5 || s.timeOffset > 0.5) return false; + if (typeof s.subdivisions !== 'number' || (s.subdivisions | 0) < 1 || (s.subdivisions | 0) > 4) return false; + if (s.subdivisions !== (s.subdivisions | 0)) return false; + } + + return true; +} diff --git a/playground/js/shapeseq/prng.js b/playground/js/shapeseq/prng.js new file mode 100644 index 0000000..b4adaf3 --- /dev/null +++ b/playground/js/shapeseq/prng.js @@ -0,0 +1,137 @@ +/** + * ShapeSeq PRNG — seedable pseudo-random number generator (mulberry32) + * + * Port-ready: explicit state as plain objects, no closures in hot path, + * all state is serializable. Pure-functional next/nextInt/fork — no mutation. + * + * @module shapeseq/prng + */ + +// --- Mulberry32 core --- + +/** + * Single step of the mulberry32 PRNG. + * Pure function: takes a 32-bit state, returns { value, nextState }. + * + * @param {number} state - unsigned 32-bit integer + * @returns {{ value: number, nextState: number }} + * value: float in [0, 1) + * nextState: next 32-bit state + */ +function mulberry32Step(state) { + let t = (state + 0x6D2B79F5) | 0; + const nextState = t >>> 0; // the incremented state IS the new state + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + const raw = ((t ^ (t >>> 14)) >>> 0); + return { value: raw / 0x100000000, nextState }; +} + +// --- Public API --- + +/** + * Create a PRNG instance from a seed. + * + * @param {number} seed - any 32-bit integer (will be coerced to unsigned) + * @returns {{ state: number }} serializable PRNG state object + */ +export function createPRNG(seed) { + return { state: (seed >>> 0) }; +} + +/** + * Produce the next random float in [0, 1). Pure — does not mutate input. + * + * @param {{ state: number }} prng + * @returns {{ value: number, nextState: { state: number } }} + */ +export function next(prng) { + const { value, nextState } = mulberry32Step(prng.state); + return { value, nextState: { state: nextState } }; +} + +/** + * Produce a random integer in [min, max] (inclusive). Pure — does not mutate input. + * + * @param {{ state: number }} prng + * @param {number} min - inclusive lower bound (integer) + * @param {number} max - inclusive upper bound (integer) + * @returns {{ value: number, nextState: { state: number } }} + */ +export function nextInt(prng, min, max) { + const { value, nextState } = next(prng); + const range = max - min + 1; + const intVal = min + Math.floor(value * range); + return { value: intVal, nextState }; +} + +/** + * Derive an independent PRNG stream from the current state + an id. + * Used to give each primitive in a chain its own deterministic stream. + * + * The child seed is produced by hashing the current state with the id + * using a simple but effective mixing function (splitmix-style). + * + * @param {{ state: number }} prng + * @param {number|string} id - primitive position index or string identifier + * @returns {{ state: number }} a new, independent PRNG state + */ +export function fork(prng, id) { + // Convert string ids to a numeric hash + let idNum; + if (typeof id === 'string') { + idNum = 0; + for (let i = 0; i < id.length; i++) { + idNum = ((idNum << 5) - idNum + id.charCodeAt(i)) | 0; + } + idNum = idNum >>> 0; + } else { + idNum = id >>> 0; + } + + // Mix state + id using splitmix-style finalizer + let h = (prng.state ^ idNum) >>> 0; + h = (h + 0x9E3779B9) >>> 0; + h = Math.imul(h ^ (h >>> 16), 0x85EBCA6B) >>> 0; + h = Math.imul(h ^ (h >>> 13), 0xC2B2AE35) >>> 0; + h = (h ^ (h >>> 16)) >>> 0; + + return { state: h }; +} + +/** + * Get serializable state from a PRNG instance (for freeze/restore). + * + * @param {{ state: number }} prng + * @returns {{ state: number }} + */ +export function getState(prng) { + return { state: prng.state }; +} + +/** + * Restore a PRNG instance from previously saved state. + * + * @param {{ state: number }} _prng - ignored (stateless restore) + * @param {{ state: number }} savedState + * @returns {{ state: number }} + */ +export function setState(_prng, savedState) { + return { state: savedState.state >>> 0 }; +} + +/** + * Generate a cryptographically random seed for initial seeding. + * Uses crypto.getRandomValues when available, falls back to Date.now(). + * + * @returns {number} unsigned 32-bit integer + */ +export function randomSeed() { + if (typeof crypto !== 'undefined' && crypto.getRandomValues) { + const buf = new Uint32Array(1); + crypto.getRandomValues(buf); + return buf[0]; + } + // Fallback: not cryptographic, but sufficient for musical randomness + return (Date.now() * 2654435761) >>> 0; +} diff --git a/playground/js/shapeseq/seq-iml.js b/playground/js/shapeseq/seq-iml.js new file mode 100644 index 0000000..3a66815 --- /dev/null +++ b/playground/js/shapeseq/seq-iml.js @@ -0,0 +1,76 @@ +// ShapeSeq — Sequence NISPS Instance +// +// Factory for creating a second WasmIML instance dedicated to sequence control. +// The MLP has a fixed 16-element output which a downstream param mapping layer +// (not in this module) fans out to however many primitive params the current +// chain requires. +// +// Input routing is configurable and wired externally (default: hand tracking +// features 0+1). This module only creates the IML instance — it does not +// subscribe to any input source. The integration layer (see meml-9tf) is +// responsible for calling setInputs() with routed values each frame. +// +// Usage: +// import { createSequenceIML } from './shapeseq/seq-iml.js'; +// const seqIML = await createSequenceIML(); +// // Per frame: +// seqIML.setInputs([x, y]); +// seqIML.process(); +// const params16 = seqIML.getOutputs(); + +import { WasmIML } from '../nisps/nisps-wasm.js'; + +// Fixed architecture for the sequence MLP. +// 2 inputs (routed externally), 16 outputs (fixed — param mapping fans out). +// Smaller network than the synth MLP ([3,32,48,64,126]): +// three hidden layers of 16 neurons keeps inference cheap while providing +// enough capacity for 16 continuous outputs. +const SEQ_N_INPUTS = 2; +const SEQ_N_OUTPUTS = 16; +const SEQ_HIDDEN_LAYERS = [16, 16, 16]; + +// Training hyperparameters — same defaults as the synth IML +const SEQ_MAX_ITERATIONS = 1000; +const SEQ_LEARNING_RATE = 1.0; +const SEQ_CONVERGENCE_THRESHOLD = 0.00001; + +/** + * Create a WasmIML instance configured for sequence control. + * + * Follows the same creation pattern as imlJoy / imlHand in a-app.js: + * const iml = await WasmIML.create(nInputs, nOutputs, hiddenLayers, ...); + * + * The returned object is a standard WasmIML instance with the full interface: + * setInput / setInputs, getOutputs, process, + * addExample, clearDataset, train, trainAsync, + * randomiseWeights (drawWeights), moveWeights, + * exampleCount, destroy, etc. + * + * Additionally exposes SEQ_N_INPUTS, SEQ_N_OUTPUTS, SEQ_HIDDEN_LAYERS as + * properties on the returned object for introspection by downstream code. + * + * @returns {Promise} — the sequence IML instance (augmented with + * .SEQ_N_INPUTS, .SEQ_N_OUTPUTS, .SEQ_HIDDEN_LAYERS) + */ +export async function createSequenceIML() { + const seqIML = await WasmIML.create( + SEQ_N_INPUTS, + SEQ_N_OUTPUTS, + SEQ_HIDDEN_LAYERS, + SEQ_MAX_ITERATIONS, + SEQ_LEARNING_RATE, + SEQ_CONVERGENCE_THRESHOLD + ); + + seqIML.setLogger(msg => console.log('[NISPS:seq]', msg)); + + // Attach architecture metadata for introspection + seqIML.SEQ_N_INPUTS = SEQ_N_INPUTS; + seqIML.SEQ_N_OUTPUTS = SEQ_N_OUTPUTS; + seqIML.SEQ_HIDDEN_LAYERS = SEQ_HIDDEN_LAYERS; + + return seqIML; +} + +// Re-export constants for use by other modules (e.g., param mapping layer) +export { SEQ_N_INPUTS, SEQ_N_OUTPUTS, SEQ_HIDDEN_LAYERS };