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.
316 lines
9.8 KiB
JavaScript
316 lines
9.8 KiB
JavaScript
/**
|
|
* ShapeSeq Projection Layer
|
|
*
|
|
* Composable chain of post-chain transforms that convert raw [0,1] values
|
|
* in a pattern description into final musical values.
|
|
*
|
|
* Note: pitch quantization is NOT here — that's the Interval Lock primitive.
|
|
*
|
|
* Port-ready: pure functions, no closures, explicit state.
|
|
*
|
|
* @module shapeseq/projection
|
|
*/
|
|
|
|
import { clonePattern } from './pattern.js';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Transform definitions
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Velocity Curve — reshapes [0,1] velocity values.
|
|
*
|
|
* @param {number} value - Input value in [0,1]
|
|
* @param {{ shape: 'linear'|'exponential'|'sCurve' }} params
|
|
* @returns {number} Transformed value in [0,1]
|
|
*/
|
|
function velocityCurveApply(value, params) {
|
|
const v = value < 0 ? 0 : value > 1 ? 1 : value;
|
|
switch (params.shape) {
|
|
case 'exponential':
|
|
return v * v;
|
|
case 'sCurve':
|
|
return (3 - 2 * v) * v * v; // smoothstep: 3v² - 2v³
|
|
case 'linear':
|
|
default:
|
|
return v;
|
|
}
|
|
}
|
|
|
|
/** @type {import('./projection.js').Transform} */
|
|
export const VelocityCurve = {
|
|
name: 'Velocity Curve',
|
|
inputType: 'continuous',
|
|
outputType: 'continuous',
|
|
apply: velocityCurveApply,
|
|
};
|
|
|
|
/**
|
|
* Gate Threshold — converts continuous [0,1] to boolean.
|
|
*
|
|
* @param {number} value - Input value in [0,1]
|
|
* @param {{ threshold: number }} params - threshold in [0,1]
|
|
* @returns {boolean}
|
|
*/
|
|
function gateThresholdApply(value, params) {
|
|
return value > params.threshold;
|
|
}
|
|
|
|
/** @type {import('./projection.js').Transform} */
|
|
export const GateThreshold = {
|
|
name: 'Gate Threshold',
|
|
inputType: 'continuous',
|
|
outputType: 'boolean',
|
|
apply: gateThresholdApply,
|
|
};
|
|
|
|
/**
|
|
* Range Map — scales [0,1] into [min, max].
|
|
*
|
|
* @param {number} value - Input value in [0,1]
|
|
* @param {{ min: number, max: number }} params
|
|
* @returns {number} Value in [min, max]
|
|
*/
|
|
function rangeMapApply(value, params) {
|
|
const v = value < 0 ? 0 : value > 1 ? 1 : value;
|
|
return params.min + v * (params.max - params.min);
|
|
}
|
|
|
|
/** @type {import('./projection.js').Transform} */
|
|
export const RangeMap = {
|
|
name: 'Range Map',
|
|
inputType: 'continuous',
|
|
outputType: 'continuous',
|
|
apply: rangeMapApply,
|
|
};
|
|
|
|
/**
|
|
* Octave Folder — folds MIDI note numbers into a target range.
|
|
*
|
|
* @param {number} value - MIDI note number
|
|
* @param {{ lowNote: number, highNote: number }} params
|
|
* @returns {number} MIDI note number within [lowNote, highNote]
|
|
*/
|
|
function octaveFolderApply(value, params) {
|
|
const low = params.lowNote | 0;
|
|
const high = params.highNote | 0;
|
|
if (high <= low) return low;
|
|
let note = value | 0;
|
|
// Fold into range by shifting octaves (12 semitones)
|
|
while (note < low) note += 12;
|
|
while (note > high) note -= 12;
|
|
// If 12-step folding overshoots, clamp (range < 12)
|
|
if (note < low) note = low;
|
|
if (note > high) note = high;
|
|
return note;
|
|
}
|
|
|
|
/** @type {import('./projection.js').Transform} */
|
|
export const OctaveFolder = {
|
|
name: 'Octave Folder',
|
|
inputType: 'midi',
|
|
outputType: 'midi',
|
|
apply: octaveFolderApply,
|
|
};
|
|
|
|
/**
|
|
* Stutter Map — maps [0,1] to integer repeat count [1, maxRepeats].
|
|
*
|
|
* @param {number} value - Input value in [0,1]
|
|
* @param {{ maxRepeats: number }} params
|
|
* @returns {number} Integer repeat count in [1, maxRepeats]
|
|
*/
|
|
function stutterMapApply(value, params) {
|
|
const v = value < 0 ? 0 : value > 1 ? 1 : value;
|
|
const max = (params.maxRepeats | 0) < 1 ? 1 : params.maxRepeats | 0;
|
|
// Map [0,1] to [1, maxRepeats] — 0 → 1, 1 → maxRepeats
|
|
return Math.min(max, 1 + ((v * (max - 1)) | 0));
|
|
}
|
|
|
|
/** @type {import('./projection.js').Transform} */
|
|
export const StutterMap = {
|
|
name: 'Stutter Map',
|
|
inputType: 'continuous',
|
|
outputType: 'continuous',
|
|
apply: stutterMapApply,
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Projection chain
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Create and validate a projection chain.
|
|
*
|
|
* Validates that the output type of each transform matches the input type
|
|
* of the next transform in the chain.
|
|
*
|
|
* @param {Array<{ transform: Transform, params: Object, field: string }>} transforms
|
|
* Each entry specifies:
|
|
* - transform: one of the transform objects above
|
|
* - params: parameter object for that transform
|
|
* - field: which pattern step field this applies to ('velocity'|'pitch'|'trigger'|'subdivisions')
|
|
* @returns {{ transforms: Array, valid: boolean, error: string|null }}
|
|
*/
|
|
export function createProjectionChain(transforms) {
|
|
if (!Array.isArray(transforms) || transforms.length === 0) {
|
|
return { transforms: [], valid: true, error: null };
|
|
}
|
|
|
|
// Group by field to validate type compatibility within each field's sub-chain
|
|
const byField = {};
|
|
for (let i = 0; i < transforms.length; i++) {
|
|
const entry = transforms[i];
|
|
const field = entry.field || 'velocity';
|
|
if (!byField[field]) byField[field] = [];
|
|
byField[field].push({ index: i, entry: entry });
|
|
}
|
|
|
|
// Validate type compatibility within each field's sub-chain
|
|
const fields = Object.keys(byField);
|
|
for (let f = 0; f < fields.length; f++) {
|
|
const group = byField[fields[f]];
|
|
for (let i = 1; i < group.length; i++) {
|
|
const prev = group[i - 1].entry.transform;
|
|
const curr = group[i].entry.transform;
|
|
if (prev.outputType !== curr.inputType) {
|
|
return {
|
|
transforms: transforms,
|
|
valid: false,
|
|
error:
|
|
'Type mismatch at index ' + group[i].index +
|
|
': ' + prev.name + ' outputs ' + prev.outputType +
|
|
' but ' + curr.name + ' expects ' + curr.inputType,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
return { transforms: transforms, valid: true, error: null };
|
|
}
|
|
|
|
/**
|
|
* Apply a projection chain to a pattern description.
|
|
*
|
|
* Returns a new pattern description — does not mutate the input.
|
|
*
|
|
* Transform application rules:
|
|
* - velocity transforms apply to step.velocity
|
|
* - gate transforms (outputType === 'boolean') apply to step.trigger (post-chain override)
|
|
* - pitch transforms apply to step.pitch
|
|
*
|
|
* @param {{ transforms: Array, valid: boolean }} chain - from createProjectionChain
|
|
* @param {{ steps: Array, stepCount: number, metadata: Object }} patternDesc
|
|
* @returns {{ steps: Array, stepCount: number, metadata: Object }}
|
|
*/
|
|
export function applyProjection(chain, patternDesc) {
|
|
if (!chain.valid) {
|
|
throw new Error('Cannot apply invalid projection chain: ' + chain.error);
|
|
}
|
|
if (!chain.transforms || chain.transforms.length === 0) {
|
|
return patternDesc;
|
|
}
|
|
|
|
const result = clonePattern(patternDesc);
|
|
const steps = result.steps;
|
|
const count = result.stepCount;
|
|
|
|
// Group transforms by field, preserving order
|
|
const velocityChain = [];
|
|
const pitchChain = [];
|
|
const triggerChain = [];
|
|
const subdivisionsChain = [];
|
|
|
|
for (let i = 0; i < chain.transforms.length; i++) {
|
|
const entry = chain.transforms[i];
|
|
const field = entry.field || 'velocity';
|
|
switch (field) {
|
|
case 'velocity':
|
|
velocityChain.push(entry);
|
|
break;
|
|
case 'pitch':
|
|
pitchChain.push(entry);
|
|
break;
|
|
case 'trigger':
|
|
triggerChain.push(entry);
|
|
break;
|
|
case 'subdivisions':
|
|
subdivisionsChain.push(entry);
|
|
break;
|
|
}
|
|
}
|
|
|
|
for (let s = 0; s < count; s++) {
|
|
const step = steps[s];
|
|
const rawVelocity = step.velocity; // capture before velocity transforms
|
|
|
|
// Apply velocity transforms
|
|
let vel = step.velocity;
|
|
for (let t = 0; t < velocityChain.length; t++) {
|
|
vel = velocityChain[t].transform.apply(vel, velocityChain[t].params);
|
|
}
|
|
step.velocity = vel;
|
|
|
|
// Apply pitch transforms
|
|
let pitch = step.pitch;
|
|
for (let t = 0; t < pitchChain.length; t++) {
|
|
pitch = pitchChain[t].transform.apply(pitch, pitchChain[t].params);
|
|
}
|
|
step.pitch = pitch;
|
|
|
|
// Apply trigger transforms (gate threshold overrides trigger)
|
|
let trig = rawVelocity; // gate threshold uses raw velocity as input
|
|
for (let t = 0; t < triggerChain.length; t++) {
|
|
trig = triggerChain[t].transform.apply(trig, triggerChain[t].params);
|
|
}
|
|
if (triggerChain.length > 0) {
|
|
step.trigger = !!trig;
|
|
}
|
|
|
|
// Apply subdivisions transforms (stutter)
|
|
let subs = step.subdivisions;
|
|
// Normalize subdivisions to [0,1] for continuous input transforms
|
|
// subdivisions range is [1,4], map to [0,1]
|
|
let subsNorm = (subs - 1) / 3;
|
|
for (let t = 0; t < subdivisionsChain.length; t++) {
|
|
subsNorm = subdivisionsChain[t].transform.apply(subsNorm, subdivisionsChain[t].params);
|
|
}
|
|
if (subdivisionsChain.length > 0) {
|
|
step.subdivisions = Math.max(1, Math.min(4, Math.round(subsNorm)));
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Projection presets
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Pre-built projection chain configurations.
|
|
*
|
|
* Each preset is a plain array of { transform, params, field } entries
|
|
* ready to pass to createProjectionChain().
|
|
*/
|
|
export const PRESETS = {
|
|
/**
|
|
* Exponential velocity shaping.
|
|
* Pitch is handled by IntervalLock primitive (outputs MIDI note / 127).
|
|
* No pitch RangeMap needed — the sequencer converts pitch * 127 to MIDI note.
|
|
*/
|
|
expressive: [
|
|
{ transform: VelocityCurve, params: { shape: 'exponential' }, field: 'velocity' },
|
|
],
|
|
|
|
/** Gate threshold at 0.5 + exponential velocity curve */
|
|
percussive: [
|
|
{ transform: GateThreshold, params: { threshold: 0.5 }, field: 'trigger' },
|
|
{ transform: VelocityCurve, params: { shape: 'exponential' }, field: 'velocity' },
|
|
],
|
|
|
|
/** S-curve velocity for more dynamic contrast */
|
|
fullRange: [
|
|
{ transform: VelocityCurve, params: { shape: 'sCurve' }, field: 'velocity' },
|
|
],
|
|
};
|