refactor(projection): simplify to 3 global knobs

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.
This commit is contained in:
w1n5t0n 2026-04-06 23:41:30 +01:00
parent af4f19bc72
commit 4cea170795
3 changed files with 292 additions and 272 deletions

View file

@ -1,12 +1,12 @@
/** /**
* ShapeSeq Projection Layer * ShapeSeq Projection 3 global post-chain knobs
* *
* Composable chain of post-chain transforms that convert raw [0,1] values * Applied after the primitive chain produces a pattern, before the clock plays it.
* in a pattern description into final musical values.
* *
* Note: pitch quantization is NOT here that's the Interval Lock primitive. * Knobs:
* * 1. velocityCurve: 'linear' | 'exponential' | 'sCurve'
* Port-ready: pure functions, no closures, explicit state. * 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 * @module shapeseq/projection
*/ */
@ -14,19 +14,25 @@
import { clonePattern } from './pattern.js'; import { clonePattern } from './pattern.js';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Transform definitions // Constants
// ---------------------------------------------------------------------------
export const VELOCITY_CURVES = ['linear', 'exponential', 'sCurve'];
// ---------------------------------------------------------------------------
// Velocity curve math
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** /**
* Velocity Curve reshapes [0,1] velocity values. * Apply a velocity curve to a [0,1] value.
* *
* @param {number} value - Input value in [0,1] * @param {number} value - Input value in [0,1]
* @param {{ shape: 'linear'|'exponential'|'sCurve' }} params * @param {'linear'|'exponential'|'sCurve'} curve
* @returns {number} Transformed value in [0,1] * @returns {number} Transformed value in [0,1]
*/ */
function velocityCurveApply(value, params) { function velocityCurveApply(value, curve) {
const v = value < 0 ? 0 : value > 1 ? 1 : value; const v = value < 0 ? 0 : value > 1 ? 1 : value;
switch (params.shape) { switch (curve) {
case 'exponential': case 'exponential':
return v * v; return v * v;
case 'sCurve': case 'sCurve':
@ -37,280 +43,82 @@ function velocityCurveApply(value, params) {
} }
} }
/** @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. * Fold a MIDI note number into a target range by shifting octaves.
* *
* @param {number} value - Input value in [0,1] * @param {number} note - MIDI note number
* @param {{ threshold: number }} params - threshold in [0,1] * @param {number} low - Low bound (inclusive)
* @returns {boolean} * @param {number} high - High bound (inclusive)
* @returns {number} MIDI note within [low, high]
*/ */
function gateThresholdApply(value, params) { function octaveFold(note, low, high) {
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; if (high <= low) return low;
let note = value | 0; let n = note | 0;
// Fold into range by shifting octaves (12 semitones) while (n < low) n += 12;
while (note < low) note += 12; while (n > high) n -= 12;
while (note > high) note -= 12; // If 12-step folding overshoots (range < 12), clamp
// If 12-step folding overshoots, clamp (range < 12) if (n < low) n = low;
if (note < low) note = low; if (n > high) n = high;
if (note > high) note = high; return n;
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 // Public API
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** /**
* Create and validate a projection chain. * Create a projection config with defaults.
* *
* Validates that the output type of each transform matches the input type * @param {Object} [opts]
* of the next transform in the chain. * @param {'linear'|'exponential'|'sCurve'} [opts.velocityCurve='linear']
* * @param {number} [opts.gateThreshold=0]
* @param {Array<{ transform: Transform, params: Object, field: string }>} transforms * @param {{ low?: number, high?: number }} [opts.pitchRange]
* Each entry specifies: * @returns {{ velocityCurve: string, gateThreshold: number, pitchRange: { low: number, high: number } }}
* - 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) { export function createProjection(opts = {}) {
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 { return {
transforms: transforms, velocityCurve: opts.velocityCurve || 'linear',
valid: false, gateThreshold: opts.gateThreshold ?? 0,
error: pitchRange: {
'Type mismatch at index ' + group[i].index + low: opts.pitchRange?.low ?? 0,
': ' + prev.name + ' outputs ' + prev.outputType + high: opts.pitchRange?.high ?? 127,
' but ' + curr.name + ' expects ' + curr.inputType, },
}; };
} }
}
}
return { transforms: transforms, valid: true, error: null };
}
/** /**
* Apply a projection chain to a pattern description. * Apply projection to a pattern. Returns a new pattern (no mutation).
* *
* Returns a new pattern description does not mutate the input. * 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
* *
* Transform application rules: * @param {{ velocityCurve: string, gateThreshold: number, pitchRange: { low: number, high: number } }} config
* - 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 * @param {{ steps: Array, stepCount: number, metadata: Object }} patternDesc
* @returns {{ steps: Array, stepCount: number, metadata: Object }} * @returns {{ steps: Array, stepCount: number, metadata: Object }}
*/ */
export function applyProjection(chain, patternDesc) { export function applyProjection(config, 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 result = clonePattern(patternDesc);
const steps = result.steps; const steps = result.steps;
const count = result.stepCount; 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++) { for (let s = 0; s < count; s++) {
const step = steps[s]; const step = steps[s];
const rawVelocity = step.velocity; // capture before velocity transforms
// Apply velocity transforms // 1. Velocity curve
let vel = step.velocity; step.velocity = velocityCurveApply(step.velocity, config.velocityCurve);
for (let t = 0; t < velocityChain.length; t++) {
vel = velocityChain[t].transform.apply(vel, velocityChain[t].params);
}
step.velocity = vel;
// Apply pitch transforms // 2. Gate threshold (uses post-curve velocity)
let pitch = step.pitch; if (step.velocity < config.gateThreshold) {
for (let t = 0; t < pitchChain.length; t++) { step.trigger = false;
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) // 3. Pitch range (octave fold)
let subs = step.subdivisions; if (step.midiNote != null) {
// Normalize subdivisions to [0,1] for continuous input transforms step.midiNote = octaveFold(step.midiNote, config.pitchRange.low, config.pitchRange.high);
// 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; 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' },
],
};

View file

@ -24,7 +24,7 @@ import { createSequenceIML, SEQ_N_OUTPUTS } from './seq-iml.js';
import { Chain } from './chain.js'; import { Chain } from './chain.js';
import { ClockEngine } from './clock.js'; import { ClockEngine } from './clock.js';
import { map } from './param-map.js'; import { map } from './param-map.js';
import { createProjectionChain, applyProjection, PRESETS } from './projection.js'; import { createProjection, applyProjection } from './projection.js';
import { SEQ } from './event-bus.js'; import { SEQ } from './event-bus.js';
import { import {
EuclideanRhythm, EuclideanRhythm,
@ -108,12 +108,8 @@ export class ShapeSeqEngine {
this._clock = new ClockEngine(this._audioCtx, this._bus); this._clock = new ClockEngine(this._audioCtx, this._bus);
this._clock.bpm = DEFAULT_BPM; this._clock.bpm = DEFAULT_BPM;
// 4. Create default projection chain (expressive preset) // 4. Create default projection
const result = createProjectionChain(PRESETS.expressive); this._projectionChain = createProjection();
if (!result.valid) {
throw new Error('Default projection chain invalid: ' + result.error);
}
this._projectionChain = result;
// 5. Subscribe to event bus for C15 bridge integration // 5. Subscribe to event bus for C15 bridge integration
this._bus.on(SEQ.NOTE_ON, this._onNoteOn); this._bus.on(SEQ.NOTE_ON, this._onNoteOn);
@ -194,19 +190,27 @@ export class ShapeSeqEngine {
} }
/** /**
* Set the projection preset by name. * Update the projection config. Accepts partial options merged with current.
* @param {'expressive'|'percussive'|'fullRange'} presetName * @param {Object} opts - Partial projection config
*/ */
setProjectionPreset(presetName) { setProjection(opts) {
const preset = PRESETS[presetName]; const cur = this._projectionChain;
if (!preset) { this._projectionChain = createProjection({
throw new Error('Unknown projection preset: ' + presetName); velocityCurve: opts.velocityCurve ?? cur.velocityCurve,
gateThreshold: opts.gateThreshold ?? cur.gateThreshold,
pitchRange: {
low: opts.pitchRange?.low ?? cur.pitchRange.low,
high: opts.pitchRange?.high ?? cur.pitchRange.high,
},
});
} }
const result = createProjectionChain(preset);
if (!result.valid) { /**
throw new Error('Projection chain invalid: ' + result.error); * Get the current projection config.
} * @returns {{ velocityCurve: string, gateThreshold: number, pitchRange: { low: number, high: number } }}
this._projectionChain = result; */
getProjection() {
return this._projectionChain;
} }
// ── Chain access (for UI binding) ────────────────────────────────── // ── Chain access (for UI binding) ──────────────────────────────────

View file

@ -0,0 +1,208 @@
/**
* Tests for the simplified 3-knob projection layer.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { createProjection, applyProjection, VELOCITY_CURVES } from '../projection.js';
import { createPattern, setStep } from '../pattern.js';
// ---------------------------------------------------------------------------
// createProjection
// ---------------------------------------------------------------------------
describe('createProjection', () => {
it('returns correct defaults', () => {
const p = createProjection();
assert.equal(p.velocityCurve, 'linear');
assert.equal(p.gateThreshold, 0);
assert.deepStrictEqual(p.pitchRange, { low: 0, high: 127 });
});
it('accepts custom values', () => {
const p = createProjection({
velocityCurve: 'sCurve',
gateThreshold: 0.4,
pitchRange: { low: 36, high: 72 },
});
assert.equal(p.velocityCurve, 'sCurve');
assert.equal(p.gateThreshold, 0.4);
assert.deepStrictEqual(p.pitchRange, { low: 36, high: 72 });
});
it('fills partial pitchRange with defaults', () => {
const p = createProjection({ pitchRange: { low: 24 } });
assert.equal(p.pitchRange.low, 24);
assert.equal(p.pitchRange.high, 127);
});
});
// ---------------------------------------------------------------------------
// VELOCITY_CURVES constant
// ---------------------------------------------------------------------------
describe('VELOCITY_CURVES', () => {
it('contains the three expected curves', () => {
assert.deepStrictEqual(VELOCITY_CURVES, ['linear', 'exponential', 'sCurve']);
});
});
// ---------------------------------------------------------------------------
// Velocity curves
// ---------------------------------------------------------------------------
describe('velocity curves', () => {
it('linear is passthrough', () => {
const config = createProjection({ velocityCurve: 'linear' });
const pattern = createPattern(1);
setStep(pattern, 0, { trigger: true, velocity: 0.7 });
const out = applyProjection(config, pattern);
assert.equal(out.steps[0].velocity, 0.7);
});
it('exponential squares the value', () => {
const config = createProjection({ velocityCurve: 'exponential' });
const pattern = createPattern(1);
setStep(pattern, 0, { trigger: true, velocity: 0.5 });
const out = applyProjection(config, pattern);
assert.ok(Math.abs(out.steps[0].velocity - 0.25) < 1e-9);
});
it('sCurve applies smoothstep', () => {
const config = createProjection({ velocityCurve: 'sCurve' });
const pattern = createPattern(1);
setStep(pattern, 0, { trigger: true, velocity: 0.5 });
const out = applyProjection(config, pattern);
// smoothstep(0.5) = (3 - 2*0.5) * 0.5 * 0.5 = 2 * 0.25 = 0.5
assert.ok(Math.abs(out.steps[0].velocity - 0.5) < 1e-9);
});
it('sCurve at 0 and 1 are identity', () => {
const config = createProjection({ velocityCurve: 'sCurve' });
const pattern = createPattern(2);
setStep(pattern, 0, { trigger: true, velocity: 0.0 });
setStep(pattern, 1, { trigger: true, velocity: 1.0 });
const out = applyProjection(config, pattern);
assert.ok(Math.abs(out.steps[0].velocity - 0.0) < 1e-9);
assert.ok(Math.abs(out.steps[1].velocity - 1.0) < 1e-9);
});
});
// ---------------------------------------------------------------------------
// Gate threshold
// ---------------------------------------------------------------------------
describe('gate threshold', () => {
it('steps below threshold get trigger=false', () => {
const config = createProjection({ gateThreshold: 0.5 });
const pattern = createPattern(3);
setStep(pattern, 0, { trigger: true, velocity: 0.3 });
setStep(pattern, 1, { trigger: true, velocity: 0.6 });
setStep(pattern, 2, { trigger: true, velocity: 0.5 }); // exactly at threshold
const out = applyProjection(config, pattern);
assert.equal(out.steps[0].trigger, false); // 0.3 < 0.5 → muted
assert.equal(out.steps[1].trigger, true); // 0.6 >= 0.5 → kept
assert.equal(out.steps[2].trigger, true); // 0.5 is not < 0.5 → kept (at threshold)
});
it('threshold 0 mutes nothing', () => {
const config = createProjection({ gateThreshold: 0 });
const pattern = createPattern(2);
setStep(pattern, 0, { trigger: true, velocity: 0.01 });
setStep(pattern, 1, { trigger: true, velocity: 0.0 });
const out = applyProjection(config, pattern);
assert.equal(out.steps[0].trigger, true);
// velocity 0.0 is not < 0, so stays true
assert.equal(out.steps[1].trigger, true);
});
it('threshold 1 mutes everything below 1', () => {
const config = createProjection({ gateThreshold: 1 });
const pattern = createPattern(2);
setStep(pattern, 0, { trigger: true, velocity: 0.99 });
setStep(pattern, 1, { trigger: true, velocity: 1.0 });
const out = applyProjection(config, pattern);
assert.equal(out.steps[0].trigger, false);
assert.equal(out.steps[1].trigger, true);
});
});
// ---------------------------------------------------------------------------
// Pitch range (octave fold)
// ---------------------------------------------------------------------------
describe('pitch range', () => {
it('clamps midiNote into range by octave folding', () => {
const config = createProjection({ pitchRange: { low: 48, high: 72 } });
const pattern = createPattern(1);
setStep(pattern, 0, { trigger: true, midiNote: 84 }); // C6, should fold down
const out = applyProjection(config, pattern);
// 84 - 12 = 72 (within range)
assert.equal(out.steps[0].midiNote, 72);
});
it('folds up when below range', () => {
const config = createProjection({ pitchRange: { low: 48, high: 72 } });
const pattern = createPattern(1);
setStep(pattern, 0, { trigger: true, midiNote: 36 }); // C2
const out = applyProjection(config, pattern);
// 36 + 12 = 48 (within range)
assert.equal(out.steps[0].midiNote, 48);
});
it('leaves note in range untouched', () => {
const config = createProjection({ pitchRange: { low: 48, high: 72 } });
const pattern = createPattern(1);
setStep(pattern, 0, { trigger: true, midiNote: 60 });
const out = applyProjection(config, pattern);
assert.equal(out.steps[0].midiNote, 60);
});
it('steps without midiNote (null) are unaffected', () => {
const config = createProjection({ pitchRange: { low: 48, high: 72 } });
const pattern = createPattern(1);
// midiNote defaults to null from createStep
setStep(pattern, 0, { trigger: true });
const out = applyProjection(config, pattern);
assert.equal(out.steps[0].midiNote, null);
});
it('handles narrow range (< 12 semitones) with clamping', () => {
const config = createProjection({ pitchRange: { low: 60, high: 65 } });
const pattern = createPattern(1);
setStep(pattern, 0, { trigger: true, midiNote: 80 });
const out = applyProjection(config, pattern);
// 80 - 12 = 68 > 65, -12 = 56 < 60, clamp to 60
assert.ok(out.steps[0].midiNote >= 60 && out.steps[0].midiNote <= 65);
});
});
// ---------------------------------------------------------------------------
// No mutation
// ---------------------------------------------------------------------------
describe('applyProjection immutability', () => {
it('does not mutate the input pattern', () => {
const config = createProjection({
velocityCurve: 'exponential',
gateThreshold: 0.5,
pitchRange: { low: 48, high: 72 },
});
const pattern = createPattern(2);
setStep(pattern, 0, { trigger: true, velocity: 0.3, midiNote: 84 });
setStep(pattern, 1, { trigger: true, velocity: 0.8, midiNote: 60 });
// Snapshot original values
const origVel0 = pattern.steps[0].velocity;
const origVel1 = pattern.steps[1].velocity;
const origTrig0 = pattern.steps[0].trigger;
const origNote0 = pattern.steps[0].midiNote;
applyProjection(config, pattern);
assert.equal(pattern.steps[0].velocity, origVel0);
assert.equal(pattern.steps[1].velocity, origVel1);
assert.equal(pattern.steps[0].trigger, origTrig0);
assert.equal(pattern.steps[0].midiNote, origNote0);
});
});