diff --git a/playground/js/shapeseq/chain.js b/playground/js/shapeseq/chain.js index 7e0f88d..b3364ee 100644 --- a/playground/js/shapeseq/chain.js +++ b/playground/js/shapeseq/chain.js @@ -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} + */ + 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} 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|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; diff --git a/playground/js/shapeseq/pattern.js b/playground/js/shapeseq/pattern.js index f667b30..0da54c2 100644 --- a/playground/js/shapeseq/pattern.js +++ b/playground/js/shapeseq/pattern.js @@ -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 + "'"); diff --git a/playground/js/shapeseq/tests/polyrhythm-chain.test.js b/playground/js/shapeseq/tests/polyrhythm-chain.test.js new file mode 100644 index 0000000..5fda5e3 --- /dev/null +++ b/playground/js/shapeseq/tests/polyrhythm-chain.test.js @@ -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); + }); +}); diff --git a/playground/js/shapeseq/tests/polyrhythm.test.js b/playground/js/shapeseq/tests/polyrhythm.test.js new file mode 100644 index 0000000..e19dccb --- /dev/null +++ b/playground/js/shapeseq/tests/polyrhythm.test.js @@ -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); + }); +});