memlnaut-nisps/playground/js/shapeseq/primitives.js
w1n5t0n aec58537db feat(pattern): add midiNote field + trigger-aware merge
Replace the pitch*127 encoding hack with a dedicated midiNote field on
pattern steps. IntervalLock now writes integer MIDI notes to step.midiNote
while preserving the original [0,1] pitch for other consumers. The clock
and sequencer bridge pass midiNote through the event pipeline.

Also reworks mergePatterns to be trigger-aware: when only one generator
fires at a step, use its values directly instead of averaging with the
other generator's defaults.
2026-04-06 23:28:01 +01:00

525 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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.00.33: every 2nd step (8th note feel)
// 0.340.66: every 4th step (16th note feel)
// 0.671.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 the integer MIDI note in midiNote; leave pitch as-is
// (pre-quantization [0,1] value) for other consumers.
step.midiNote = notes[clampedIdx];
}
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,
};