memlnaut-nisps/playground/js/shapeseq/projection.js
w1n5t0n d40711eb77 fix(shapeseq): fix 6 bugs found in fresh-eyes review
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.
2026-03-24 11:42:25 +02:00

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' },
],
};