/** * ShapeSeq Sequencing Primitives * * All 8 primitives for the ShapeSeq generative sequencing system. * Each extends Primitive and implements process(params, patternDesc, state, rng). * * Port-ready: explicit state, no closures, seeded PRNG, typed arrays. * * @module shapeseq/primitives */ import { Primitive } from './primitive.js'; import { createPattern, clonePattern, setStep } from './pattern.js'; import { next, nextInt } from './prng.js'; // ── Helper: map [0,1] float to integer range [lo, hi] ────────────── function mapToInt(value, lo, hi) { const clamped = value < 0 ? 0 : value > 1 ? 1 : value; return lo + Math.floor(clamped * (hi - lo + 1 - 1e-9)); } // ── 1. EuclideanRhythm ────────────────────────────────────────────── /** * Bjorklund algorithm: distribute `pulses` as evenly as possible * across `steps`, then apply rotation. */ function bjorklund(steps, pulses) { if (pulses >= steps) { const result = new Array(steps); for (let i = 0; i < steps; i++) result[i] = true; return result; } if (pulses <= 0) { const result = new Array(steps); for (let i = 0; i < steps; i++) result[i] = false; return result; } // Build pattern using Bjorklund's algorithm let groups = []; for (let i = 0; i < pulses; i++) groups.push([true]); for (let i = 0; i < steps - pulses; i++) groups.push([false]); while (true) { const remainder = groups.length - pulses; if (remainder <= 1) break; const minLen = Math.min(pulses, remainder); const newGroups = []; for (let i = 0; i < minLen; i++) { newGroups.push(groups[i].concat(groups[groups.length - 1 - i])); } // Keep any leftovers const leftStart = minLen; const leftEnd = groups.length - minLen; for (let i = leftStart; i < leftEnd; i++) { newGroups.push(groups[i]); } groups = newGroups; pulses = minLen; if (pulses <= 1) break; } // Flatten groups const result = []; for (let i = 0; i < groups.length; i++) { for (let j = 0; j < groups[i].length; j++) { result.push(groups[i][j]); } } return result; } export class EuclideanRhythm extends Primitive { constructor() { super('EuclideanRhythm', 'generator', [ { name: 'steps', default: 0.5, boundary: 'clamp' }, { name: 'pulses', default: 0.5, boundary: 'clamp' }, { name: 'rotation', default: 0.0, boundary: 'wrap' }, ]); } process(params, patternDesc, state, rng) { const stepCount = patternDesc.stepCount; const steps = mapToInt(params[0], 2, stepCount); const pulses = mapToInt(params[1], 0, steps); const rotation = mapToInt(params[2], 0, steps - 1); const rhythm = bjorklund(steps, pulses); const pattern = createPattern(stepCount); for (let i = 0; i < stepCount; i++) { if (i < steps) { const srcIdx = (i - rotation + steps) % steps; if (rhythm[srcIdx]) { setStep(pattern, i, { trigger: true }); } } // Steps beyond `steps` remain untriggered (default) } return { patternDesc: pattern, nextState: {} }; } } // ── 2. ProbabilityGate ────────────────────────────────────────────── export class ProbabilityGate extends Primitive { constructor() { super('ProbabilityGate', 'processor', [ { name: 'density', default: 0.7, boundary: 'clamp' }, { name: 'accentProbability', default: 0.3, boundary: 'clamp' }, ]); } process(params, patternDesc, state, rng) { const density = params[0]; const accentProb = params[1]; const pattern = clonePattern(patternDesc); let currentRng = rng; for (let i = 0; i < pattern.stepCount; i++) { const step = pattern.steps[i]; if (step.trigger) { // Coin flip for survival const r1 = next(currentRng); currentRng = r1.nextState; if (r1.value >= density) { step.trigger = false; step.accent = false; } else { // Accent coin flip const r2 = next(currentRng); currentRng = r2.nextState; step.accent = r2.value < accentProb; } } } return { patternDesc: pattern, nextState: {} }; } } // ── 3. PitchWalker ────────────────────────────────────────────────── export class PitchWalker extends Primitive { constructor() { super('PitchWalker', 'generator', [ { name: 'stepSize', default: 0.3, boundary: 'clamp' }, { name: 'directionBias', default: 0.5, boundary: 'clamp' }, { name: 'gravity', default: 0.3, boundary: 'clamp' }, { name: 'range', default: 0.8, boundary: 'clamp' }, ]); /** @private */ this._position = 0.5; } getState() { return { position: this._position }; } setState(savedState) { if (savedState && typeof savedState.position === 'number') { this._position = savedState.position; } } process(params, patternDesc, state, rng) { const stepSize = params[0]; const directionBias = params[1]; const gravity = params[2]; const range = params[3]; // Restore position from state if provided let position = (state && typeof state.position === 'number') ? state.position : this._position; const pattern = createPattern(patternDesc.stepCount); let currentRng = rng; // Use incoming pattern's triggers if available, otherwise all triggered const srcSteps = patternDesc.steps; for (let i = 0; i < patternDesc.stepCount; i++) { const triggered = srcSteps[i].trigger; if (triggered) { // Random walk step const r1 = next(currentRng); currentRng = r1.nextState; // Direction: bias + gravity toward center const gravityPull = (0.5 - position) * gravity; const biasOffset = (directionBias - 0.5) * 2; // [-1, 1] const direction = biasOffset + gravityPull; // Random component: [-1, 1] scaled by stepSize const randomComponent = (r1.value * 2 - 1) * stepSize * range; const delta = direction * stepSize * 0.5 + randomComponent; position = position + delta; // Clamp to [0, 1] if (position < 0) position = 0; if (position > 1) position = 1; setStep(pattern, i, { trigger: true, pitch: position }); } // Untriggered steps keep default pitch, trigger=false } this._position = position; return { patternDesc: pattern, nextState: { position: position }, }; } } // ── 4. Ratchet ────────────────────────────────────────────────────── export class Ratchet extends Primitive { constructor() { super('Ratchet', 'timing', [ { name: 'maxDivision', default: 0.5, boundary: 'clamp' }, { name: 'probability', default: 0.5, boundary: 'clamp' }, ]); } process(params, patternDesc, state, rng) { const maxDiv = mapToInt(params[0], 1, 4); const probability = params[1]; const pattern = clonePattern(patternDesc); let currentRng = rng; for (let i = 0; i < pattern.stepCount; i++) { const step = pattern.steps[i]; if (step.trigger) { const r1 = next(currentRng); currentRng = r1.nextState; if (r1.value < probability && maxDiv > 1) { // Pick a subdivision count in [2, maxDiv] const r2 = nextInt(currentRng, 2, maxDiv); currentRng = r2.nextState; step.subdivisions = r2.value; } } } return { patternDesc: pattern, nextState: {} }; } } // ── 5. SwingGroove ────────────────────────────────────────────────── export class SwingGroove extends Primitive { constructor() { super('SwingGroove', 'timing', [ { name: 'swingAmount', default: 0.0, boundary: 'clamp' }, { name: 'swingGrid', default: 0.0, boundary: 'clamp' }, ]); } process(params, patternDesc, state, rng) { const swingAmount = params[0]; const swingGrid = params[1]; const pattern = clonePattern(patternDesc); // Max swing = 0.33 (triplet feel) const maxOffset = 0.33; const offset = swingAmount * maxOffset; // swingGrid selects which subdivision gets swung: // 0.0–0.33: every 2nd step (8th note feel) // 0.34–0.66: every 4th step (16th note feel) // 0.67–1.0: every 3rd step (triplet feel) let period; if (swingGrid < 0.34) { period = 2; } else if (swingGrid < 0.67) { period = 4; } else { period = 3; } // Apply swing to steps that fall on the swing grid for (let i = 0; i < pattern.stepCount; i++) { if (i % period === period - 1) { pattern.steps[i].timeOffset = offset; } } return { patternDesc: pattern, nextState: {} }; } } // ── 6. DensityMorph ───────────────────────────────────────────────── export class DensityMorph extends Primitive { constructor() { super('DensityMorph', 'generator', [ { name: 'density', default: 0.5, boundary: 'clamp' }, { name: 'clustering', default: 0.0, boundary: 'clamp' }, ]); } process(params, patternDesc, state, rng) { const density = params[0]; const clustering = params[1]; const stepCount = patternDesc.stepCount; const pattern = createPattern(stepCount); const numTriggers = Math.floor(density * stepCount); if (numTriggers <= 0) { return { patternDesc: pattern, nextState: {} }; } if (numTriggers >= stepCount) { for (let i = 0; i < stepCount; i++) { setStep(pattern, i, { trigger: true }); } return { patternDesc: pattern, nextState: {} }; } let currentRng = rng; if (clustering < 0.01) { // Even spread: Euclidean-like placement for (let i = 0; i < numTriggers; i++) { const idx = Math.floor((i * stepCount) / numTriggers); setStep(pattern, idx, { trigger: true }); } } else if (clustering > 0.99) { // Full clustering: contiguous burst const r1 = nextInt(currentRng, 0, stepCount - 1); currentRng = r1.nextState; const startPos = r1.value; for (let i = 0; i < numTriggers; i++) { const idx = (startPos + i) % stepCount; setStep(pattern, idx, { trigger: true }); } } else { // Interpolate: place triggers with clustering-dependent spread // Use a "center of mass" approach: // Pick a random center, then distribute triggers around it // with spread inversely proportional to clustering const r1 = next(currentRng); currentRng = r1.nextState; const center = r1.value * stepCount; // Spread factor: low clustering = large spread, high = tight const spreadRadius = (1 - clustering) * stepCount * 0.5; // Score each step by distance from center (wrapping) const scores = new Float32Array(stepCount); for (let i = 0; i < stepCount; i++) { // Wrapped distance from center let dist = Math.abs(i - center); if (dist > stepCount * 0.5) dist = stepCount - dist; // Add small random jitter to break ties const r2 = next(currentRng); currentRng = r2.nextState; scores[i] = dist / (spreadRadius + 0.001) + r2.value * 0.01; } // Select the numTriggers steps with lowest scores const indices = new Array(stepCount); for (let i = 0; i < stepCount; i++) indices[i] = i; indices.sort(function (a, b) { return scores[a] - scores[b]; }); for (let i = 0; i < numTriggers; i++) { setStep(pattern, indices[i], { trigger: true }); } } return { patternDesc: pattern, nextState: {} }; } } // ── 7. IntervalLock ───────────────────────────────────────────────── const SCALES = [ [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], // chromatic [0, 2, 4, 5, 7, 9, 11], // major [0, 2, 3, 5, 7, 8, 10], // natural minor [0, 2, 3, 5, 7, 8, 11], // harmonic minor [0, 2, 4, 7, 9], // pentatonic major [0, 3, 5, 7, 10], // pentatonic minor [0, 3, 5, 6, 7, 10], // blues [0, 2, 3, 5, 7, 9, 10], // dorian [0, 2, 4, 5, 7, 9, 10], // mixolydian [0, 2, 4, 6, 8, 10], // whole tone [0, 2, 3, 5, 6, 8, 9, 11], // diminished ]; export class IntervalLock extends Primitive { constructor() { super('IntervalLock', 'converter', [ { name: 'root', default: 0.0, boundary: 'clamp' }, { name: 'mode', default: 0.0, boundary: 'clamp' }, { name: 'octaveRange', default: 0.25, boundary: 'clamp' }, ]); } process(params, patternDesc, state, rng) { const root = mapToInt(params[0], 0, 11); const scaleIdx = mapToInt(params[1], 0, SCALES.length - 1); const octaveRange = mapToInt(params[2], 1, 4); const scale = SCALES[scaleIdx]; const pattern = clonePattern(patternDesc); // Build the full set of MIDI notes in this scale + root + range // Base octave offset: root param selects the note class (0-11), // we start from C3 (MIDI 48) so that default output is in a playable range const BASE_OCTAVE = 48; const notes = []; for (let oct = 0; oct < octaveRange; oct++) { for (let i = 0; i < scale.length; i++) { const midiNote = BASE_OCTAVE + root + scale[i] + oct * 12; if (midiNote <= 127) { notes.push(midiNote); } } } if (notes.length === 0) { return { patternDesc: pattern, nextState: {} }; } for (let i = 0; i < pattern.stepCount; i++) { const step = pattern.steps[i]; // Quantize pitch [0,1] to nearest note in our scale const targetIdx = Math.round(step.pitch * (notes.length - 1)); const clampedIdx = targetIdx < 0 ? 0 : targetIdx >= notes.length ? notes.length - 1 : targetIdx; // Store MIDI note directly — downstream (sequencer._handleNoteOn) // reads this as a MIDI note number, not a [0,1] value. // We store as note/127 to stay within the [0,1] pattern field range. step.pitch = notes[clampedIdx] / 127; } return { patternDesc: pattern, nextState: {} }; } } // ── 8. VelocityShaper ─────────────────────────────────────────────── export class VelocityShaper extends Primitive { constructor() { super('VelocityShaper', 'processor', [ { name: 'curveType', default: 0.0, boundary: 'clamp' }, { name: 'depth', default: 0.5, boundary: 'clamp' }, { name: 'phase', default: 0.0, boundary: 'wrap' }, ]); } process(params, patternDesc, state, rng) { const curveIdx = mapToInt(params[0], 0, 4); const depth = params[1]; const phase = params[2]; const pattern = clonePattern(patternDesc); const stepCount = pattern.stepCount; let currentRng = rng; for (let i = 0; i < stepCount; i++) { const step = pattern.steps[i]; if (!step.trigger) continue; // Phase-shifted position const pos = ((i / stepCount) + phase) % 1; let shapeValue; switch (curveIdx) { case 0: // flat shapeValue = 1.0; break; case 1: // accent-every-N (accent every 4th step) shapeValue = ((i + Math.floor(phase * stepCount)) % 4 === 0) ? 1.0 : 0.5; break; case 2: // crescendo shapeValue = pos; break; case 3: // decrescendo shapeValue = 1.0 - pos; break; case 4: { // random const r1 = next(currentRng); currentRng = r1.nextState; shapeValue = r1.value; break; } default: shapeValue = 1.0; } // Apply depth: interpolate between uniform (1.0) and shaped // depth=0 means all same velocity (base), depth=1 means full shape const baseVelocity = 0.7; const shaped = shapeValue; step.velocity = baseVelocity * (1 - depth) + shaped * depth; // Clamp if (step.velocity < 0) step.velocity = 0; if (step.velocity > 1) step.velocity = 1; } return { patternDesc: pattern, nextState: {} }; } } // ── Registry ──────────────────────────────────────────────────────── export const PRIMITIVE_REGISTRY = { EuclideanRhythm: EuclideanRhythm, ProbabilityGate: ProbabilityGate, PitchWalker: PitchWalker, Ratchet: Ratchet, SwingGroove: SwingGroove, DensityMorph: DensityMorph, IntervalLock: IntervalLock, VelocityShaper: VelocityShaper, };