feat(shapeseq): freeze-as-algorithm/pattern + delta controller

Two freeze modes for capturing and preserving musical state:

- Algorithm freeze: snapshot all primitive params + seeds. Individual
  params can be selectively unfrozen for ML-driven exploration via
  DeltaController (applies MLP deltas to frozen base values with
  per-param boundary enforcement).

- Pattern freeze: snapshot the realized note pattern. The sequencer
  bypasses the primitive chain entirely and loops the frozen pattern.

Extracts _runPipeline() from setSequenceInputs() for reuse.
This commit is contained in:
w1n5t0n 2026-04-06 23:55:23 +01:00
parent 122bdb9843
commit 9b08918b1e
6 changed files with 1097 additions and 7 deletions

View file

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

View file

@ -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<number>|null} */ this._frozenSeeds = null;
/** @private @type {Array<Object>|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<number>|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<number>|null}
*/
getFrozenSeeds() {
return this._frozenSeeds;
}
/**
* Returns the captured per-primitive states, or null if not frozen.
* @returns {Array<Object>|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<number>} 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;
}
}

View file

@ -33,6 +33,8 @@ import {
IntervalLock, IntervalLock,
VelocityShaper, VelocityShaper,
} from './primitives.js'; } from './primitives.js';
import { FreezeManager } from './freeze.js';
import { DeltaController } from './delta.js';
// ── Defaults ───────────────────────────────────────────────────────── // ── Defaults ─────────────────────────────────────────────────────────
@ -63,6 +65,15 @@ export class ShapeSeqEngine {
/** @private */ this._outputCount = SEQ_DEFAULT_OUTPUT_COUNT; /** @private */ this._outputCount = SEQ_DEFAULT_OUTPUT_COUNT;
/** @private */ this._stepCount = DEFAULT_STEP_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._masterSeed = DEFAULT_MASTER_SEED;
/** @private */ this._playing = false; /** @private */ this._playing = false;
/** @private */ this._initialized = false; /** @private */ this._initialized = false;
@ -267,6 +278,51 @@ export class ShapeSeqEngine {
/** @returns {WasmIML} */ /** @returns {WasmIML} */
getSequenceIML() { return this._sequenceIML; } 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 ────────────────────────────────────────────────── // ── Input routing ──────────────────────────────────────────────────
/** /**
@ -280,6 +336,11 @@ export class ShapeSeqEngine {
setSequenceInputs(values) { setSequenceInputs(values) {
if (!this._initialized || !this._sequenceIML) return; 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 // Dirty-check: skip re-evaluation if inputs AND config haven't changed
const EPS = 1e-5; const EPS = 1e-5;
const inputsSame = Math.abs(values[0] - this._lastInputs[0]) < EPS && const inputsSame = Math.abs(values[0] - this._lastInputs[0]) < EPS &&
@ -298,20 +359,49 @@ export class ShapeSeqEngine {
// 2. Run MLP inference // 2. Run MLP inference
this._sequenceIML.process(); this._sequenceIML.process();
// 3. Get the 16 MLP outputs // 3. Get the MLP outputs
const mlpOutputs = this._sequenceIML.getOutputs(); const mlpOutputs = this._sequenceIML.getOutputs();
// 4. Map 16 outputs to N primitive params // 47. Run downstream pipeline
const paramCount = this._chain.totalParamCount; this._runPipeline(mlpOutputs);
const mappedParams = map(mlpOutputs, paramCount); }
// 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); const patternDesc = this._chain.evaluate(mappedParams, this._stepCount, this._masterSeed);
// 6. Apply projection transforms // Apply projection transforms
const projectedPattern = applyProjection(this._projectionChain, patternDesc); 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); this._clock.schedulePattern(projectedPattern);
} }
@ -345,6 +435,7 @@ export class ShapeSeqEngine {
*/ */
_handleLoopStart() { _handleLoopStart() {
if (!this._initialized || !this._chain || !this._sequenceIML) return; if (!this._initialized || !this._chain || !this._sequenceIML) return;
if (this._freezeManager.shouldSuppressReEval()) return;
if (!this._chain.hasReEvalPrimitives()) return; if (!this._chain.hasReEvalPrimitives()) return;
// Bump the generation counter so the next setSequenceInputs() call // Bump the generation counter so the next setSequenceInputs() call

View file

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

View file

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

View file

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