Replace the composable transform chain (VelocityCurve, GateThreshold, RangeMap, OctaveFolder, StutterMap, presets) with 3 straightforward post-chain knobs: velocityCurve, gateThreshold, and pitchRange. The old system was over-engineered for a research prototype — the new API is easier to wire to UI controls and reason about.
124 lines
3.9 KiB
JavaScript
124 lines
3.9 KiB
JavaScript
/**
|
|
* ShapeSeq Projection — 3 global post-chain knobs
|
|
*
|
|
* Applied after the primitive chain produces a pattern, before the clock plays it.
|
|
*
|
|
* Knobs:
|
|
* 1. velocityCurve: 'linear' | 'exponential' | 'sCurve'
|
|
* 2. gateThreshold: [0, 1] — global density filter (steps with velocity below threshold are muted)
|
|
* 3. pitchRange: { low: midiNote, high: midiNote } — clamp midiNote to range
|
|
*
|
|
* @module shapeseq/projection
|
|
*/
|
|
|
|
import { clonePattern } from './pattern.js';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Constants
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const VELOCITY_CURVES = ['linear', 'exponential', 'sCurve'];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Velocity curve math
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Apply a velocity curve to a [0,1] value.
|
|
*
|
|
* @param {number} value - Input value in [0,1]
|
|
* @param {'linear'|'exponential'|'sCurve'} curve
|
|
* @returns {number} Transformed value in [0,1]
|
|
*/
|
|
function velocityCurveApply(value, curve) {
|
|
const v = value < 0 ? 0 : value > 1 ? 1 : value;
|
|
switch (curve) {
|
|
case 'exponential':
|
|
return v * v;
|
|
case 'sCurve':
|
|
return (3 - 2 * v) * v * v; // smoothstep: 3v² - 2v³
|
|
case 'linear':
|
|
default:
|
|
return v;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fold a MIDI note number into a target range by shifting octaves.
|
|
*
|
|
* @param {number} note - MIDI note number
|
|
* @param {number} low - Low bound (inclusive)
|
|
* @param {number} high - High bound (inclusive)
|
|
* @returns {number} MIDI note within [low, high]
|
|
*/
|
|
function octaveFold(note, low, high) {
|
|
if (high <= low) return low;
|
|
let n = note | 0;
|
|
while (n < low) n += 12;
|
|
while (n > high) n -= 12;
|
|
// If 12-step folding overshoots (range < 12), clamp
|
|
if (n < low) n = low;
|
|
if (n > high) n = high;
|
|
return n;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public API
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Create a projection config with defaults.
|
|
*
|
|
* @param {Object} [opts]
|
|
* @param {'linear'|'exponential'|'sCurve'} [opts.velocityCurve='linear']
|
|
* @param {number} [opts.gateThreshold=0]
|
|
* @param {{ low?: number, high?: number }} [opts.pitchRange]
|
|
* @returns {{ velocityCurve: string, gateThreshold: number, pitchRange: { low: number, high: number } }}
|
|
*/
|
|
export function createProjection(opts = {}) {
|
|
return {
|
|
velocityCurve: opts.velocityCurve || 'linear',
|
|
gateThreshold: opts.gateThreshold ?? 0,
|
|
pitchRange: {
|
|
low: opts.pitchRange?.low ?? 0,
|
|
high: opts.pitchRange?.high ?? 127,
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Apply projection to a pattern. Returns a new pattern (no mutation).
|
|
*
|
|
* For each step:
|
|
* 1. Apply velocity curve to step.velocity
|
|
* 2. If step.velocity < gateThreshold, set step.trigger = false
|
|
* 3. If step.midiNote != null, fold into [pitchRange.low, pitchRange.high] by octave
|
|
*
|
|
* @param {{ velocityCurve: string, gateThreshold: number, pitchRange: { low: number, high: number } }} config
|
|
* @param {{ steps: Array, stepCount: number, metadata: Object }} patternDesc
|
|
* @returns {{ steps: Array, stepCount: number, metadata: Object }}
|
|
*/
|
|
export function applyProjection(config, patternDesc) {
|
|
const result = clonePattern(patternDesc);
|
|
const steps = result.steps;
|
|
const count = result.stepCount;
|
|
|
|
for (let s = 0; s < count; s++) {
|
|
const step = steps[s];
|
|
|
|
// 1. Velocity curve
|
|
step.velocity = velocityCurveApply(step.velocity, config.velocityCurve);
|
|
|
|
// 2. Gate threshold (uses post-curve velocity)
|
|
if (step.velocity < config.gateThreshold) {
|
|
step.trigger = false;
|
|
}
|
|
|
|
// 3. Pitch range (octave fold)
|
|
if (step.midiNote != null) {
|
|
step.midiNote = octaveFold(step.midiNote, config.pitchRange.low, config.pitchRange.high);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|