1. Pitch pipeline: IntervalLock stores midiNote/127, sequencer now converts back via pitch*127. Removed pitch RangeMap from projection presets (was double-mapping already-quantized values). 2. Clock step duration: changed from quarter-note grid (60/bpm) to 16th-note grid (60/bpm/4). 8 steps at 120 BPM now = 1 second. 3. Method name: drawWeights → randomiseWeights (matching WasmIML API). 4. Dirty-check: setSequenceInputs now skips re-evaluation when inputs haven't changed (epsilon 1e-5). Avoids 60fps chain evaluation. 5. SwingGroove: swingGrid param now functional — selects 8th note (period 2), 16th note (period 4), or triplet (period 3) swing grid. 6. IntervalLock base octave: starts from C3 (MIDI 48) instead of C0, so default output is in a playable range.
526 lines
17 KiB
JavaScript
526 lines
17 KiB
JavaScript
/**
|
||
* 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,
|
||
};
|