memlnaut-nisps/playground/js/shapeseq/tests/session.test.js
w1n5t0n 9ebfec8814 feat(shapeseq): session serialization + preset integration
Add serializeShapeSeqState/restoreShapeSeqState for complete ShapeSeq
state capture (chain config, primitive states, projection knobs, voice
mode, MLP weights, freeze state, clock config). SessionPresetManager
lazily imports the session module when a ShapeSeq engine is registered,
including ShapeSeq state in save/load without a hard dependency.
2026-04-07 00:01:12 +01:00

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