211 lines
7.9 KiB
JavaScript
211 lines
7.9 KiB
JavaScript
|
|
import { describe, it } from 'node:test';
|
||
|
|
import { strict as assert } from 'node:assert';
|
||
|
|
|
||
|
|
import { Chain } from '../chain.js';
|
||
|
|
import { EuclideanRhythm, DensityMorph, VelocityShaper } from '../primitives.js';
|
||
|
|
import { lcm } from '../pattern.js';
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Helpers
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Build a chain with two EuclideanRhythm generators.
|
||
|
|
* Returns { chain, gen0Index, gen1Index }.
|
||
|
|
*/
|
||
|
|
function twoGeneratorChain() {
|
||
|
|
const chain = new Chain();
|
||
|
|
chain.addPrimitive(new EuclideanRhythm()); // index 0
|
||
|
|
chain.addPrimitive(new EuclideanRhythm()); // index 1
|
||
|
|
return { chain, gen0Index: 0, gen1Index: 1 };
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Count triggered steps in a pattern.
|
||
|
|
*/
|
||
|
|
function countTriggers(pattern) {
|
||
|
|
let count = 0;
|
||
|
|
for (let i = 0; i < pattern.stepCount; i++) {
|
||
|
|
if (pattern.steps[i].trigger) count++;
|
||
|
|
}
|
||
|
|
return count;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Default behavior: all generators use global stepCount (backward compat)
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
describe('polyrhythm chain — backward compatibility', () => {
|
||
|
|
it('without per-generator step counts, all generators use global stepCount', () => {
|
||
|
|
const { chain } = twoGeneratorChain();
|
||
|
|
|
||
|
|
// Both generators: steps=1.0 (max), pulses=0.5, rotation=0
|
||
|
|
// With stepCount=8, steps param maps to 8, pulses maps to ~4
|
||
|
|
const params = new Float32Array([1.0, 0.5, 0.0, 1.0, 0.5, 0.0]);
|
||
|
|
const pattern = chain.evaluate(params, 8, 42);
|
||
|
|
|
||
|
|
// Both use the same stepCount, so merged pattern should be 8 steps
|
||
|
|
assert.equal(pattern.stepCount, 8);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('clearGeneratorStepCounts resets to default behavior', () => {
|
||
|
|
const { chain, gen0Index } = twoGeneratorChain();
|
||
|
|
|
||
|
|
// Set a custom step count
|
||
|
|
chain.setGeneratorStepCount(gen0Index, 3);
|
||
|
|
assert.equal(chain.getGeneratorStepCount(gen0Index), 3);
|
||
|
|
|
||
|
|
// Clear
|
||
|
|
chain.clearGeneratorStepCounts();
|
||
|
|
assert.equal(chain.getGeneratorStepCount(gen0Index), null);
|
||
|
|
|
||
|
|
// Evaluate — should use global stepCount for both
|
||
|
|
const params = new Float32Array([1.0, 0.5, 0.0, 1.0, 0.5, 0.0]);
|
||
|
|
const pattern = chain.evaluate(params, 8, 42);
|
||
|
|
assert.equal(pattern.stepCount, 8);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Per-generator step counts
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
describe('polyrhythm chain — per-generator step counts', () => {
|
||
|
|
it('two generators with different step counts produce LCM-length pattern', () => {
|
||
|
|
const { chain, gen0Index, gen1Index } = twoGeneratorChain();
|
||
|
|
|
||
|
|
chain.setGeneratorStepCount(gen0Index, 3);
|
||
|
|
chain.setGeneratorStepCount(gen1Index, 4);
|
||
|
|
|
||
|
|
// All pulses high so we get triggers
|
||
|
|
const params = new Float32Array([1.0, 1.0, 0.0, 1.0, 1.0, 0.0]);
|
||
|
|
const pattern = chain.evaluate(params, 8, 42);
|
||
|
|
|
||
|
|
// LCM(3, 4) = 12
|
||
|
|
assert.equal(pattern.stepCount, lcm(3, 4));
|
||
|
|
assert.equal(pattern.stepCount, 12);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('triggers from both generators are present in merged output', () => {
|
||
|
|
const { chain, gen0Index, gen1Index } = twoGeneratorChain();
|
||
|
|
|
||
|
|
// Gen0: 3 steps with all triggers (steps=1.0 maps to max=3, pulses=1.0 fills all)
|
||
|
|
chain.setGeneratorStepCount(gen0Index, 3);
|
||
|
|
// Gen1: 4 steps with all triggers
|
||
|
|
chain.setGeneratorStepCount(gen1Index, 4);
|
||
|
|
|
||
|
|
const params = new Float32Array([1.0, 1.0, 0.0, 1.0, 1.0, 0.0]);
|
||
|
|
const pattern = chain.evaluate(params, 8, 42);
|
||
|
|
|
||
|
|
assert.equal(pattern.stepCount, 12);
|
||
|
|
|
||
|
|
// With all pulses, both generators trigger every step in their respective
|
||
|
|
// cycle lengths. Tiled to 12:
|
||
|
|
// Gen0 (3-step cycle, all on): every step has a trigger
|
||
|
|
// Gen1 (4-step cycle, all on): every step has a trigger
|
||
|
|
// Additive merge: every step should be triggered
|
||
|
|
for (let i = 0; i < 12; i++) {
|
||
|
|
assert.equal(pattern.steps[i].trigger, true, `step ${i} should be triggered`);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
it('only one generator with custom step count, other uses global', () => {
|
||
|
|
const { chain, gen0Index } = twoGeneratorChain();
|
||
|
|
|
||
|
|
// Gen0 gets 3 steps, Gen1 uses global (4)
|
||
|
|
chain.setGeneratorStepCount(gen0Index, 3);
|
||
|
|
|
||
|
|
const params = new Float32Array([1.0, 1.0, 0.0, 1.0, 1.0, 0.0]);
|
||
|
|
const pattern = chain.evaluate(params, 4, 42);
|
||
|
|
|
||
|
|
// LCM(3, 4) = 12
|
||
|
|
assert.equal(pattern.stepCount, 12);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('generatorStepCounts passed as evaluate() argument overrides instance map', () => {
|
||
|
|
const { chain, gen0Index, gen1Index } = twoGeneratorChain();
|
||
|
|
|
||
|
|
// Instance map says 3 and 4
|
||
|
|
chain.setGeneratorStepCount(gen0Index, 3);
|
||
|
|
chain.setGeneratorStepCount(gen1Index, 4);
|
||
|
|
|
||
|
|
// But explicit arg says 5 and 7
|
||
|
|
const explicitMap = new Map([[gen0Index, 5], [gen1Index, 7]]);
|
||
|
|
const params = new Float32Array([1.0, 1.0, 0.0, 1.0, 1.0, 0.0]);
|
||
|
|
const pattern = chain.evaluate(params, 8, 42, explicitMap);
|
||
|
|
|
||
|
|
// LCM(5, 7) = 35
|
||
|
|
assert.equal(pattern.stepCount, lcm(5, 7));
|
||
|
|
assert.equal(pattern.stepCount, 35);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('getGeneratorStepCount returns null for unset generators', () => {
|
||
|
|
const { chain, gen1Index } = twoGeneratorChain();
|
||
|
|
assert.equal(chain.getGeneratorStepCount(gen1Index), null);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('setGeneratorStepCount validates chain index', () => {
|
||
|
|
const { chain } = twoGeneratorChain();
|
||
|
|
assert.throws(() => chain.setGeneratorStepCount(99, 4), { name: 'RangeError' });
|
||
|
|
assert.throws(() => chain.setGeneratorStepCount(-1, 4), { name: 'RangeError' });
|
||
|
|
});
|
||
|
|
|
||
|
|
it('setGeneratorStepCount validates step count', () => {
|
||
|
|
const { chain } = twoGeneratorChain();
|
||
|
|
assert.throws(() => chain.setGeneratorStepCount(0, 0), { name: 'RangeError' });
|
||
|
|
assert.throws(() => chain.setGeneratorStepCount(0, -1), { name: 'RangeError' });
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Processors run on LCM-length pattern
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
describe('polyrhythm chain — processors on LCM-length pattern', () => {
|
||
|
|
it('processor receives and outputs the LCM-length pattern', () => {
|
||
|
|
const chain = new Chain();
|
||
|
|
chain.addPrimitive(new EuclideanRhythm()); // index 0 — generator
|
||
|
|
chain.addPrimitive(new EuclideanRhythm()); // index 1 — generator
|
||
|
|
chain.addPrimitive(new VelocityShaper()); // index 2 — processor
|
||
|
|
|
||
|
|
chain.setGeneratorStepCount(0, 3);
|
||
|
|
chain.setGeneratorStepCount(1, 4);
|
||
|
|
|
||
|
|
// EuclideanRhythm: 3 params each, VelocityShaper: 3 params
|
||
|
|
// Fill all with moderate values
|
||
|
|
const params = new Float32Array([
|
||
|
|
1.0, 1.0, 0.0, // gen0: all triggers
|
||
|
|
1.0, 1.0, 0.0, // gen1: all triggers
|
||
|
|
0.0, 0.5, 0.0, // velocity shaper: flat curve, half depth, no phase
|
||
|
|
]);
|
||
|
|
const pattern = chain.evaluate(params, 8, 42);
|
||
|
|
|
||
|
|
// LCM(3, 4) = 12 — processor should preserve that length
|
||
|
|
assert.equal(pattern.stepCount, 12);
|
||
|
|
|
||
|
|
// Velocity shaper should have modified velocities (not all default 0.7)
|
||
|
|
// With flat curve (idx 0) and depth 0.5, velocity = 0.7 * 0.5 + 1.0 * 0.5 = 0.85
|
||
|
|
const triggeredSteps = pattern.steps.filter(s => s.trigger);
|
||
|
|
assert.ok(triggeredSteps.length > 0, 'should have triggered steps');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Single generator with per-generator step count
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
describe('polyrhythm chain — single generator', () => {
|
||
|
|
it('single generator uses its per-generator step count', () => {
|
||
|
|
const chain = new Chain();
|
||
|
|
chain.addPrimitive(new EuclideanRhythm()); // index 0
|
||
|
|
|
||
|
|
chain.setGeneratorStepCount(0, 5);
|
||
|
|
|
||
|
|
const params = new Float32Array([1.0, 1.0, 0.0]);
|
||
|
|
const pattern = chain.evaluate(params, 8, 42);
|
||
|
|
|
||
|
|
// Single generator with 5 steps — no merge, output is 5 steps
|
||
|
|
assert.equal(pattern.stepCount, 5);
|
||
|
|
});
|
||
|
|
});
|