diff --git a/playground/js/shapeseq/chain.js b/playground/js/shapeseq/chain.js new file mode 100644 index 0000000..7e0f88d --- /dev/null +++ b/playground/js/shapeseq/chain.js @@ -0,0 +1,319 @@ +/** + * ShapeSeq Chain — sequential pipeline runner with generator combination modes + * + * Evaluates an ordered list of primitives as a sequential pipeline: + * 1. Generators run first, combined via additive or multiplicative merge + * 2. Processors transform the pattern in chain order + * 3. Converters run in chain order + * 4. Timing modifiers annotate last + * + * Params are distributed flat across primitives in chain order. + * Each primitive gets a deterministic PRNG stream via fork(masterPRNG, index). + * + * Port-ready: explicit state, typed arrays, no closures in hot path. + * + * @module shapeseq/chain + */ + +import { createPattern, mergePatterns } from './pattern.js'; +import { createPRNG, fork } from './prng.js'; + +// ── Category execution order ──────────────────────────────────────── + +const PHASE_ORDER = ['generator', 'processor', 'converter', 'timing']; + +// ── Chain class ───────────────────────────────────────────────────── + +export class Chain { + constructor() { + /** @private @type {Array} */ + this._primitives = []; + + /** @type {'additive'|'multiplicative'} */ + this.generatorCombineMode = 'additive'; + + /** @private @type {number} */ + this._masterSeed = 0; + + /** + * Per-primitive state objects, indexed by position in chain. + * Populated after evaluate() calls; used for freeze support. + * @private @type {Array} + */ + this._primitiveStates = []; + } + + // ── Primitive management ──────────────────────────────────────── + + /** + * Append a primitive to the end of the chain. + * @param {import('./primitive.js').Primitive} primitive + */ + addPrimitive(primitive) { + this._primitives.push(primitive); + this._primitiveStates.push(primitive.getState()); + } + + /** + * Remove the primitive at the given index. + * @param {number} index + */ + removePrimitive(index) { + const idx = index | 0; + if (idx < 0 || idx >= this._primitives.length) { + throw new RangeError('removePrimitive: index ' + index + ' out of range [0, ' + (this._primitives.length - 1) + ']'); + } + this._primitives.splice(idx, 1); + this._primitiveStates.splice(idx, 1); + } + + /** + * Insert a primitive at the given index, shifting others right. + * @param {number} index + * @param {import('./primitive.js').Primitive} primitive + */ + insertPrimitive(index, primitive) { + const idx = index | 0; + if (idx < 0 || idx > this._primitives.length) { + throw new RangeError('insertPrimitive: index ' + index + ' out of range [0, ' + this._primitives.length + ']'); + } + this._primitives.splice(idx, 0, primitive); + this._primitiveStates.splice(idx, 0, primitive.getState()); + } + + /** + * Move a primitive from one position to another. + * @param {number} fromIndex + * @param {number} toIndex + */ + movePrimitive(fromIndex, toIndex) { + const from = fromIndex | 0; + const to = toIndex | 0; + const len = this._primitives.length; + if (from < 0 || from >= len) { + throw new RangeError('movePrimitive: fromIndex ' + fromIndex + ' out of range [0, ' + (len - 1) + ']'); + } + if (to < 0 || to >= len) { + throw new RangeError('movePrimitive: toIndex ' + toIndex + ' out of range [0, ' + (len - 1) + ']'); + } + + const [prim] = this._primitives.splice(from, 1); + const [state] = this._primitiveStates.splice(from, 1); + this._primitives.splice(to, 0, prim); + this._primitiveStates.splice(to, 0, state); + } + + /** + * Get the current list of primitives (shallow copy). + * @returns {Array} + */ + getPrimitives() { + return this._primitives.slice(); + } + + // ── Configuration ─────────────────────────────────────────────── + + /** + * Total parameter count across all primitives in the chain. + * @returns {number} + */ + get totalParamCount() { + let total = 0; + for (let i = 0; i < this._primitives.length; i++) { + total += this._primitives[i].paramCount; + } + return total; + } + + /** + * Get a flat list of all param schemas across all primitives, + * annotated with their primitive and param indices. + * + * @returns {Array<{ primitiveIndex: number, paramIndex: number, schema: Object }>} + */ + getParamSchemas() { + const result = []; + for (let pi = 0; pi < this._primitives.length; pi++) { + const prim = this._primitives[pi]; + const schema = prim.paramSchema; + for (let si = 0; si < schema.length; si++) { + result.push({ + primitiveIndex: pi, + paramIndex: si, + schema: schema[si], + }); + } + } + return result; + } + + // ── Evaluation ────────────────────────────────────────────────── + + /** + * Evaluate the chain, producing a pattern description. + * + * Pipeline order: + * 1. Generators — combined via generatorCombineMode + * 2. Processors — sequential transform + * 3. Converters — sequential transform + * 4. Timing modifiers — annotate last + * + * @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 + * @returns {{ steps: Array, stepCount: number, metadata: Object }} + */ + evaluate(params, stepCount, masterSeed) { + const primitives = this._primitives; + const primCount = primitives.length; + + // Create master PRNG from seed + const masterPRNG = createPRNG(masterSeed >>> 0); + + // ── Bucket primitives by category, preserving chain order ── + + /** @type {Array<{ index: number, prim: Object }>} */ + const generators = []; + const processors = []; + const converters = []; + const timingMods = []; + + for (let i = 0; i < primCount; i++) { + const entry = { index: i, prim: primitives[i] }; + switch (primitives[i].category) { + case 'generator': generators.push(entry); break; + case 'processor': processors.push(entry); break; + case 'converter': converters.push(entry); break; + case 'timing': timingMods.push(entry); break; + } + } + + // ── Compute param offsets per primitive ── + + const paramOffsets = new Array(primCount); + let offset = 0; + for (let i = 0; i < primCount; i++) { + paramOffsets[i] = offset; + offset += primitives[i].paramCount; + } + + // ── Helper: run a single primitive ── + + const self = this; + + function runPrimitive(entry, inputPattern) { + const idx = entry.index; + const prim = entry.prim; + const pOffset = paramOffsets[idx]; + const pCount = prim.paramCount; + + // Slice params for this primitive + const primParams = new Float32Array(pCount); + for (let p = 0; p < pCount; p++) { + primParams[p] = pOffset + p < params.length ? +params[pOffset + p] : prim.paramSchema[p].default; + } + + // Fork a deterministic PRNG for this primitive + const primRNG = fork(masterPRNG, idx); + + // Get current state + const state = self._primitiveStates[idx] || prim.getState(); + + // Process + const result = prim.process(primParams, inputPattern, state, primRNG); + + // Store updated state + self._primitiveStates[idx] = result.nextState; + + return result.patternDesc; + } + + // ── Phase 1: Generators ── + + let pattern; + + if (generators.length === 0) { + // Default pattern: all steps triggered + pattern = createPattern(stepCount); + for (let i = 0; i < stepCount; i++) { + pattern.steps[i].trigger = true; + } + } else if (generators.length === 1) { + // Single generator — no merge needed + pattern = runPrimitive(generators[0], createPattern(stepCount)); + } else { + // Multiple generators — run each, then merge + let merged = runPrimitive(generators[0], createPattern(stepCount)); + for (let g = 1; g < generators.length; g++) { + const next = runPrimitive(generators[g], createPattern(stepCount)); + merged = mergePatterns(merged, next, this.generatorCombineMode); + } + pattern = merged; + } + + // ── Phase 2: Processors ── + + for (let i = 0; i < processors.length; i++) { + pattern = runPrimitive(processors[i], pattern); + } + + // ── Phase 3: Converters ── + + for (let i = 0; i < converters.length; i++) { + pattern = runPrimitive(converters[i], pattern); + } + + // ── Phase 4: Timing modifiers ── + + for (let i = 0; i < timingMods.length; i++) { + pattern = runPrimitive(timingMods[i], pattern); + } + + return pattern; + } + + // ── State management (for freeze) ────────────────────────────── + + /** + * Get serializable state for all primitives in the chain. + * @returns {Array} + */ + getState() { + const states = new Array(this._primitives.length); + for (let i = 0; i < this._primitives.length; i++) { + states[i] = this._primitiveStates[i] || this._primitives[i].getState(); + } + return states; + } + + /** + * Restore all primitive states from a previously serialized state array. + * @param {Array} states + */ + setState(states) { + if (!Array.isArray(states)) { + throw new TypeError('setState expects an array of state objects'); + } + const len = Math.min(states.length, this._primitives.length); + for (let i = 0; i < len; i++) { + this._primitives[i].setState(states[i]); + this._primitiveStates[i] = states[i]; + } + } + + /** + * Get the master PRNG seed. + * @returns {number} + */ + getMasterSeed() { + return this._masterSeed; + } + + /** + * Set the master PRNG seed. + * @param {number} seed - 32-bit integer seed + */ + setMasterSeed(seed) { + this._masterSeed = seed >>> 0; + } +} diff --git a/playground/js/shapeseq/primitives.js b/playground/js/shapeseq/primitives.js new file mode 100644 index 0000000..9e21cf8 --- /dev/null +++ b/playground/js/shapeseq/primitives.js @@ -0,0 +1,505 @@ +/** + * 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 pattern = clonePattern(patternDesc); + + // Max swing = 0.33 (triplet feel) + const maxOffset = 0.33; + const offset = swingAmount * maxOffset; + + // Apply swing to every other step (odd-indexed steps) + for (let i = 1; i < pattern.stepCount; i += 2) { + 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 + const notes = []; + for (let oct = 0; oct < octaveRange; oct++) { + for (let i = 0; i < scale.length; i++) { + const midiNote = 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 as MIDI note / 127 to stay in [0,1] + 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, +};