395 lines
12 KiB
JavaScript
395 lines
12 KiB
JavaScript
|
|
import { describe, it } from 'node:test';
|
||
|
|
import { strict as assert } from 'node:assert';
|
||
|
|
import { serializeShapeSeqState, restoreShapeSeqState } from '../session.js';
|
||
|
|
import { Chain } from '../chain.js';
|
||
|
|
import { FreezeManager } from '../freeze.js';
|
||
|
|
import {
|
||
|
|
EuclideanRhythm,
|
||
|
|
ProbabilityGate,
|
||
|
|
PitchWalker,
|
||
|
|
IntervalLock,
|
||
|
|
VelocityShaper,
|
||
|
|
PRIMITIVE_REGISTRY,
|
||
|
|
} from '../primitives.js';
|
||
|
|
import { createProjection } from '../projection.js';
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Helpers — minimal engine stub
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Build a fake ShapeSeqEngine with the same accessor shape the session
|
||
|
|
* module expects, but without AudioContext/EventBus/C15 dependencies.
|
||
|
|
*/
|
||
|
|
function makeFakeEngine(opts = {}) {
|
||
|
|
const chain = new Chain();
|
||
|
|
const prims = opts.primitives ?? [
|
||
|
|
new EuclideanRhythm(),
|
||
|
|
new ProbabilityGate(),
|
||
|
|
new PitchWalker(),
|
||
|
|
new IntervalLock(),
|
||
|
|
new VelocityShaper(),
|
||
|
|
];
|
||
|
|
for (const p of prims) chain.addPrimitive(p);
|
||
|
|
if (opts.combineMode) chain.generatorCombineMode = opts.combineMode;
|
||
|
|
|
||
|
|
const projection = opts.projection ?? createProjection();
|
||
|
|
const fm = new FreezeManager();
|
||
|
|
|
||
|
|
let voiceMode = opts.voiceMode ?? 'mono';
|
||
|
|
const bpm = opts.bpm ?? 120;
|
||
|
|
let seqWeights = opts.sequenceWeights ?? null;
|
||
|
|
|
||
|
|
return {
|
||
|
|
getChain: () => chain,
|
||
|
|
getClock: () => ({ bpm }),
|
||
|
|
getSequenceIML: () => seqWeights
|
||
|
|
? { getWeights: () => seqWeights, setWeights: (w) => { seqWeights = w; } }
|
||
|
|
: null,
|
||
|
|
getProjection: () => projection,
|
||
|
|
get voiceMode() { return voiceMode; },
|
||
|
|
setVoiceMode: (m) => { voiceMode = m; },
|
||
|
|
setProjection: (p) => { /* stub */ },
|
||
|
|
setTempo: (t) => { /* stub */ },
|
||
|
|
get freezeManager() { return fm; },
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// serializeShapeSeqState
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
describe('serializeShapeSeqState', () => {
|
||
|
|
it('produces a version-1 object with all expected top-level keys', () => {
|
||
|
|
const engine = makeFakeEngine();
|
||
|
|
const state = serializeShapeSeqState(engine);
|
||
|
|
|
||
|
|
assert.strictEqual(state.version, 1);
|
||
|
|
assert.ok('chain' in state);
|
||
|
|
assert.ok('projection' in state);
|
||
|
|
assert.ok('voiceMode' in state);
|
||
|
|
assert.ok('clock' in state);
|
||
|
|
assert.ok('sequenceWeights' in state);
|
||
|
|
assert.ok('freeze' in state);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('serializes chain primitives by name', () => {
|
||
|
|
const engine = makeFakeEngine({
|
||
|
|
primitives: [new EuclideanRhythm(), new PitchWalker()],
|
||
|
|
});
|
||
|
|
const state = serializeShapeSeqState(engine);
|
||
|
|
|
||
|
|
assert.strictEqual(state.chain.primitives.length, 2);
|
||
|
|
assert.strictEqual(state.chain.primitives[0].name, 'EuclideanRhythm');
|
||
|
|
assert.strictEqual(state.chain.primitives[1].name, 'PitchWalker');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('captures generator combine mode', () => {
|
||
|
|
const engine = makeFakeEngine({ combineMode: 'multiplicative' });
|
||
|
|
const state = serializeShapeSeqState(engine);
|
||
|
|
|
||
|
|
assert.strictEqual(state.chain.generatorCombineMode, 'multiplicative');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('captures primitive states array', () => {
|
||
|
|
const engine = makeFakeEngine({
|
||
|
|
primitives: [new PitchWalker()],
|
||
|
|
});
|
||
|
|
const state = serializeShapeSeqState(engine);
|
||
|
|
|
||
|
|
assert.ok(Array.isArray(state.chain.states));
|
||
|
|
assert.strictEqual(state.chain.states.length, 1);
|
||
|
|
// PitchWalker default state has position
|
||
|
|
assert.strictEqual(typeof state.chain.states[0].position, 'number');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('captures projection config', () => {
|
||
|
|
const proj = createProjection({
|
||
|
|
velocityCurve: 'exponential',
|
||
|
|
gateThreshold: 0.25,
|
||
|
|
pitchRange: { low: 36, high: 84 },
|
||
|
|
});
|
||
|
|
const engine = makeFakeEngine({ projection: proj });
|
||
|
|
const state = serializeShapeSeqState(engine);
|
||
|
|
|
||
|
|
assert.strictEqual(state.projection.velocityCurve, 'exponential');
|
||
|
|
assert.strictEqual(state.projection.gateThreshold, 0.25);
|
||
|
|
assert.strictEqual(state.projection.pitchRange.low, 36);
|
||
|
|
assert.strictEqual(state.projection.pitchRange.high, 84);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('captures voice mode', () => {
|
||
|
|
const engine = makeFakeEngine({ voiceMode: 'poly' });
|
||
|
|
const state = serializeShapeSeqState(engine);
|
||
|
|
|
||
|
|
assert.strictEqual(state.voiceMode, 'poly');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('captures clock BPM', () => {
|
||
|
|
const engine = makeFakeEngine({ bpm: 140 });
|
||
|
|
const state = serializeShapeSeqState(engine);
|
||
|
|
|
||
|
|
assert.strictEqual(state.clock.bpm, 140);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('captures sequence MLP weights when available', () => {
|
||
|
|
const weights = [[1, 2], [3, 4]];
|
||
|
|
const engine = makeFakeEngine({ sequenceWeights: weights });
|
||
|
|
const state = serializeShapeSeqState(engine);
|
||
|
|
|
||
|
|
assert.deepStrictEqual(state.sequenceWeights, weights);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('sets sequenceWeights to null when IML is unavailable', () => {
|
||
|
|
const engine = makeFakeEngine(); // no sequenceWeights
|
||
|
|
const state = serializeShapeSeqState(engine);
|
||
|
|
|
||
|
|
assert.strictEqual(state.sequenceWeights, null);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('freeze is null when not frozen', () => {
|
||
|
|
const engine = makeFakeEngine();
|
||
|
|
const state = serializeShapeSeqState(engine);
|
||
|
|
|
||
|
|
assert.strictEqual(state.freeze, null);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('freeze state is serialized when frozen in algorithm mode', () => {
|
||
|
|
const engine = makeFakeEngine({
|
||
|
|
primitives: [new EuclideanRhythm(), new ProbabilityGate()],
|
||
|
|
});
|
||
|
|
const chain = engine.getChain();
|
||
|
|
const paramCount = chain.totalParamCount;
|
||
|
|
const params = new Float32Array(paramCount);
|
||
|
|
for (let i = 0; i < paramCount; i++) params[i] = i / paramCount;
|
||
|
|
|
||
|
|
engine.freezeManager.freeze(chain, params, 42);
|
||
|
|
|
||
|
|
const state = serializeShapeSeqState(engine);
|
||
|
|
|
||
|
|
assert.ok(state.freeze !== null);
|
||
|
|
assert.strictEqual(state.freeze.mode, 'algorithm');
|
||
|
|
assert.ok(Array.isArray(state.freeze.frozenParams));
|
||
|
|
assert.strictEqual(state.freeze.frozenParams.length, paramCount);
|
||
|
|
assert.ok(Array.isArray(state.freeze.liveFlags));
|
||
|
|
assert.strictEqual(state.freeze.masterSeed, 42);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// restoreShapeSeqState
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
describe('restoreShapeSeqState', () => {
|
||
|
|
it('rebuilds chain from primitive names', () => {
|
||
|
|
const engine = makeFakeEngine({ primitives: [] });
|
||
|
|
assert.strictEqual(engine.getChain().getPrimitives().length, 0);
|
||
|
|
|
||
|
|
const state = {
|
||
|
|
version: 1,
|
||
|
|
chain: {
|
||
|
|
primitives: [
|
||
|
|
{ name: 'PitchWalker' },
|
||
|
|
{ name: 'EuclideanRhythm' },
|
||
|
|
{ name: 'VelocityShaper' },
|
||
|
|
],
|
||
|
|
states: null,
|
||
|
|
generatorCombineMode: 'additive',
|
||
|
|
},
|
||
|
|
projection: null,
|
||
|
|
voiceMode: null,
|
||
|
|
clock: null,
|
||
|
|
sequenceWeights: null,
|
||
|
|
freeze: null,
|
||
|
|
};
|
||
|
|
|
||
|
|
restoreShapeSeqState(engine, state);
|
||
|
|
|
||
|
|
const prims = engine.getChain().getPrimitives();
|
||
|
|
assert.strictEqual(prims.length, 3);
|
||
|
|
assert.strictEqual(prims[0].name, 'PitchWalker');
|
||
|
|
assert.strictEqual(prims[1].name, 'EuclideanRhythm');
|
||
|
|
assert.strictEqual(prims[2].name, 'VelocityShaper');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('restores generator combine mode', () => {
|
||
|
|
const engine = makeFakeEngine();
|
||
|
|
const state = {
|
||
|
|
version: 1,
|
||
|
|
chain: {
|
||
|
|
primitives: [{ name: 'EuclideanRhythm' }],
|
||
|
|
states: null,
|
||
|
|
generatorCombineMode: 'multiplicative',
|
||
|
|
},
|
||
|
|
projection: null,
|
||
|
|
voiceMode: null,
|
||
|
|
clock: null,
|
||
|
|
sequenceWeights: null,
|
||
|
|
freeze: null,
|
||
|
|
};
|
||
|
|
|
||
|
|
restoreShapeSeqState(engine, state);
|
||
|
|
assert.strictEqual(engine.getChain().generatorCombineMode, 'multiplicative');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('restores projection config', () => {
|
||
|
|
let capturedProjection = null;
|
||
|
|
const engine = makeFakeEngine();
|
||
|
|
engine.setProjection = (p) => { capturedProjection = p; };
|
||
|
|
|
||
|
|
const proj = {
|
||
|
|
velocityCurve: 'sCurve',
|
||
|
|
gateThreshold: 0.4,
|
||
|
|
pitchRange: { low: 24, high: 96 },
|
||
|
|
};
|
||
|
|
|
||
|
|
restoreShapeSeqState(engine, {
|
||
|
|
version: 1,
|
||
|
|
chain: null,
|
||
|
|
projection: proj,
|
||
|
|
voiceMode: null,
|
||
|
|
clock: null,
|
||
|
|
sequenceWeights: null,
|
||
|
|
freeze: null,
|
||
|
|
});
|
||
|
|
|
||
|
|
assert.deepStrictEqual(capturedProjection, proj);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('restores voice mode', () => {
|
||
|
|
let capturedMode = null;
|
||
|
|
const engine = makeFakeEngine();
|
||
|
|
engine.setVoiceMode = (m) => { capturedMode = m; };
|
||
|
|
|
||
|
|
restoreShapeSeqState(engine, {
|
||
|
|
version: 1,
|
||
|
|
chain: null,
|
||
|
|
projection: null,
|
||
|
|
voiceMode: 'poly',
|
||
|
|
clock: null,
|
||
|
|
sequenceWeights: null,
|
||
|
|
freeze: null,
|
||
|
|
});
|
||
|
|
|
||
|
|
assert.strictEqual(capturedMode, 'poly');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('restores clock BPM', () => {
|
||
|
|
let capturedBPM = null;
|
||
|
|
const engine = makeFakeEngine();
|
||
|
|
engine.setTempo = (t) => { capturedBPM = t; };
|
||
|
|
|
||
|
|
restoreShapeSeqState(engine, {
|
||
|
|
version: 1,
|
||
|
|
chain: null,
|
||
|
|
projection: null,
|
||
|
|
voiceMode: null,
|
||
|
|
clock: { bpm: 90 },
|
||
|
|
sequenceWeights: null,
|
||
|
|
freeze: null,
|
||
|
|
});
|
||
|
|
|
||
|
|
assert.strictEqual(capturedBPM, 90);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('restores sequence MLP weights when IML available', () => {
|
||
|
|
const weights = [[5, 6], [7, 8]];
|
||
|
|
const engine = makeFakeEngine({ sequenceWeights: [[0, 0]] });
|
||
|
|
|
||
|
|
restoreShapeSeqState(engine, {
|
||
|
|
version: 1,
|
||
|
|
chain: null,
|
||
|
|
projection: null,
|
||
|
|
voiceMode: null,
|
||
|
|
clock: null,
|
||
|
|
sequenceWeights: weights,
|
||
|
|
freeze: null,
|
||
|
|
});
|
||
|
|
|
||
|
|
// The stub's setWeights should have been called
|
||
|
|
assert.deepStrictEqual(engine.getSequenceIML().getWeights(), weights);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('skips unknown primitive names gracefully', () => {
|
||
|
|
const engine = makeFakeEngine({ primitives: [] });
|
||
|
|
|
||
|
|
const state = {
|
||
|
|
version: 1,
|
||
|
|
chain: {
|
||
|
|
primitives: [
|
||
|
|
{ name: 'EuclideanRhythm' },
|
||
|
|
{ name: 'NonExistentPrimitive' },
|
||
|
|
{ name: 'PitchWalker' },
|
||
|
|
],
|
||
|
|
states: null,
|
||
|
|
generatorCombineMode: 'additive',
|
||
|
|
},
|
||
|
|
projection: null,
|
||
|
|
voiceMode: null,
|
||
|
|
clock: null,
|
||
|
|
sequenceWeights: null,
|
||
|
|
freeze: null,
|
||
|
|
};
|
||
|
|
|
||
|
|
// Should not throw
|
||
|
|
restoreShapeSeqState(engine, state);
|
||
|
|
|
||
|
|
// Unknown primitive skipped, only valid ones added
|
||
|
|
const prims = engine.getChain().getPrimitives();
|
||
|
|
assert.strictEqual(prims.length, 2);
|
||
|
|
assert.strictEqual(prims[0].name, 'EuclideanRhythm');
|
||
|
|
assert.strictEqual(prims[1].name, 'PitchWalker');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('handles null state without throwing', () => {
|
||
|
|
const engine = makeFakeEngine();
|
||
|
|
// Should not throw
|
||
|
|
restoreShapeSeqState(engine, null);
|
||
|
|
restoreShapeSeqState(engine, undefined);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('handles wrong version without throwing', () => {
|
||
|
|
const engine = makeFakeEngine();
|
||
|
|
restoreShapeSeqState(engine, { version: 99 });
|
||
|
|
// Chain should be untouched
|
||
|
|
assert.strictEqual(engine.getChain().getPrimitives().length, 5);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('handles state with missing optional fields', () => {
|
||
|
|
const engine = makeFakeEngine({ primitives: [] });
|
||
|
|
|
||
|
|
// Minimal valid state — only version and chain
|
||
|
|
restoreShapeSeqState(engine, {
|
||
|
|
version: 1,
|
||
|
|
chain: {
|
||
|
|
primitives: [{ name: 'EuclideanRhythm' }],
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
assert.strictEqual(engine.getChain().getPrimitives().length, 1);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('restores primitive states alongside chain rebuild', () => {
|
||
|
|
const engine = makeFakeEngine({ primitives: [] });
|
||
|
|
|
||
|
|
const state = {
|
||
|
|
version: 1,
|
||
|
|
chain: {
|
||
|
|
primitives: [{ name: 'PitchWalker' }],
|
||
|
|
states: [{ position: 0.75 }],
|
||
|
|
generatorCombineMode: 'additive',
|
||
|
|
},
|
||
|
|
projection: null,
|
||
|
|
voiceMode: null,
|
||
|
|
clock: null,
|
||
|
|
sequenceWeights: null,
|
||
|
|
freeze: null,
|
||
|
|
};
|
||
|
|
|
||
|
|
restoreShapeSeqState(engine, state);
|
||
|
|
|
||
|
|
const chainStates = engine.getChain().getState();
|
||
|
|
assert.strictEqual(chainStates[0].position, 0.75);
|
||
|
|
});
|
||
|
|
});
|