diff --git a/playground/js/shapeseq/sequencer.js b/playground/js/shapeseq/sequencer.js index dd1deff..5ac7341 100644 --- a/playground/js/shapeseq/sequencer.js +++ b/playground/js/shapeseq/sequencer.js @@ -6,8 +6,8 @@ * * Main loop (triggered by setSequenceInputs): * 1. Forward inputs to sequenceIML - * 2. Run MLP inference to get 16 outputs - * 3. Map 16 outputs to N primitive params via param-map + * 2. Run MLP inference to get N outputs (configurable, default 16) + * 3. Map N outputs to M primitive params via param-map * 4. Evaluate the chain to produce a pattern description * 5. Apply projection transforms * 6. Schedule the pattern on the clock @@ -66,6 +66,9 @@ export class ShapeSeqEngine { /** @private */ this._outputCount = SEQ_DEFAULT_OUTPUT_COUNT; /** @private */ this._stepCount = DEFAULT_STEP_COUNT; + /** @private */ this._masterSeed = DEFAULT_MASTER_SEED; + /** @private */ this._playing = false; + /** @private */ this._initialized = false; // MLP mode manager (unified vs dual) /** @private */ this._mlpMode = new MLPModeManager(); @@ -78,13 +81,14 @@ export class ShapeSeqEngine { // Last projected pattern — needed for freeze-as-pattern snapshot /** @private @type {Object|null} */ this._lastPattern = null; - /** @private */ this._masterSeed = DEFAULT_MASTER_SEED; - /** @private */ this._playing = false; - /** @private */ this._initialized = false; // Dirty-check: skip re-evaluation when inputs haven't changed /** @private */ this._lastInputs = [NaN, NaN]; + // Generation counter: bumped on config changes to force re-evaluation + /** @private */ this._generation = 0; + /** @private */ this._lastGeneration = -1; + // Voice mode: 'mono' (default) or 'poly' /** @private @type {'mono'|'poly'} */ this._voiceMode = 'mono'; @@ -93,10 +97,6 @@ export class ShapeSeqEngine { /** @private @type {{note: number, velocity: number}|null} */ this._lastNote = null; - // Generation counter: bumped on config changes to force re-evaluation - /** @private */ this._generation = 0; - /** @private */ this._lastGeneration = -1; - // Track active notes for orphan prevention /** @private @type {Set} */ this._activeNotes = new Set(); @@ -107,9 +107,9 @@ export class ShapeSeqEngine { /** @private */ this._onNoteOff = (data) => this._handleNoteOff(data); /** @private */ - this._onLoopStart = () => this._handleLoopStart(); - /** @private */ this._onChainEdit = () => this._bumpGeneration(); + /** @private */ + this._onLoopStart = () => this._handleLoopStart(); } // ── Lifecycle ────────────────────────────────────────────────────── @@ -276,6 +276,7 @@ export class ShapeSeqEngine { this._sequenceIML = await createSequenceIML({ outputCount: count }); this._sequenceIML.randomiseWeights(DEFAULT_SPREAD); + // TODO: bump generation counter (meml-06h) this._bumpGeneration(); } @@ -290,12 +291,6 @@ export class ShapeSeqEngine { /** @returns {WasmIML} */ getSequenceIML() { return this._sequenceIML; } - /** @returns {FreezeManager} */ - get freezeManager() { return this._freezeManager; } - - /** @returns {MLPModeManager} */ - get mlpMode() { return this._mlpMode; } - /** @returns {'mono'|'poly'} */ get voiceMode() { return this._voiceMode; } @@ -315,6 +310,12 @@ export class ShapeSeqEngine { this._voiceMode = mode; } + /** @returns {FreezeManager} */ + get freezeManager() { return this._freezeManager; } + + /** @returns {MLPModeManager} */ + get mlpMode() { return this._mlpMode; } + // ── MLP mode switching ──────────────────────────────────────────── /** @@ -457,6 +458,7 @@ export class ShapeSeqEngine { /** * Shared downstream pipeline: param mapping -> chain -> projection -> clock. + * Used by both setSequenceInputs (dual mode) and setSequenceOutputsFromTimbre (unified mode). * * @private * @param {Float32Array} mlpOutputs - raw MLP outputs (sequence slice) @@ -520,6 +522,9 @@ export class ShapeSeqEngine { * last known inputs. This lets stateful generators (e.g. PitchWalker) * produce evolving patterns across loops even when inputs stay still. * + * When freeze-as-algorithm is active, re-evaluation is suppressed + * to preserve the frozen state. + * * @private */ _handleLoopStart() { diff --git a/playground/js/shapeseq/tests/run.sh b/playground/js/shapeseq/tests/run.sh new file mode 100755 index 0000000..ccfacc5 --- /dev/null +++ b/playground/js/shapeseq/tests/run.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Run all shapeseq tests using Node's built-in test runner. +# Usage: bash playground/js/shapeseq/tests/run.sh + +set -euo pipefail + +DIR="$(cd "$(dirname "$0")" && pwd)" + +node --test "$DIR"/*.test.js diff --git a/playground/js/shapeseq/tests/smoke.test.js b/playground/js/shapeseq/tests/smoke.test.js new file mode 100644 index 0000000..2655878 --- /dev/null +++ b/playground/js/shapeseq/tests/smoke.test.js @@ -0,0 +1,253 @@ +/** + * Smoke tests for shapeseq modules. + * + * Validates that every module can be imported and that non-browser modules + * construct correctly. Browser-dependent modules are imported inside + * try/catch blocks so the suite stays green in a pure Node environment. + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +// ── Pure modules (no browser APIs required) ───────────────────────── + +import { + createStep, createPattern, clonePattern, + mergePatterns, setStep, validatePattern, +} from '../pattern.js'; + +import { Primitive, CATEGORIES, applyBoundary } from '../primitive.js'; + +import { + EuclideanRhythm, ProbabilityGate, PitchWalker, + Ratchet, SwingGroove, DensityMorph, IntervalLock, + VelocityShaper, PRIMITIVE_REGISTRY, +} from '../primitives.js'; + +import { + createPRNG, next, nextInt, fork, getState, setState, randomSeed, +} from '../prng.js'; + +import { Chain } from '../chain.js'; + +import { + SEQ, ML, UI, EventBus, +} from '../event-bus.js'; + +import { createParamMap, map, mapWithSchema } from '../param-map.js'; + +import { + createProjection, applyProjection, VELOCITY_CURVES, +} from '../projection.js'; + +// ── Tests: pattern ────────────────────────────────────────────────── + +describe('pattern', () => { + it('createStep returns an object', () => { + const step = createStep(); + assert.equal(typeof step, 'object'); + }); + + it('createPattern builds a pattern with the requested step count', () => { + const pat = createPattern(8); + assert.ok(pat); + assert.ok(validatePattern(pat)); + }); + + it('clonePattern produces an independent copy', () => { + const a = createPattern(4); + const b = clonePattern(a); + assert.deepStrictEqual(a, b); + assert.notEqual(a, b); + }); + + it('mergePatterns does not throw', () => { + const a = createPattern(4); + const b = createPattern(4); + assert.ok(mergePatterns(a, b, 'additive')); + }); +}); + +// ── Tests: prng ───────────────────────────────────────────────────── + +describe('prng', () => { + it('createPRNG returns a deterministic generator', () => { + const a = createPRNG(42); + const b = createPRNG(42); + assert.deepStrictEqual(next(a), next(b)); + }); + + it('nextInt returns values within range', () => { + const rng = createPRNG(1); + for (let i = 0; i < 50; i++) { + const result = nextInt(rng, 0, 10); + // nextInt may return a number or { value, nextState } + const v = typeof result === 'number' ? result : result.value; + assert.ok(v >= 0 && v <= 10, `${v} out of range`); + } + }); + + it('fork creates a child PRNG', () => { + const rng = createPRNG(7); + const child = fork(rng, 'sub'); + assert.ok(child); + const result = next(child); + assert.equal(typeof result.value, 'number'); + }); + + it('getState / setState round-trips', () => { + const rng = createPRNG(99); + next(rng); next(rng); + const snap = getState(rng); + const v1 = next(rng); + setState(rng, snap); + const v2 = next(rng); + assert.deepStrictEqual(v1, v2); + }); + + it('randomSeed returns a number', () => { + assert.equal(typeof randomSeed(), 'number'); + }); +}); + +// ── Tests: primitive ──────────────────────────────────────────────── + +describe('primitive', () => { + it('CATEGORIES is a frozen array', () => { + assert.ok(Array.isArray(CATEGORIES)); + assert.ok(Object.isFrozen(CATEGORIES)); + }); + + it('Primitive can be constructed with a schema', () => { + // Grab the first registered primitive's schema as a reference + const key = Object.keys(PRIMITIVE_REGISTRY)[0]; + const Cls = PRIMITIVE_REGISTRY[key]; + const inst = new Cls(); + assert.ok(inst instanceof Primitive); + }); +}); + +// ── Tests: primitives ─────────────────────────────────────────────── + +describe('primitives', () => { + it('PRIMITIVE_REGISTRY contains all expected primitives', () => { + const expected = [ + 'EuclideanRhythm', 'ProbabilityGate', 'PitchWalker', 'Ratchet', + 'SwingGroove', 'DensityMorph', 'IntervalLock', 'VelocityShaper', + ]; + for (const name of expected) { + assert.ok(name in PRIMITIVE_REGISTRY, `missing: ${name}`); + } + }); + + it('each registered primitive instantiates without error', () => { + for (const [name, Cls] of Object.entries(PRIMITIVE_REGISTRY)) { + const inst = new Cls(); + assert.ok(inst instanceof Primitive, `${name} is not a Primitive`); + } + }); +}); + +// ── Tests: chain ──────────────────────────────────────────────────── + +describe('chain', () => { + it('Chain constructs with defaults', () => { + const c = new Chain(); + assert.ok(c); + }); +}); + +// ── Tests: event-bus ──────────────────────────────────────────────── + +describe('event-bus', () => { + it('SEQ, ML, UI are frozen namespace objects', () => { + assert.ok(Object.isFrozen(SEQ)); + assert.ok(Object.isFrozen(ML)); + assert.ok(Object.isFrozen(UI)); + }); + + it('EventBus can be constructed without AudioContext', () => { + const bus = new EventBus(); + assert.ok(bus); + }); + + it('on/emit round-trip works', () => { + const bus = new EventBus(); + let received = null; + bus.on(SEQ.STEP, (data) => { received = data; }); + bus.emit(SEQ.STEP, { idx: 3 }); + assert.equal(received.idx, 3); + }); +}); + +// ── Tests: param-map ──────────────────────────────────────────────── + +describe('param-map', () => { + it('createParamMap returns an object', () => { + const pm = createParamMap(8); + assert.ok(pm); + }); + + it('map transforms an array of outputs', () => { + const outputs = new Array(8).fill(0.5); + const result = map(outputs, 4); + assert.equal(result.length, 4); + }); +}); + +// ── Tests: projection ─────────────────────────────────────────────── + +describe('projection', () => { + it('createProjection returns a valid config object', () => { + const config = createProjection(); + assert.ok(config); + assert.equal(typeof config.velocityCurve, 'string'); + assert.equal(typeof config.gateThreshold, 'number'); + assert.ok(config.pitchRange); + }); + + it('VELOCITY_CURVES has 3 entries', () => { + assert.equal(VELOCITY_CURVES.length, 3); + }); +}); + +// ── Browser-dependent modules ─────────────────────────────────────── +// These modules reference AudioContext, DOM APIs, or parent-directory +// imports that may not resolve in Node. We verify the import itself +// doesn't throw at module-evaluation time (syntax / top-level errors). + +describe('browser-dependent modules (import-only)', () => { + it('clock.js imports without top-level error', async () => { + // ClockEngine requires AudioContext at *construction*, but the + // module should load fine. + const mod = await import('../clock.js'); + assert.ok(mod.ClockEngine, 'ClockEngine export exists'); + }); + + it('step-viz.js imports without top-level error', async () => { + const mod = await import('../step-viz.js'); + assert.ok(mod.StepVisualizer, 'StepVisualizer export exists'); + }); + + it('sequencer.js imports without top-level error', async () => { + const mod = await import('../sequencer.js'); + assert.ok(mod.ShapeSeqEngine, 'ShapeSeqEngine export exists'); + }); + + it('chain-ui.js imports without top-level error', async () => { + const mod = await import('../chain-ui.js'); + assert.ok(mod.ChainBuilderUI, 'ChainBuilderUI export exists'); + }); + + it('seq-iml.js imports without top-level error', async () => { + // seq-iml imports from ../nisps/nisps-wasm.js which may not + // resolve in Node. We catch and skip gracefully. + try { + const mod = await import('../seq-iml.js'); + assert.ok(mod.createSequenceIML, 'createSequenceIML export exists'); + } catch (err) { + // Expected — parent-directory import of nisps-wasm may fail + assert.ok(true, `skipped: ${err.message}`); + } + }); +});