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:
parent
122bdb9843
commit
9b08918b1e
6 changed files with 1097 additions and 7 deletions
50
playground/js/shapeseq/delta.js
Normal file
50
playground/js/shapeseq/delta.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
246
playground/js/shapeseq/freeze.js
Normal file
246
playground/js/shapeseq/freeze.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
// 4–7. 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
|
||||||
|
|
|
||||||
193
playground/js/shapeseq/tests/delta.test.js
Normal file
193
playground/js/shapeseq/tests/delta.test.js
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
217
playground/js/shapeseq/tests/freeze-pattern.test.js
Normal file
217
playground/js/shapeseq/tests/freeze-pattern.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
293
playground/js/shapeseq/tests/freeze.test.js
Normal file
293
playground/js/shapeseq/tests/freeze.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue