/** * ShapeSeq Pattern Description Data Structure * * The foundational data type for the ShapeSeq sequencing system. * A pattern description is the symbolic output of the primitive chain -- * a complete loop description that the clock engine steps through. * * Port-ready: typed arrays where possible, explicit construction, no closures. * * @module shapeseq/pattern */ // --- Step defaults --- const DEFAULT_TRIGGER = false; const DEFAULT_PITCH = 0.5; const DEFAULT_VELOCITY = 0.7; const DEFAULT_ACCENT = false; const DEFAULT_TIME_OFFSET = 0.0; const DEFAULT_SUBDIVISIONS = 1; /** * Create a single step with default values. * * @returns {{ trigger: boolean, pitch: number, velocity: number, accent: boolean, timeOffset: number, subdivisions: number }} */ export function createStep() { return { trigger: DEFAULT_TRIGGER, pitch: DEFAULT_PITCH, velocity: DEFAULT_VELOCITY, accent: DEFAULT_ACCENT, timeOffset: DEFAULT_TIME_OFFSET, subdivisions: DEFAULT_SUBDIVISIONS, midiNote: null, }; } /** * Create a pattern description with the given step count, all steps at defaults. * * @param {number} stepCount - Number of steps (must be positive integer) * @returns {{ steps: Array, stepCount: number, metadata: Object }} */ export function createPattern(stepCount) { const count = stepCount | 0; // coerce to int if (count < 1) { throw new RangeError('stepCount must be >= 1, got ' + stepCount); } const steps = new Array(count); for (let i = 0; i < count; i++) { steps[i] = createStep(); } return { steps: steps, stepCount: count, metadata: {}, }; } /** * Deep clone a pattern description. * Needed for chain processing where each primitive transforms a copy. * * @param {{ steps: Array, stepCount: number, metadata: Object }} pattern * @returns {{ steps: Array, stepCount: number, metadata: Object }} */ export function clonePattern(pattern) { const count = pattern.stepCount; const srcSteps = pattern.steps; const steps = new Array(count); for (let i = 0; i < count; i++) { const s = srcSteps[i]; 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 (one level deep for plain-object 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: count, metadata: metadata, }; } // --- 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. * * 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 * @param {{ steps: Array, stepCount: number, metadata: Object }} patternB * @param {'additive'|'multiplicative'} mode * - 'additive': triggers OR'd, pitch/velocity averaged * - 'multiplicative': triggers AND'd, pitch/velocity averaged * @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) { 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 + "'"); } const count = patternA.stepCount; const stepsA = patternA.steps; const stepsB = patternB.steps; const steps = new Array(count); const isAdditive = mode === 'additive'; for (let i = 0; i < count; i++) { const a = stepsA[i]; const b = stepsB[i]; const trigger = isAdditive ? (a.trigger || b.trigger) : (a.trigger && b.trigger); const bothTriggered = a.trigger && b.trigger; let pitch, velocity, accent, timeOffset, subdivisions, midiNote; if (bothTriggered) { // Both generators triggered: average continuous values, combine accent by mode pitch = (a.pitch + b.pitch) * 0.5; velocity = (a.velocity + b.velocity) * 0.5; accent = isAdditive ? (a.accent || b.accent) : (a.accent && b.accent); timeOffset = (a.timeOffset + b.timeOffset) * 0.5; subdivisions = Math.max(a.subdivisions, b.subdivisions); // midiNote: both set → average (rounded), one set → use it, neither → null if (a.midiNote != null && b.midiNote != null) { midiNote = Math.round((a.midiNote + b.midiNote) * 0.5); } else if (a.midiNote != null) { midiNote = a.midiNote; } else if (b.midiNote != null) { midiNote = b.midiNote; } else { midiNote = null; } } else if (a.trigger) { // Only A triggered: use A's values directly pitch = a.pitch; velocity = a.velocity; accent = a.accent; timeOffset = a.timeOffset; subdivisions = a.subdivisions; midiNote = a.midiNote; } else if (b.trigger) { // Only B triggered: use B's values directly pitch = b.pitch; velocity = b.velocity; accent = b.accent; timeOffset = b.timeOffset; subdivisions = b.subdivisions; midiNote = b.midiNote; } else { // Neither triggered: defaults pitch = DEFAULT_PITCH; velocity = DEFAULT_VELOCITY; accent = DEFAULT_ACCENT; timeOffset = DEFAULT_TIME_OFFSET; subdivisions = DEFAULT_SUBDIVISIONS; midiNote = null; } steps[i] = { trigger: trigger, pitch: pitch, velocity: velocity, accent: accent, timeOffset: timeOffset, subdivisions: subdivisions, midiNote: midiNote, }; } return { steps: steps, stepCount: count, metadata: {}, }; } /** * Update a specific step in a pattern (mutates the pattern in place). * * Only the fields present in stepData are updated; others remain unchanged. * * @param {{ steps: Array, stepCount: number, metadata: Object }} pattern * @param {number} index - Step index (0-based) * @param {Object} stepData - Partial step data to apply */ export function setStep(pattern, index, stepData) { const idx = index | 0; if (idx < 0 || idx >= pattern.stepCount) { throw new RangeError('Step index ' + index + ' out of range [0, ' + (pattern.stepCount - 1) + ']'); } const step = pattern.steps[idx]; if (stepData.trigger !== undefined) step.trigger = !!stepData.trigger; if (stepData.pitch !== undefined) step.pitch = +stepData.pitch; if (stepData.velocity !== undefined) step.velocity = +stepData.velocity; if (stepData.accent !== undefined) step.accent = !!stepData.accent; if (stepData.timeOffset !== undefined) step.timeOffset = +stepData.timeOffset; if (stepData.subdivisions !== undefined) step.subdivisions = stepData.subdivisions | 0; if (stepData.midiNote !== undefined) step.midiNote = stepData.midiNote === null ? null : stepData.midiNote | 0; } /** * Validate a pattern description structure. * * Checks: * - pattern is an object with steps array, stepCount int, metadata object * - steps.length === stepCount * - Each step has all required fields with correct types and in-range values * * @param {*} pattern * @returns {boolean} */ export function validatePattern(pattern) { if (pattern == null || typeof pattern !== 'object') return false; if (typeof pattern.stepCount !== 'number' || (pattern.stepCount | 0) < 1) return false; if (pattern.stepCount !== (pattern.stepCount | 0)) return false; if (!Array.isArray(pattern.steps)) return false; if (pattern.steps.length !== pattern.stepCount) return false; if (pattern.metadata == null || typeof pattern.metadata !== 'object') return false; const steps = pattern.steps; const count = pattern.stepCount; for (let i = 0; i < count; i++) { const s = steps[i]; if (s == null || typeof s !== 'object') return false; if (typeof s.trigger !== 'boolean') return false; if (typeof s.pitch !== 'number' || s.pitch < 0 || s.pitch > 1) return false; if (typeof s.velocity !== 'number' || s.velocity < 0 || s.velocity > 1) return false; if (typeof s.accent !== 'boolean') return false; if (typeof s.timeOffset !== 'number' || s.timeOffset < -0.5 || s.timeOffset > 0.5) return false; if (typeof s.subdivisions !== 'number' || (s.subdivisions | 0) < 1 || (s.subdivisions | 0) > 4) return false; if (s.subdivisions !== (s.subdivisions | 0)) return false; // midiNote: null (pre-quantization) or integer 0-127 if (s.midiNote !== null) { if (typeof s.midiNote !== 'number') return false; if (s.midiNote !== (s.midiNote | 0)) return false; if (s.midiNote < 0 || s.midiNote > 127) return false; } } return true; }