diff --git a/playground/js/shapeseq/delta.js b/playground/js/shapeseq/delta.js new file mode 100644 index 0000000..1597964 --- /dev/null +++ b/playground/js/shapeseq/delta.js @@ -0,0 +1,50 @@ +/** + * ShapeSeq DeltaController — MLP delta application with boundary enforcement + * + * Applies MLP output deltas to frozen param values for live (unfrozen) params. + * Frozen params pass through unchanged. Live params get: + * frozenValue + (mlpValue - 0.5) * 2 * deltaScale + * with per-param boundary enforcement (clamp, wrap, scaled). + * + * @module shapeseq/delta + */ + +import { applyBoundary } from './primitive.js'; + +export class DeltaController { + /** + * Compute effective params by applying MLP-derived deltas to frozen values. + * + * For frozen params: returns the frozen value unchanged. + * For live params: frozenValue + delta, with boundary enforcement. + * + * The delta is derived from the MLP output: delta = (mlpValue - 0.5) * 2 * deltaScale + * This maps MLP [0,1] output to a [-deltaScale, +deltaScale] range centered on the frozen value. + * + * @param {Float32Array} frozenParams - captured param values from FreezeManager + * @param {Uint8Array} liveFlags - 1=live, 0=frozen from FreezeManager + * @param {Float32Array} mlpParams - current MLP-mapped param values [0,1] + * @param {Array<{schema: {boundary: string, scaledRange?: number}}>} paramSchemas - from chain.getParamSchemas() + * @param {number} deltaScale - global delta sensitivity (default 0.3) + * @returns {Float32Array} effective params ready for chain.evaluate() + */ + static computeEffective(frozenParams, liveFlags, mlpParams, paramSchemas, deltaScale = 0.3) { + const count = frozenParams.length; + const result = new Float32Array(count); + + for (let i = 0; i < count; i++) { + if (!liveFlags[i]) { + // Frozen: passthrough + result[i] = frozenParams[i]; + } else { + // Live: apply delta with boundary enforcement + const delta = (mlpParams[i] - 0.5) * 2 * deltaScale; + const raw = frozenParams[i] + delta; + const schema = paramSchemas[i]?.schema || { boundary: 'clamp' }; + result[i] = applyBoundary(raw, schema, frozenParams[i]); + } + } + + return result; + } +} diff --git a/playground/js/shapeseq/freeze.js b/playground/js/shapeseq/freeze.js new file mode 100644 index 0000000..4c56f42 --- /dev/null +++ b/playground/js/shapeseq/freeze.js @@ -0,0 +1,246 @@ +/** + * ShapeSeq FreezeManager — freeze-as-algorithm and freeze-as-pattern + * + * Two freeze modes: + * + * - **algorithm**: Captures all primitive param values + PRNG seeds. + * Frozen params hold their captured values; individual params can + * be selectively re-exposed as "live" for ML-driven exploration. + * + * - **pattern**: Captures the realized note pattern (the actual step + * events for one full loop). The sequencer bypasses the primitive + * chain entirely and just loops the frozen pattern. + * + * Delta math (proper delta+boundary logic) is a separate issue (meml-7goy). + * This module provides the simple version: frozen params use captured values, + * live params pass through current ML values. + * + * @module shapeseq/freeze + */ + +import { clonePattern } from './pattern.js'; + +export class FreezeManager { + constructor() { + /** @private */ this._frozen = false; + /** @private @type {'algorithm'|'pattern'|null} */ this._freezeMode = null; + /** @private @type {Float32Array|null} */ this._frozenParams = null; + /** @private @type {Array|null} */ this._frozenSeeds = null; + /** @private @type {Array|null} */ this._frozenStates = null; + /** @private @type {Uint8Array|null} */ this._liveFlags = null; + /** @private @type {number|null} */ this._masterSeed = null; + /** @private @type {Object|null} */ this._frozenPattern = null; + } + + // ── Freeze / unfreeze ─────────────────────────────────────────────── + + /** + * Capture a full snapshot of the current state. + * + * In **algorithm** mode (default): captures params, seeds, and states. + * All params start frozen (liveFlags = 0); use toggleParam() or + * setParamLive() to selectively re-expose params for ML control. + * + * In **pattern** mode: captures the realized note pattern via deep clone. + * The primitive chain is bypassed entirely — the sequencer just loops + * the frozen pattern. + * + * @param {import('./chain.js').Chain|null} chain + * @param {Float32Array|Array|null} currentParams - flat param array + * @param {number|null} masterSeed + * @param {'algorithm'|'pattern'} [mode='algorithm'] + * @param {Object|null} [currentPattern=null] - required when mode is 'pattern' + */ + freeze(chain, currentParams, masterSeed, mode = 'algorithm', currentPattern = null) { + this._freezeMode = mode; + + if (mode === 'pattern') { + // Pattern mode: capture the realized pattern, bypass chain + if (!currentPattern) { + throw new Error('freeze: pattern mode requires a currentPattern'); + } + this._frozenPattern = clonePattern(currentPattern); + // Clear algorithm-mode state + this._frozenParams = null; + this._frozenSeeds = null; + this._frozenStates = null; + this._liveFlags = null; + this._masterSeed = null; + } else { + // Algorithm mode: existing behavior + // Copy param values + this._frozenParams = new Float32Array(currentParams.length); + for (let i = 0; i < currentParams.length; i++) { + this._frozenParams[i] = currentParams[i]; + } + + // Capture per-primitive seeds + this._frozenSeeds = chain.getPrimitives().map(p => p.getSeed()); + + // Capture per-primitive states + this._frozenStates = chain.getState(); + + // Store master seed + this._masterSeed = masterSeed; + + // All params frozen by default + this._liveFlags = new Uint8Array(currentParams.length); + + // Clear pattern-mode state + this._frozenPattern = null; + } + + this._frozen = true; + } + + /** + * Clear all captured state and return to unfrozen mode. + */ + unfreeze() { + this._frozen = false; + this._freezeMode = null; + this._frozenParams = null; + this._frozenSeeds = null; + this._frozenStates = null; + this._liveFlags = null; + this._masterSeed = null; + this._frozenPattern = null; + } + + // ── Per-param live/frozen control ────────────────────────────────── + + /** + * Toggle a param between frozen (0) and live (1). + * Only valid when frozen; throws if not frozen. + * + * @param {number} flatIndex - index into the flat param array + */ + toggleParam(flatIndex) { + if (!this._frozen) { + throw new Error('toggleParam: cannot toggle when not frozen'); + } + const idx = flatIndex | 0; + if (idx < 0 || idx >= this._liveFlags.length) { + throw new RangeError('toggleParam: index ' + flatIndex + ' out of range [0, ' + (this._liveFlags.length - 1) + ']'); + } + this._liveFlags[idx] = this._liveFlags[idx] ? 0 : 1; + } + + /** + * Explicitly set a param's live state. + * Only valid when frozen; throws if not frozen. + * + * @param {number} flatIndex - index into the flat param array + * @param {boolean} isLive - true = live (receives ML values), false = frozen + */ + setParamLive(flatIndex, isLive) { + if (!this._frozen) { + throw new Error('setParamLive: cannot set when not frozen'); + } + const idx = flatIndex | 0; + if (idx < 0 || idx >= this._liveFlags.length) { + throw new RangeError('setParamLive: index ' + flatIndex + ' out of range [0, ' + (this._liveFlags.length - 1) + ']'); + } + this._liveFlags[idx] = isLive ? 1 : 0; + } + + // ── Getters ──────────────────────────────────────────────────────── + + /** @returns {boolean} */ + get isFrozen() { + return this._frozen; + } + + /** + * Returns the current freeze mode, or null if not frozen. + * @returns {'algorithm'|'pattern'|null} + */ + get freezeMode() { + return this._freezeMode; + } + + /** + * Returns the captured pattern (pattern mode only), or null. + * @returns {Object|null} + */ + getFrozenPattern() { + return this._frozenPattern; + } + + /** + * Returns the captured param values, or null if not frozen. + * @returns {Float32Array|null} + */ + getFrozenParams() { + return this._frozenParams; + } + + /** + * Returns the live flags array, or null if not frozen. + * @returns {Uint8Array|null} + */ + getLiveFlags() { + return this._liveFlags; + } + + /** + * Returns the captured per-primitive PRNG seeds, or null if not frozen. + * @returns {Array|null} + */ + getFrozenSeeds() { + return this._frozenSeeds; + } + + /** + * Returns the captured per-primitive states, or null if not frozen. + * @returns {Array|null} + */ + getFrozenStates() { + return this._frozenStates; + } + + /** + * Returns the captured master seed, or null if not frozen. + * @returns {number|null} + */ + getMasterSeed() { + return this._masterSeed; + } + + // ── Effective params ─────────────────────────────────────────────── + + /** + * Given current MLP-derived params, return the effective param array: + * frozen params use captured values, live params use currentMLParams. + * + * This is the simple version. The delta controller (meml-7goy) will + * replace this with proper delta+boundary logic later. + * + * Returns null if not frozen. + * + * @param {Float32Array|Array} currentMLParams + * @returns {Float32Array|null} + */ + getEffectiveParams(currentMLParams) { + if (!this._frozen) return null; + if (this._freezeMode === 'pattern') return null; + + const result = new Float32Array(this._frozenParams.length); + for (let i = 0; i < result.length; i++) { + result[i] = this._liveFlags[i] ? currentMLParams[i] : this._frozenParams[i]; + } + return result; + } + + // ── Sequencer integration ────────────────────────────────────────── + + /** + * Returns true when frozen — freeze-as-algorithm suppresses per-loop + * re-evaluation. The sequencer's _handleLoopStart should check this. + * + * @returns {boolean} + */ + shouldSuppressReEval() { + return this._frozen; + } +} diff --git a/playground/js/shapeseq/sequencer.js b/playground/js/shapeseq/sequencer.js index 311645e..5b8ba42 100644 --- a/playground/js/shapeseq/sequencer.js +++ b/playground/js/shapeseq/sequencer.js @@ -33,6 +33,8 @@ import { IntervalLock, VelocityShaper, } from './primitives.js'; +import { FreezeManager } from './freeze.js'; +import { DeltaController } from './delta.js'; // ── Defaults ───────────────────────────────────────────────────────── @@ -63,6 +65,15 @@ export class ShapeSeqEngine { /** @private */ this._outputCount = SEQ_DEFAULT_OUTPUT_COUNT; /** @private */ this._stepCount = DEFAULT_STEP_COUNT; + + // Freeze-as-algorithm manager + /** @private */ this._freezeManager = new FreezeManager(); + + // Last evaluated params — needed for freeze snapshot + /** @private @type {Float32Array|null} */ this._lastEvaluatedParams = null; + + // 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; @@ -267,6 +278,51 @@ export class ShapeSeqEngine { /** @returns {WasmIML} */ getSequenceIML() { return this._sequenceIML; } + /** @returns {FreezeManager} */ + get freezeManager() { return this._freezeManager; } + + // ── Freeze-as-algorithm ──────────────────────────────────────────── + + /** + * Freeze the current state. + * + * In **algorithm** mode (default): capture param values, seeds, and states. + * All params start frozen; use toggleParamFreeze() to re-expose + * individual params for ML-driven exploration. + * + * In **pattern** mode: capture the last projected pattern. The sequencer + * bypasses the primitive chain entirely and loops the frozen pattern. + * + * @param {'algorithm'|'pattern'} [mode='algorithm'] + */ + freeze(mode = 'algorithm') { + if (mode === 'pattern') { + // Need the last evaluated pattern + this._freezeManager.freeze(null, null, null, 'pattern', this._lastPattern); + } else { + if (!this._chain) return; + const currentParams = this._lastEvaluatedParams || new Float32Array(this._chain.totalParamCount); + this._freezeManager.freeze(this._chain, currentParams, this._masterSeed, 'algorithm'); + } + } + + /** + * Unfreeze: clear captured state and return to normal ML control. + */ + unfreeze() { + this._freezeManager.unfreeze(); + this._bumpGeneration(); + } + + /** + * Toggle a single param between frozen and live. + * @param {number} flatIndex + */ + toggleParamFreeze(flatIndex) { + this._freezeManager.toggleParam(flatIndex); + this._bumpGeneration(); + } + // ── Input routing ────────────────────────────────────────────────── /** @@ -280,6 +336,11 @@ export class ShapeSeqEngine { setSequenceInputs(values) { if (!this._initialized || !this._sequenceIML) return; + // Freeze-as-pattern: skip entire pipeline, just keep looping frozen pattern + if (this._freezeManager.isFrozen && this._freezeManager.freezeMode === 'pattern') { + return; + } + // Dirty-check: skip re-evaluation if inputs AND config haven't changed const EPS = 1e-5; const inputsSame = Math.abs(values[0] - this._lastInputs[0]) < EPS && @@ -298,20 +359,49 @@ export class ShapeSeqEngine { // 2. Run MLP inference this._sequenceIML.process(); - // 3. Get the 16 MLP outputs + // 3. Get the MLP outputs const mlpOutputs = this._sequenceIML.getOutputs(); - // 4. Map 16 outputs to N primitive params - const paramCount = this._chain.totalParamCount; - const mappedParams = map(mlpOutputs, paramCount); + // 4–7. Run downstream pipeline + this._runPipeline(mlpOutputs); + } - // 5. Evaluate the chain to produce a pattern description + /** + * Shared downstream pipeline: param mapping -> chain -> projection -> clock. + * + * @private + * @param {Float32Array} mlpOutputs - raw MLP outputs (sequence slice) + */ + _runPipeline(mlpOutputs) { + // Map N outputs to M primitive params + const paramCount = this._chain.totalParamCount; + let mappedParams = map(mlpOutputs, paramCount); + + // Apply freeze: frozen params use captured values, live params get delta control + if (this._freezeManager.isFrozen) { + const schemas = this._chain.getParamSchemas(); + mappedParams = DeltaController.computeEffective( + this._freezeManager.getFrozenParams(), + this._freezeManager.getLiveFlags(), + mappedParams, + schemas, + 0.3 // deltaScale — could be configurable later + ); + } + + // Track last evaluated params for freeze snapshot + this._lastEvaluatedParams = mappedParams; + + // Evaluate the chain to produce a pattern description const patternDesc = this._chain.evaluate(mappedParams, this._stepCount, this._masterSeed); - // 6. Apply projection transforms + // Apply projection transforms const projectedPattern = applyProjection(this._projectionChain, patternDesc); - // 7. Schedule the pattern on the clock + // Track last pattern for freeze-as-pattern snapshot + this._lastPattern = projectedPattern; + + // Schedule the pattern on the clock this._clock.schedulePattern(projectedPattern); } @@ -345,6 +435,7 @@ export class ShapeSeqEngine { */ _handleLoopStart() { if (!this._initialized || !this._chain || !this._sequenceIML) return; + if (this._freezeManager.shouldSuppressReEval()) return; if (!this._chain.hasReEvalPrimitives()) return; // Bump the generation counter so the next setSequenceInputs() call diff --git a/playground/js/shapeseq/tests/delta.test.js b/playground/js/shapeseq/tests/delta.test.js new file mode 100644 index 0000000..b7c8734 --- /dev/null +++ b/playground/js/shapeseq/tests/delta.test.js @@ -0,0 +1,193 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { DeltaController } from '../delta.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const EPS = 1e-6; + +function approxEqual(actual, expected, msg) { + assert.ok( + Math.abs(actual - expected) < EPS, + `${msg || ''} expected ${expected}, got ${actual}` + ); +} + +function makeSchemas(count, boundary = 'clamp', scaledRange) { + const schemas = []; + for (let i = 0; i < count; i++) { + const entry = { boundary }; + if (boundary === 'scaled' && scaledRange !== undefined) { + entry.scaledRange = scaledRange; + } + schemas.push({ schema: entry }); + } + return schemas; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('DeltaController.computeEffective', () => { + + it('all frozen (liveFlags all 0): output === frozenParams', () => { + const frozen = new Float32Array([0.2, 0.5, 0.8]); + const live = new Uint8Array([0, 0, 0]); + const mlp = new Float32Array([0.0, 1.0, 0.5]); + const schemas = makeSchemas(3); + + const result = DeltaController.computeEffective(frozen, live, mlp, schemas); + + approxEqual(result[0], 0.2, 'param 0'); + approxEqual(result[1], 0.5, 'param 1'); + approxEqual(result[2], 0.8, 'param 2'); + }); + + it('all live with clamp boundary: delta applied, result clamped to [0,1]', () => { + const frozen = new Float32Array([0.9, 0.1]); + const live = new Uint8Array([1, 1]); + const mlp = new Float32Array([1.0, 0.0]); // max positive, max negative + const schemas = makeSchemas(2, 'clamp'); + const deltaScale = 0.3; + + const result = DeltaController.computeEffective(frozen, live, mlp, schemas, deltaScale); + + // param 0: 0.9 + (1.0 - 0.5)*2*0.3 = 0.9 + 0.3 = 1.2 -> clamped to 1.0 + approxEqual(result[0], 1.0, 'param 0 clamped high'); + // param 1: 0.1 + (0.0 - 0.5)*2*0.3 = 0.1 - 0.3 = -0.2 -> clamped to 0.0 + approxEqual(result[1], 0.0, 'param 1 clamped low'); + }); + + it('live with wrap boundary: values wrap around', () => { + const frozen = new Float32Array([0.9, 0.1]); + const live = new Uint8Array([1, 1]); + const mlp = new Float32Array([1.0, 0.0]); + const schemas = makeSchemas(2, 'wrap'); + const deltaScale = 0.3; + + const result = DeltaController.computeEffective(frozen, live, mlp, schemas, deltaScale); + + // param 0: 0.9 + 0.3 = 1.2 -> wrap -> 0.2 + approxEqual(result[0], 0.2, 'param 0 wrapped high'); + // param 1: 0.1 - 0.3 = -0.2 -> wrap -> 0.8 + approxEqual(result[1], 0.8, 'param 1 wrapped low'); + }); + + it('live with scaled boundary: delta operates within scaledRange of frozen value', () => { + const frozen = new Float32Array([0.5]); + const live = new Uint8Array([1]); + // With scaled boundary, applyBoundary maps [0,1] input to [frozen-range, frozen+range] + // The raw value passed to applyBoundary is frozen + delta + // For scaled: lo = 0.5 - 0.3 = 0.2, hi = 0.5 + 0.3 = 0.8 + // mapped = 0.2 + raw * 0.6 + const mlp = new Float32Array([0.75]); // delta = (0.75 - 0.5)*2*0.3 = 0.15, raw = 0.65 + const schemas = makeSchemas(1, 'scaled', 0.3); + const deltaScale = 0.3; + + const result = DeltaController.computeEffective(frozen, live, mlp, schemas, deltaScale); + + // raw = 0.5 + 0.15 = 0.65 + // scaled: lo = 0.5 - 0.3 = 0.2, hi = 0.5 + 0.3 = 0.8 + // mapped = 0.2 + 0.65 * (0.8 - 0.2) = 0.2 + 0.39 = 0.59 + approxEqual(result[0], 0.59, 'scaled boundary'); + }); + + it('MLP value 0.5 produces zero delta (frozen value unchanged)', () => { + const frozen = new Float32Array([0.3, 0.7]); + const live = new Uint8Array([1, 1]); + const mlp = new Float32Array([0.5, 0.5]); + const schemas = makeSchemas(2, 'clamp'); + + const result = DeltaController.computeEffective(frozen, live, mlp, schemas); + + approxEqual(result[0], 0.3, 'param 0 unchanged'); + approxEqual(result[1], 0.7, 'param 1 unchanged'); + }); + + it('MLP value 0.0 produces max negative delta', () => { + const frozen = new Float32Array([0.5]); + const live = new Uint8Array([1]); + const mlp = new Float32Array([0.0]); + const schemas = makeSchemas(1, 'clamp'); + const deltaScale = 0.3; + + const result = DeltaController.computeEffective(frozen, live, mlp, schemas, deltaScale); + + // 0.5 + (0.0 - 0.5)*2*0.3 = 0.5 - 0.3 = 0.2 + approxEqual(result[0], 0.2, 'max negative delta'); + }); + + it('MLP value 1.0 produces max positive delta', () => { + const frozen = new Float32Array([0.5]); + const live = new Uint8Array([1]); + const mlp = new Float32Array([1.0]); + const schemas = makeSchemas(1, 'clamp'); + const deltaScale = 0.3; + + const result = DeltaController.computeEffective(frozen, live, mlp, schemas, deltaScale); + + // 0.5 + (1.0 - 0.5)*2*0.3 = 0.5 + 0.3 = 0.8 + approxEqual(result[0], 0.8, 'max positive delta'); + }); + + it('mixed frozen/live params', () => { + const frozen = new Float32Array([0.2, 0.5, 0.8, 0.4]); + const live = new Uint8Array([0, 1, 0, 1]); + const mlp = new Float32Array([0.9, 0.5, 0.1, 0.75]); + const schemas = makeSchemas(4, 'clamp'); + const deltaScale = 0.3; + + const result = DeltaController.computeEffective(frozen, live, mlp, schemas, deltaScale); + + // param 0: frozen -> 0.2 + approxEqual(result[0], 0.2, 'frozen param 0'); + // param 1: live, mlp=0.5 -> zero delta -> 0.5 + approxEqual(result[1], 0.5, 'live param 1 zero delta'); + // param 2: frozen -> 0.8 + approxEqual(result[2], 0.8, 'frozen param 2'); + // param 3: live, 0.4 + (0.75 - 0.5)*2*0.3 = 0.4 + 0.15 = 0.55 + approxEqual(result[3], 0.55, 'live param 3'); + }); + + it('deltaScale affects magnitude', () => { + const frozen = new Float32Array([0.5]); + const live = new Uint8Array([1]); + const mlp = new Float32Array([1.0]); // max positive + const schemas = makeSchemas(1, 'clamp'); + + const r1 = DeltaController.computeEffective(frozen, live, mlp, schemas, 0.1); + const r2 = DeltaController.computeEffective(frozen, live, mlp, schemas, 0.5); + + // scale 0.1: 0.5 + 0.1 = 0.6 + approxEqual(r1[0], 0.6, 'small deltaScale'); + // scale 0.5: 0.5 + 0.5 = 1.0 + approxEqual(r2[0], 1.0, 'large deltaScale'); + }); + + it('defaults deltaScale to 0.3 when not provided', () => { + const frozen = new Float32Array([0.5]); + const live = new Uint8Array([1]); + const mlp = new Float32Array([1.0]); + const schemas = makeSchemas(1, 'clamp'); + + const result = DeltaController.computeEffective(frozen, live, mlp, schemas); + + // 0.5 + (1.0 - 0.5)*2*0.3 = 0.8 + approxEqual(result[0], 0.8, 'default deltaScale 0.3'); + }); + + it('falls back to clamp when schema is missing', () => { + const frozen = new Float32Array([0.9]); + const live = new Uint8Array([1]); + const mlp = new Float32Array([1.0]); + const schemas = []; // empty — no schema for this index + + const result = DeltaController.computeEffective(frozen, live, mlp, schemas, 0.3); + + // 0.9 + 0.3 = 1.2 -> clamped to 1.0 + approxEqual(result[0], 1.0, 'missing schema falls back to clamp'); + }); +}); diff --git a/playground/js/shapeseq/tests/freeze-pattern.test.js b/playground/js/shapeseq/tests/freeze-pattern.test.js new file mode 100644 index 0000000..96c66e6 --- /dev/null +++ b/playground/js/shapeseq/tests/freeze-pattern.test.js @@ -0,0 +1,217 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { FreezeManager } from '../freeze.js'; +import { Chain } from '../chain.js'; +import { EuclideanRhythm, ProbabilityGate, PitchWalker } from '../primitives.js'; +import { createPattern, clonePattern } from '../pattern.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeTestChain() { + const chain = new Chain(); + chain.addPrimitive(new EuclideanRhythm()); + chain.addPrimitive(new ProbabilityGate()); + chain.addPrimitive(new PitchWalker()); + return { chain, paramCount: chain.totalParamCount }; +} + +function makeParams(count) { + const params = new Float32Array(count); + for (let i = 0; i < count; i++) { + params[i] = (i + 1) / (count + 1); + } + return params; +} + +/** + * Create a pattern with some non-default step data for testing. + */ +function makeTestPattern(stepCount) { + const pattern = createPattern(stepCount); + for (let i = 0; i < stepCount; i++) { + pattern.steps[i].trigger = i % 2 === 0; + pattern.steps[i].pitch = (i + 1) / (stepCount + 1); + pattern.steps[i].velocity = 0.5 + (i * 0.05); + pattern.steps[i].accent = i === 0; + pattern.steps[i].midiNote = 60 + i; + } + pattern.metadata.source = 'test'; + return pattern; +} + +// --------------------------------------------------------------------------- +// freeze with mode='pattern' captures the pattern +// --------------------------------------------------------------------------- + +describe('FreezeManager freeze-as-pattern', () => { + it('freeze with mode=pattern captures the pattern', () => { + const fm = new FreezeManager(); + const pattern = makeTestPattern(8); + + fm.freeze(null, null, null, 'pattern', pattern); + + assert.strictEqual(fm.isFrozen, true); + const frozen = fm.getFrozenPattern(); + assert.ok(frozen !== null, 'frozen pattern should not be null'); + assert.strictEqual(frozen.stepCount, 8); + assert.strictEqual(frozen.steps.length, 8); + }); + + it('getFrozenPattern returns the captured pattern', () => { + const fm = new FreezeManager(); + const pattern = makeTestPattern(4); + + fm.freeze(null, null, null, 'pattern', pattern); + + const frozen = fm.getFrozenPattern(); + // Verify step data matches + for (let i = 0; i < 4; i++) { + assert.strictEqual(frozen.steps[i].trigger, pattern.steps[i].trigger, + 'step ' + i + ' trigger should match'); + assert.strictEqual(frozen.steps[i].pitch, pattern.steps[i].pitch, + 'step ' + i + ' pitch should match'); + assert.strictEqual(frozen.steps[i].velocity, pattern.steps[i].velocity, + 'step ' + i + ' velocity should match'); + assert.strictEqual(frozen.steps[i].midiNote, pattern.steps[i].midiNote, + 'step ' + i + ' midiNote should match'); + } + assert.strictEqual(frozen.metadata.source, 'test'); + }); + + it('freezeMode is pattern', () => { + const fm = new FreezeManager(); + const pattern = makeTestPattern(8); + + fm.freeze(null, null, null, 'pattern', pattern); + + assert.strictEqual(fm.freezeMode, 'pattern'); + }); + + it('getEffectiveParams returns null in pattern mode', () => { + const fm = new FreezeManager(); + const pattern = makeTestPattern(8); + + fm.freeze(null, null, null, 'pattern', pattern); + + const result = fm.getEffectiveParams(new Float32Array(8)); + assert.strictEqual(result, null); + }); + + it('shouldSuppressReEval returns true in pattern mode', () => { + const fm = new FreezeManager(); + const pattern = makeTestPattern(8); + + fm.freeze(null, null, null, 'pattern', pattern); + + assert.strictEqual(fm.shouldSuppressReEval(), true); + }); + + it('unfreeze clears frozen pattern', () => { + const fm = new FreezeManager(); + const pattern = makeTestPattern(8); + + fm.freeze(null, null, null, 'pattern', pattern); + assert.strictEqual(fm.isFrozen, true); + assert.ok(fm.getFrozenPattern() !== null); + + fm.unfreeze(); + + assert.strictEqual(fm.isFrozen, false); + assert.strictEqual(fm.freezeMode, null); + assert.strictEqual(fm.getFrozenPattern(), null); + assert.strictEqual(fm.getFrozenParams(), null); + assert.strictEqual(fm.getFrozenSeeds(), null); + assert.strictEqual(fm.getFrozenStates(), null); + assert.strictEqual(fm.getMasterSeed(), null); + }); + + it('freeze with mode=algorithm still works (backward compat)', () => { + const fm = new FreezeManager(); + const { chain, paramCount } = makeTestChain(); + const params = makeParams(paramCount); + + fm.freeze(chain, params, 42, 'algorithm'); + + assert.strictEqual(fm.isFrozen, true); + assert.strictEqual(fm.freezeMode, 'algorithm'); + assert.ok(fm.getFrozenParams() instanceof Float32Array); + assert.strictEqual(fm.getFrozenParams().length, paramCount); + assert.ok(Array.isArray(fm.getFrozenSeeds())); + assert.ok(Array.isArray(fm.getFrozenStates())); + assert.strictEqual(fm.getMasterSeed(), 42); + assert.strictEqual(fm.getFrozenPattern(), null); + }); + + it('freeze with default mode is algorithm (backward compat)', () => { + const fm = new FreezeManager(); + const { chain, paramCount } = makeTestChain(); + const params = makeParams(paramCount); + + // Call without mode argument — should default to 'algorithm' + fm.freeze(chain, params, 42); + + assert.strictEqual(fm.freezeMode, 'algorithm'); + assert.ok(fm.getFrozenParams() instanceof Float32Array); + assert.strictEqual(fm.getFrozenPattern(), null); + }); + + it('frozen pattern is a deep clone (mutating original does not affect frozen)', () => { + const fm = new FreezeManager(); + const pattern = makeTestPattern(4); + + fm.freeze(null, null, null, 'pattern', pattern); + + // Mutate the original + pattern.steps[0].trigger = !pattern.steps[0].trigger; + pattern.steps[0].pitch = 0.999; + pattern.steps[0].midiNote = 127; + pattern.metadata.source = 'mutated'; + + // Frozen should be unaffected + const frozen = fm.getFrozenPattern(); + assert.strictEqual(frozen.steps[0].trigger, true, 'frozen trigger should be unchanged'); + assert.notStrictEqual(frozen.steps[0].pitch, 0.999, 'frozen pitch should be unchanged'); + assert.strictEqual(frozen.steps[0].midiNote, 60, 'frozen midiNote should be unchanged'); + assert.strictEqual(frozen.metadata.source, 'test', 'frozen metadata should be unchanged'); + }); + + it('pattern mode throws if currentPattern is null', () => { + const fm = new FreezeManager(); + assert.throws( + () => fm.freeze(null, null, null, 'pattern', null), + /pattern mode requires a currentPattern/ + ); + }); + + it('freezeMode is null when not frozen', () => { + const fm = new FreezeManager(); + assert.strictEqual(fm.freezeMode, null); + }); + + it('getFrozenPattern returns null when not frozen', () => { + const fm = new FreezeManager(); + assert.strictEqual(fm.getFrozenPattern(), null); + }); + + it('getFrozenPattern returns null in algorithm mode', () => { + const fm = new FreezeManager(); + const { chain, paramCount } = makeTestChain(); + fm.freeze(chain, makeParams(paramCount), 42, 'algorithm'); + assert.strictEqual(fm.getFrozenPattern(), null); + }); + + it('algorithm-mode state is null in pattern mode', () => { + const fm = new FreezeManager(); + const pattern = makeTestPattern(8); + + fm.freeze(null, null, null, 'pattern', pattern); + + assert.strictEqual(fm.getFrozenParams(), null); + assert.strictEqual(fm.getFrozenSeeds(), null); + assert.strictEqual(fm.getFrozenStates(), null); + assert.strictEqual(fm.getLiveFlags(), null); + assert.strictEqual(fm.getMasterSeed(), null); + }); +}); diff --git a/playground/js/shapeseq/tests/freeze.test.js b/playground/js/shapeseq/tests/freeze.test.js new file mode 100644 index 0000000..a3a490d --- /dev/null +++ b/playground/js/shapeseq/tests/freeze.test.js @@ -0,0 +1,293 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { FreezeManager } from '../freeze.js'; +import { Chain } from '../chain.js'; +import { EuclideanRhythm, ProbabilityGate, PitchWalker } from '../primitives.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Create a chain with known primitives for testing. + * Returns { chain, paramCount }. + */ +function makeTestChain() { + const chain = new Chain(); + chain.addPrimitive(new EuclideanRhythm()); + chain.addPrimitive(new ProbabilityGate()); + chain.addPrimitive(new PitchWalker()); + return { chain, paramCount: chain.totalParamCount }; +} + +/** + * Create a Float32Array of params with sequential values for easy identification. + */ +function makeParams(count) { + const params = new Float32Array(count); + for (let i = 0; i < count; i++) { + params[i] = (i + 1) / (count + 1); // spread across (0, 1) + } + return params; +} + +// --------------------------------------------------------------------------- +// freeze() captures params, seeds, states correctly +// --------------------------------------------------------------------------- + +describe('FreezeManager.freeze()', () => { + it('captures params as a Float32Array copy', () => { + const fm = new FreezeManager(); + const { chain, paramCount } = makeTestChain(); + const params = makeParams(paramCount); + const masterSeed = 42; + + fm.freeze(chain, params, masterSeed); + + const frozen = fm.getFrozenParams(); + assert.ok(frozen instanceof Float32Array, 'should be Float32Array'); + assert.strictEqual(frozen.length, paramCount); + + // Values should match + for (let i = 0; i < paramCount; i++) { + assert.strictEqual(frozen[i], params[i], 'param ' + i + ' should match'); + } + + // Should be a copy, not the same reference + assert.notStrictEqual(frozen, params); + }); + + it('captures per-primitive seeds', () => { + const fm = new FreezeManager(); + const { chain, paramCount } = makeTestChain(); + const params = makeParams(paramCount); + + // Set known seeds + const prims = chain.getPrimitives(); + prims[0].setSeed(100); + prims[1].setSeed(200); + prims[2].setSeed(300); + + fm.freeze(chain, params, 42); + + const seeds = fm.getFrozenSeeds(); + assert.ok(Array.isArray(seeds)); + assert.strictEqual(seeds.length, 3); + assert.strictEqual(seeds[0], 100); + assert.strictEqual(seeds[1], 200); + assert.strictEqual(seeds[2], 300); + }); + + it('captures per-primitive states', () => { + const fm = new FreezeManager(); + const { chain, paramCount } = makeTestChain(); + const params = makeParams(paramCount); + + fm.freeze(chain, params, 42); + + const states = fm.getFrozenStates(); + assert.ok(Array.isArray(states)); + assert.strictEqual(states.length, 3); + }); + + it('captures master seed', () => { + const fm = new FreezeManager(); + const { chain, paramCount } = makeTestChain(); + const params = makeParams(paramCount); + + fm.freeze(chain, params, 12345); + assert.strictEqual(fm.getMasterSeed(), 12345); + }); + + it('initializes all live flags to 0 (frozen)', () => { + const fm = new FreezeManager(); + const { chain, paramCount } = makeTestChain(); + const params = makeParams(paramCount); + + fm.freeze(chain, params, 42); + + const flags = fm.getLiveFlags(); + assert.ok(flags instanceof Uint8Array); + assert.strictEqual(flags.length, paramCount); + for (let i = 0; i < paramCount; i++) { + assert.strictEqual(flags[i], 0, 'param ' + i + ' should be frozen'); + } + }); +}); + +// --------------------------------------------------------------------------- +// isFrozen +// --------------------------------------------------------------------------- + +describe('FreezeManager.isFrozen', () => { + it('returns false initially', () => { + const fm = new FreezeManager(); + assert.strictEqual(fm.isFrozen, false); + }); + + it('returns true after freeze()', () => { + const fm = new FreezeManager(); + const { chain, paramCount } = makeTestChain(); + fm.freeze(chain, makeParams(paramCount), 42); + assert.strictEqual(fm.isFrozen, true); + }); +}); + +// --------------------------------------------------------------------------- +// toggleParam +// --------------------------------------------------------------------------- + +describe('FreezeManager.toggleParam()', () => { + it('flips a param from frozen to live and back', () => { + const fm = new FreezeManager(); + const { chain, paramCount } = makeTestChain(); + fm.freeze(chain, makeParams(paramCount), 42); + + assert.strictEqual(fm.getLiveFlags()[0], 0); + + fm.toggleParam(0); + assert.strictEqual(fm.getLiveFlags()[0], 1); + + fm.toggleParam(0); + assert.strictEqual(fm.getLiveFlags()[0], 0); + }); + + it('only affects the targeted param', () => { + const fm = new FreezeManager(); + const { chain, paramCount } = makeTestChain(); + fm.freeze(chain, makeParams(paramCount), 42); + + fm.toggleParam(2); + const flags = fm.getLiveFlags(); + assert.strictEqual(flags[0], 0); + assert.strictEqual(flags[1], 0); + assert.strictEqual(flags[2], 1); + assert.strictEqual(flags[3], 0); + }); + + it('throws when not frozen', () => { + const fm = new FreezeManager(); + assert.throws(() => fm.toggleParam(0), /cannot toggle when not frozen/); + }); + + it('throws on out-of-range index', () => { + const fm = new FreezeManager(); + const { chain, paramCount } = makeTestChain(); + fm.freeze(chain, makeParams(paramCount), 42); + assert.throws(() => fm.toggleParam(paramCount + 10), /out of range/); + }); +}); + +// --------------------------------------------------------------------------- +// setParamLive +// --------------------------------------------------------------------------- + +describe('FreezeManager.setParamLive()', () => { + it('explicitly sets live state', () => { + const fm = new FreezeManager(); + const { chain, paramCount } = makeTestChain(); + fm.freeze(chain, makeParams(paramCount), 42); + + fm.setParamLive(1, true); + assert.strictEqual(fm.getLiveFlags()[1], 1); + + fm.setParamLive(1, false); + assert.strictEqual(fm.getLiveFlags()[1], 0); + }); + + it('throws when not frozen', () => { + const fm = new FreezeManager(); + assert.throws(() => fm.setParamLive(0, true), /cannot set when not frozen/); + }); +}); + +// --------------------------------------------------------------------------- +// getEffectiveParams +// --------------------------------------------------------------------------- + +describe('FreezeManager.getEffectiveParams()', () => { + it('returns frozen values for frozen params, current for live', () => { + const fm = new FreezeManager(); + const { chain, paramCount } = makeTestChain(); + const frozenParams = makeParams(paramCount); + + fm.freeze(chain, frozenParams, 42); + + // Make param 2 live + fm.toggleParam(2); + + // Create different "current" ML params + const currentParams = new Float32Array(paramCount); + for (let i = 0; i < paramCount; i++) { + currentParams[i] = 0.99; + } + + const effective = fm.getEffectiveParams(currentParams); + assert.ok(effective instanceof Float32Array); + assert.strictEqual(effective.length, paramCount); + + // Param 0: frozen -> uses frozen value + assert.strictEqual(effective[0], frozenParams[0]); + // Param 1: frozen -> uses frozen value + assert.strictEqual(effective[1], frozenParams[1]); + // Param 2: live -> uses current ML value (Float32 precision) + assert.ok(Math.abs(effective[2] - 0.99) < 1e-5, 'live param should use current ML value'); + // Param 3: frozen -> uses frozen value + assert.strictEqual(effective[3], frozenParams[3]); + }); + + it('returns null when not frozen', () => { + const fm = new FreezeManager(); + const result = fm.getEffectiveParams(new Float32Array(4)); + assert.strictEqual(result, null); + }); +}); + +// --------------------------------------------------------------------------- +// unfreeze +// --------------------------------------------------------------------------- + +describe('FreezeManager.unfreeze()', () => { + it('clears all captured state', () => { + const fm = new FreezeManager(); + const { chain, paramCount } = makeTestChain(); + fm.freeze(chain, makeParams(paramCount), 42); + + assert.strictEqual(fm.isFrozen, true); + + fm.unfreeze(); + + assert.strictEqual(fm.isFrozen, false); + assert.strictEqual(fm.getFrozenParams(), null); + assert.strictEqual(fm.getLiveFlags(), null); + assert.strictEqual(fm.getFrozenSeeds(), null); + assert.strictEqual(fm.getFrozenStates(), null); + assert.strictEqual(fm.getMasterSeed(), null); + }); +}); + +// --------------------------------------------------------------------------- +// shouldSuppressReEval +// --------------------------------------------------------------------------- + +describe('FreezeManager.shouldSuppressReEval()', () => { + it('returns true when frozen', () => { + const fm = new FreezeManager(); + const { chain, paramCount } = makeTestChain(); + fm.freeze(chain, makeParams(paramCount), 42); + assert.strictEqual(fm.shouldSuppressReEval(), true); + }); + + it('returns false when not frozen', () => { + const fm = new FreezeManager(); + assert.strictEqual(fm.shouldSuppressReEval(), false); + }); + + it('returns false after unfreeze', () => { + const fm = new FreezeManager(); + const { chain, paramCount } = makeTestChain(); + fm.freeze(chain, makeParams(paramCount), 42); + fm.unfreeze(); + assert.strictEqual(fm.shouldSuppressReEval(), false); + }); +});