feat(shapeseq): polyrhythm — per-generator step counts with LCM tiling
Generators can now have independent step counts. When merging patterns of different lengths, both are tiled to their LCM. Chain exposes setGeneratorStepCount/getGeneratorStepCount for polyrhythmic control.
This commit is contained in:
parent
aec58537db
commit
b4b4025f59
4 changed files with 549 additions and 10 deletions
|
|
@ -32,6 +32,14 @@ export class Chain {
|
|||
/** @type {'additive'|'multiplicative'} */
|
||||
this.generatorCombineMode = 'additive';
|
||||
|
||||
/**
|
||||
* Per-generator step counts for polyrhythm support.
|
||||
* Maps chain index → step count. Generators not in this map
|
||||
* use the global stepCount passed to evaluate().
|
||||
* @private @type {Map<number, number>}
|
||||
*/
|
||||
this._generatorStepCounts = new Map();
|
||||
|
||||
/** @private @type {number} */
|
||||
this._masterSeed = 0;
|
||||
|
||||
|
|
@ -111,6 +119,45 @@ export class Chain {
|
|||
return this._primitives.slice();
|
||||
}
|
||||
|
||||
// ── Per-generator step counts (polyrhythm) ─────────────────────
|
||||
|
||||
/**
|
||||
* Set a per-generator step count for polyrhythm.
|
||||
* The chain index must refer to a primitive in the chain.
|
||||
*
|
||||
* @param {number} chainIndex - Index in the chain's primitive list
|
||||
* @param {number} stepCount - Step count for this generator (positive integer)
|
||||
*/
|
||||
setGeneratorStepCount(chainIndex, stepCount) {
|
||||
const idx = chainIndex | 0;
|
||||
const sc = stepCount | 0;
|
||||
if (idx < 0 || idx >= this._primitives.length) {
|
||||
throw new RangeError('setGeneratorStepCount: chainIndex ' + chainIndex + ' out of range [0, ' + (this._primitives.length - 1) + ']');
|
||||
}
|
||||
if (sc < 1) {
|
||||
throw new RangeError('setGeneratorStepCount: stepCount must be >= 1, got ' + stepCount);
|
||||
}
|
||||
this._generatorStepCounts.set(idx, sc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the per-generator step count, or null if using global.
|
||||
*
|
||||
* @param {number} chainIndex
|
||||
* @returns {number|null}
|
||||
*/
|
||||
getGeneratorStepCount(chainIndex) {
|
||||
const idx = chainIndex | 0;
|
||||
return this._generatorStepCounts.has(idx) ? this._generatorStepCounts.get(idx) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all per-generator step counts, reverting to global stepCount.
|
||||
*/
|
||||
clearGeneratorStepCounts() {
|
||||
this._generatorStepCounts.clear();
|
||||
}
|
||||
|
||||
// ── Configuration ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
@ -161,9 +208,10 @@ export class Chain {
|
|||
* @param {Float32Array|Array<number>} params - flat param array distributed across primitives
|
||||
* @param {number} stepCount - number of steps in the output pattern
|
||||
* @param {number} masterSeed - seed for the master PRNG
|
||||
* @param {Map<number,number>|null} [generatorStepCounts=null] - optional per-generator step counts (chain index → step count). If null, uses this._generatorStepCounts.
|
||||
* @returns {{ steps: Array, stepCount: number, metadata: Object }}
|
||||
*/
|
||||
evaluate(params, stepCount, masterSeed) {
|
||||
evaluate(params, stepCount, masterSeed, generatorStepCounts = null) {
|
||||
const primitives = this._primitives;
|
||||
const primCount = primitives.length;
|
||||
|
||||
|
|
@ -230,6 +278,18 @@ export class Chain {
|
|||
|
||||
// ── Phase 1: Generators ──
|
||||
|
||||
// Resolve per-generator step counts: explicit arg > instance map > global
|
||||
const genStepMap = generatorStepCounts || this._generatorStepCounts;
|
||||
|
||||
/** Look up the step count for a generator by its chain index. */
|
||||
function genStepsFor(chainIndex) {
|
||||
if (genStepMap && genStepMap.size > 0) {
|
||||
const override = genStepMap.get(chainIndex);
|
||||
if (override != null) return override;
|
||||
}
|
||||
return stepCount;
|
||||
}
|
||||
|
||||
let pattern;
|
||||
|
||||
if (generators.length === 0) {
|
||||
|
|
@ -240,12 +300,14 @@ export class Chain {
|
|||
}
|
||||
} else if (generators.length === 1) {
|
||||
// Single generator — no merge needed
|
||||
pattern = runPrimitive(generators[0], createPattern(stepCount));
|
||||
const gs = genStepsFor(generators[0].index);
|
||||
pattern = runPrimitive(generators[0], createPattern(gs));
|
||||
} else {
|
||||
// Multiple generators — run each, then merge
|
||||
let merged = runPrimitive(generators[0], createPattern(stepCount));
|
||||
// Multiple generators — run each with its own step count, then merge
|
||||
let merged = runPrimitive(generators[0], createPattern(genStepsFor(generators[0].index)));
|
||||
for (let g = 1; g < generators.length; g++) {
|
||||
const next = runPrimitive(generators[g], createPattern(stepCount));
|
||||
const gs = genStepsFor(generators[g].index);
|
||||
const next = runPrimitive(generators[g], createPattern(gs));
|
||||
merged = mergePatterns(merged, next, this.generatorCombineMode);
|
||||
}
|
||||
pattern = merged;
|
||||
|
|
|
|||
|
|
@ -100,10 +100,94 @@ export function clonePattern(pattern) {
|
|||
};
|
||||
}
|
||||
|
||||
// --- Polyrhythm utilities ---
|
||||
|
||||
/**
|
||||
* Greatest Common Divisor (Euclidean algorithm).
|
||||
* @param {number} a
|
||||
* @param {number} b
|
||||
* @returns {number}
|
||||
*/
|
||||
function gcd(a, b) { while (b) { [a, b] = [b, a % b]; } return a; }
|
||||
|
||||
/**
|
||||
* Least Common Multiple.
|
||||
* @param {number} a
|
||||
* @param {number} b
|
||||
* @returns {number}
|
||||
*/
|
||||
export function lcm(a, b) { return (a / gcd(a, b)) * b; }
|
||||
|
||||
/**
|
||||
* Tile (repeat/wrap) a pattern to fill a longer step count.
|
||||
* Returns a new pattern — does not mutate the input.
|
||||
*
|
||||
* @param {{ steps: Array, stepCount: number, metadata: Object }} pattern
|
||||
* @param {number} targetStepCount - Must be >= pattern.stepCount
|
||||
* @returns {{ steps: Array, stepCount: number, metadata: Object }}
|
||||
*/
|
||||
export function tilePattern(pattern, targetStepCount) {
|
||||
const target = targetStepCount | 0;
|
||||
if (target < pattern.stepCount) {
|
||||
throw new RangeError(
|
||||
'targetStepCount (' + target + ') must be >= pattern.stepCount (' + pattern.stepCount + ')'
|
||||
);
|
||||
}
|
||||
if (target === pattern.stepCount) {
|
||||
return clonePattern(pattern);
|
||||
}
|
||||
|
||||
const srcSteps = pattern.steps;
|
||||
const srcCount = pattern.stepCount;
|
||||
const steps = new Array(target);
|
||||
|
||||
for (let i = 0; i < target; i++) {
|
||||
const s = srcSteps[i % srcCount];
|
||||
steps[i] = {
|
||||
trigger: s.trigger,
|
||||
pitch: s.pitch,
|
||||
velocity: s.velocity,
|
||||
accent: s.accent,
|
||||
timeOffset: s.timeOffset,
|
||||
subdivisions: s.subdivisions,
|
||||
midiNote: s.midiNote,
|
||||
};
|
||||
}
|
||||
|
||||
// Shallow clone metadata
|
||||
const srcMeta = pattern.metadata;
|
||||
const metadata = {};
|
||||
const keys = Object.keys(srcMeta);
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
metadata[keys[i]] = srcMeta[keys[i]];
|
||||
}
|
||||
|
||||
return {
|
||||
steps: steps,
|
||||
stepCount: target,
|
||||
metadata: metadata,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the LCM of all step counts in an array of patterns.
|
||||
*
|
||||
* @param {Array<{ stepCount: number }>} patterns
|
||||
* @returns {number}
|
||||
*/
|
||||
export function lcmOfPatterns(patterns) {
|
||||
if (patterns.length === 0) return 1;
|
||||
let result = patterns[0].stepCount;
|
||||
for (let i = 1; i < patterns.length; i++) {
|
||||
result = lcm(result, patterns[i].stepCount);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge two patterns together.
|
||||
*
|
||||
* Both patterns must have the same stepCount.
|
||||
* If step counts differ, both patterns are tiled to their LCM before merging.
|
||||
* Returns a new pattern (does not mutate inputs).
|
||||
*
|
||||
* @param {{ steps: Array, stepCount: number, metadata: Object }} patternA
|
||||
|
|
@ -114,11 +198,11 @@ export function clonePattern(pattern) {
|
|||
* @returns {{ steps: Array, stepCount: number, metadata: Object }}
|
||||
*/
|
||||
export function mergePatterns(patternA, patternB, mode) {
|
||||
// Tile to LCM if step counts differ
|
||||
if (patternA.stepCount !== patternB.stepCount) {
|
||||
throw new RangeError(
|
||||
'Cannot merge patterns with different step counts: ' +
|
||||
patternA.stepCount + ' vs ' + patternB.stepCount
|
||||
);
|
||||
const target = lcm(patternA.stepCount, patternB.stepCount);
|
||||
patternA = tilePattern(patternA, target);
|
||||
patternB = tilePattern(patternB, target);
|
||||
}
|
||||
if (mode !== 'additive' && mode !== 'multiplicative') {
|
||||
throw new TypeError("mode must be 'additive' or 'multiplicative', got '" + mode + "'");
|
||||
|
|
|
|||
210
playground/js/shapeseq/tests/polyrhythm-chain.test.js
Normal file
210
playground/js/shapeseq/tests/polyrhythm-chain.test.js
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
183
playground/js/shapeseq/tests/polyrhythm.test.js
Normal file
183
playground/js/shapeseq/tests/polyrhythm.test.js
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
import { describe, it } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import {
|
||||
createPattern, setStep, clonePattern, mergePatterns,
|
||||
lcm, tilePattern, lcmOfPatterns,
|
||||
} from '../pattern.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// lcm
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('lcm', () => {
|
||||
it('lcm(3, 4) = 12', () => assert.equal(lcm(3, 4), 12));
|
||||
it('lcm(5, 8) = 40', () => assert.equal(lcm(5, 8), 40));
|
||||
it('lcm(6, 6) = 6', () => assert.equal(lcm(6, 6), 6));
|
||||
it('lcm(1, 7) = 7', () => assert.equal(lcm(1, 7), 7));
|
||||
it('lcm(7, 1) = 7', () => assert.equal(lcm(7, 1), 7));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tilePattern
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('tilePattern', () => {
|
||||
/** Build a 3-step pattern with distinct triggers on each step. */
|
||||
function make3StepPattern() {
|
||||
const p = createPattern(3);
|
||||
setStep(p, 0, { trigger: true, pitch: 0.1, velocity: 0.2, accent: true, timeOffset: 0.05, subdivisions: 2, midiNote: 60 });
|
||||
setStep(p, 1, { trigger: false, pitch: 0.3, velocity: 0.4, accent: false, timeOffset: -0.1, subdivisions: 1, midiNote: null });
|
||||
setStep(p, 2, { trigger: true, pitch: 0.9, velocity: 0.8, accent: true, timeOffset: 0.0, subdivisions: 3, midiNote: 72 });
|
||||
return p;
|
||||
}
|
||||
|
||||
it('tiles a 3-step pattern to 12 steps by wrapping', () => {
|
||||
const src = make3StepPattern();
|
||||
const tiled = tilePattern(src, 12);
|
||||
|
||||
assert.equal(tiled.stepCount, 12);
|
||||
assert.equal(tiled.steps.length, 12);
|
||||
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const srcStep = src.steps[i % 3];
|
||||
const tiledStep = tiled.steps[i];
|
||||
assert.equal(tiledStep.trigger, srcStep.trigger, `step ${i} trigger`);
|
||||
assert.equal(tiledStep.pitch, srcStep.pitch, `step ${i} pitch`);
|
||||
assert.equal(tiledStep.velocity, srcStep.velocity, `step ${i} velocity`);
|
||||
assert.equal(tiledStep.accent, srcStep.accent, `step ${i} accent`);
|
||||
assert.equal(tiledStep.timeOffset, srcStep.timeOffset, `step ${i} timeOffset`);
|
||||
assert.equal(tiledStep.subdivisions, srcStep.subdivisions, `step ${i} subdivisions`);
|
||||
assert.equal(tiledStep.midiNote, srcStep.midiNote, `step ${i} midiNote`);
|
||||
}
|
||||
});
|
||||
|
||||
it('same length returns a clone (no-op)', () => {
|
||||
const src = make3StepPattern();
|
||||
const tiled = tilePattern(src, 3);
|
||||
|
||||
assert.equal(tiled.stepCount, 3);
|
||||
// Must be a separate object (clone), not the same reference
|
||||
assert.notEqual(tiled, src);
|
||||
assert.notEqual(tiled.steps, src.steps);
|
||||
assert.notEqual(tiled.steps[0], src.steps[0]);
|
||||
// But values match
|
||||
assert.equal(tiled.steps[0].pitch, src.steps[0].pitch);
|
||||
});
|
||||
|
||||
it('preserves all step fields (trigger, pitch, velocity, accent, timeOffset, subdivisions, midiNote)', () => {
|
||||
const src = make3StepPattern();
|
||||
const tiled = tilePattern(src, 6);
|
||||
|
||||
const step0 = tiled.steps[0];
|
||||
assert.equal(step0.trigger, true);
|
||||
assert.equal(step0.pitch, 0.1);
|
||||
assert.equal(step0.velocity, 0.2);
|
||||
assert.equal(step0.accent, true);
|
||||
assert.equal(step0.timeOffset, 0.05);
|
||||
assert.equal(step0.subdivisions, 2);
|
||||
assert.equal(step0.midiNote, 60);
|
||||
|
||||
// Step 4 should be a copy of step 1 (4 % 3 = 1)
|
||||
const step4 = tiled.steps[4];
|
||||
assert.equal(step4.trigger, false);
|
||||
assert.equal(step4.pitch, 0.3);
|
||||
assert.equal(step4.velocity, 0.4);
|
||||
assert.equal(step4.midiNote, null);
|
||||
});
|
||||
|
||||
it('throws if targetStepCount < pattern.stepCount', () => {
|
||||
const src = make3StepPattern();
|
||||
assert.throws(
|
||||
() => tilePattern(src, 2),
|
||||
{ name: 'RangeError' }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mergePatterns — backward compatibility (same step counts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('mergePatterns — same step count (backward compat)', () => {
|
||||
it('merges two 4-step patterns additively', () => {
|
||||
const a = createPattern(4);
|
||||
setStep(a, 0, { trigger: true, pitch: 0.8, velocity: 0.6 });
|
||||
setStep(a, 2, { trigger: true, pitch: 0.3, velocity: 0.9 });
|
||||
|
||||
const b = createPattern(4);
|
||||
setStep(b, 1, { trigger: true, pitch: 0.5, velocity: 0.7 });
|
||||
setStep(b, 2, { trigger: true, pitch: 0.7, velocity: 0.5 });
|
||||
|
||||
const merged = mergePatterns(a, b, 'additive');
|
||||
assert.equal(merged.stepCount, 4);
|
||||
assert.equal(merged.steps[0].trigger, true); // A only
|
||||
assert.equal(merged.steps[1].trigger, true); // B only
|
||||
assert.equal(merged.steps[2].trigger, true); // both
|
||||
assert.equal(merged.steps[3].trigger, false); // neither
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mergePatterns — different step counts (polyrhythm)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('mergePatterns — different step counts (polyrhythm)', () => {
|
||||
it('3+4 step patterns produce LCM=12 length result', () => {
|
||||
const a = createPattern(3);
|
||||
setStep(a, 0, { trigger: true, pitch: 0.2, velocity: 0.8 });
|
||||
// Steps 1, 2 are default (trigger: false)
|
||||
|
||||
const b = createPattern(4);
|
||||
setStep(b, 0, { trigger: true, pitch: 0.6, velocity: 0.4 });
|
||||
// Steps 1, 2, 3 are default (trigger: false)
|
||||
|
||||
const merged = mergePatterns(a, b, 'additive');
|
||||
assert.equal(merged.stepCount, 12);
|
||||
assert.equal(merged.steps.length, 12);
|
||||
});
|
||||
|
||||
it('tiled pattern has correct trigger patterns from both inputs', () => {
|
||||
// A: 3 steps, triggers on 0 and 2 → tiled to 12: triggers at 0,2,3,5,6,8,9,11
|
||||
const a = createPattern(3);
|
||||
setStep(a, 0, { trigger: true });
|
||||
setStep(a, 2, { trigger: true });
|
||||
|
||||
// B: 4 steps, trigger on 1 → tiled to 12: triggers at 1,5,9
|
||||
const b = createPattern(4);
|
||||
setStep(b, 1, { trigger: true });
|
||||
|
||||
const merged = mergePatterns(a, b, 'additive');
|
||||
assert.equal(merged.stepCount, 12);
|
||||
|
||||
// A triggers at positions where i%3 is 0 or 2: 0,2,3,5,6,8,9,11
|
||||
// B triggers at positions where i%4 is 1: 1,5,9
|
||||
// Combined (additive = OR): 0,1,2,3,5,6,8,9,11
|
||||
const expectedTriggers = [true, true, true, true, false, true, true, false, true, true, false, true];
|
||||
for (let i = 0; i < 12; i++) {
|
||||
assert.equal(merged.steps[i].trigger, expectedTriggers[i], `step ${i} trigger`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// lcmOfPatterns
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('lcmOfPatterns', () => {
|
||||
it('single pattern returns its stepCount', () => {
|
||||
const p = createPattern(5);
|
||||
assert.equal(lcmOfPatterns([p]), 5);
|
||||
});
|
||||
|
||||
it('multiple patterns returns LCM of all step counts', () => {
|
||||
const a = createPattern(3);
|
||||
const b = createPattern(4);
|
||||
const c = createPattern(6);
|
||||
// lcm(3,4) = 12, lcm(12,6) = 12
|
||||
assert.equal(lcmOfPatterns([a, b, c]), 12);
|
||||
});
|
||||
|
||||
it('empty array returns 1', () => {
|
||||
assert.equal(lcmOfPatterns([]), 1);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue