feat(shapeseq): implement Layer 2 — all 8 primitives and chain runner
primitives.js (505 lines): All 8 sequencing primitives extending Primitive base class: - EuclideanRhythm: Bjorklund-distributed trigger patterns - ProbabilityGate: PRNG-based trigger filtering + accent assignment - PitchWalker: stateful constrained random walk with gravity - Ratchet: probabilistic step subdivision (1-4x) - SwingGroove: alternating-step timing offsets (max triplet feel) - DensityMorph: trigger placement with clustering control - IntervalLock: 11-scale pitch quantizer (chromatic→diminished) - VelocityShaper: 5 curve types with depth/phase control Includes PRIMITIVE_REGISTRY for chain builder UI. chain.js (319 lines): Sequential pipeline chain evaluator. Buckets primitives by category (generators→processors→converters→ timing), forks deterministic PRNG per primitive, merges multiple generators via additive (OR) or multiplicative (AND) mode (configurable in real time). Flat param distribution by chain position. Full state serialization for freeze support.
This commit is contained in:
parent
c8015da35a
commit
efcf6d2c06
2 changed files with 824 additions and 0 deletions
319
playground/js/shapeseq/chain.js
Normal file
319
playground/js/shapeseq/chain.js
Normal file
|
|
@ -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<import('./primitive.js').Primitive>} */
|
||||
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<Object>}
|
||||
*/
|
||||
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<import('./primitive.js').Primitive>}
|
||||
*/
|
||||
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<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
|
||||
* @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<Object>}
|
||||
*/
|
||||
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<Object>} 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;
|
||||
}
|
||||
}
|
||||
505
playground/js/shapeseq/primitives.js
Normal file
505
playground/js/shapeseq/primitives.js
Normal file
|
|
@ -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,
|
||||
};
|
||||
Loading…
Reference in a new issue