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).
This commit is contained in:
w1n5t0n 2026-03-24 01:57:59 +02:00
parent ddb6b77abb
commit e3b94d644f
5 changed files with 638 additions and 0 deletions

View file

@ -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<callback>
this._wildcards = new Map(); // namespace prefix (e.g. "seq") -> Set<callback>
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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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<WasmIML>} 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 };