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:
parent
af4f19bc72
commit
4cea170795
3 changed files with 292 additions and 272 deletions
|
|
@ -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' },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -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) ──────────────────────────────────
|
||||||
|
|
|
||||||
208
playground/js/shapeseq/tests/projection.test.js
Normal file
208
playground/js/shapeseq/tests/projection.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue