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
|
||||
* in a pattern description into final musical values.
|
||||
* Applied after the primitive chain produces a pattern, before the clock plays it.
|
||||
*
|
||||
* Note: pitch quantization is NOT here — that's the Interval Lock primitive.
|
||||
*
|
||||
* Port-ready: pure functions, no closures, explicit state.
|
||||
* 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
|
||||
*/
|
||||
|
|
@ -14,19 +14,25 @@
|
|||
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 {{ shape: 'linear'|'exponential'|'sCurve' }} params
|
||||
* @param {'linear'|'exponential'|'sCurve'} curve
|
||||
* @returns {number} Transformed value in [0,1]
|
||||
*/
|
||||
function velocityCurveApply(value, params) {
|
||||
function velocityCurveApply(value, curve) {
|
||||
const v = value < 0 ? 0 : value > 1 ? 1 : value;
|
||||
switch (params.shape) {
|
||||
switch (curve) {
|
||||
case 'exponential':
|
||||
return v * v;
|
||||
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 {{ threshold: number }} params - threshold in [0,1]
|
||||
* @returns {boolean}
|
||||
* @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 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;
|
||||
function octaveFold(note, low, high) {
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
/** @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
|
||||
* 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 }}
|
||||
* @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 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) {
|
||||
export function createProjection(opts = {}) {
|
||||
return {
|
||||
transforms: transforms,
|
||||
valid: false,
|
||||
error:
|
||||
'Type mismatch at index ' + group[i].index +
|
||||
': ' + prev.name + ' outputs ' + prev.outputType +
|
||||
' but ' + curr.name + ' expects ' + curr.inputType,
|
||||
velocityCurve: opts.velocityCurve || 'linear',
|
||||
gateThreshold: opts.gateThreshold ?? 0,
|
||||
pitchRange: {
|
||||
low: opts.pitchRange?.low ?? 0,
|
||||
high: opts.pitchRange?.high ?? 127,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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:
|
||||
* - 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 {{ 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(chain, patternDesc) {
|
||||
if (!chain.valid) {
|
||||
throw new Error('Cannot apply invalid projection chain: ' + chain.error);
|
||||
}
|
||||
if (!chain.transforms || chain.transforms.length === 0) {
|
||||
return patternDesc;
|
||||
}
|
||||
|
||||
export function applyProjection(config, 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;
|
||||
// 1. Velocity curve
|
||||
step.velocity = velocityCurveApply(step.velocity, config.velocityCurve);
|
||||
|
||||
// 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;
|
||||
// 2. Gate threshold (uses post-curve velocity)
|
||||
if (step.velocity < config.gateThreshold) {
|
||||
step.trigger = false;
|
||||
}
|
||||
|
||||
// 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)));
|
||||
// 3. Pitch range (octave fold)
|
||||
if (step.midiNote != null) {
|
||||
step.midiNote = octaveFold(step.midiNote, config.pitchRange.low, config.pitchRange.high);
|
||||
}
|
||||
}
|
||||
|
||||
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 { ClockEngine } from './clock.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 {
|
||||
EuclideanRhythm,
|
||||
|
|
@ -108,12 +108,8 @@ export class ShapeSeqEngine {
|
|||
this._clock = new ClockEngine(this._audioCtx, this._bus);
|
||||
this._clock.bpm = DEFAULT_BPM;
|
||||
|
||||
// 4. Create default projection chain (expressive preset)
|
||||
const result = createProjectionChain(PRESETS.expressive);
|
||||
if (!result.valid) {
|
||||
throw new Error('Default projection chain invalid: ' + result.error);
|
||||
}
|
||||
this._projectionChain = result;
|
||||
// 4. Create default projection
|
||||
this._projectionChain = createProjection();
|
||||
|
||||
// 5. Subscribe to event bus for C15 bridge integration
|
||||
this._bus.on(SEQ.NOTE_ON, this._onNoteOn);
|
||||
|
|
@ -194,19 +190,27 @@ export class ShapeSeqEngine {
|
|||
}
|
||||
|
||||
/**
|
||||
* Set the projection preset by name.
|
||||
* @param {'expressive'|'percussive'|'fullRange'} presetName
|
||||
* Update the projection config. Accepts partial options merged with current.
|
||||
* @param {Object} opts - Partial projection config
|
||||
*/
|
||||
setProjectionPreset(presetName) {
|
||||
const preset = PRESETS[presetName];
|
||||
if (!preset) {
|
||||
throw new Error('Unknown projection preset: ' + presetName);
|
||||
setProjection(opts) {
|
||||
const cur = this._projectionChain;
|
||||
this._projectionChain = createProjection({
|
||||
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);
|
||||
}
|
||||
this._projectionChain = result;
|
||||
|
||||
/**
|
||||
* Get the current projection config.
|
||||
* @returns {{ velocityCurve: string, gateThreshold: number, pitchRange: { low: number, high: number } }}
|
||||
*/
|
||||
getProjection() {
|
||||
return this._projectionChain;
|
||||
}
|
||||
|
||||
// ── 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