diff --git a/playground/js/shapeseq/projection.js b/playground/js/shapeseq/projection.js index d4d5889..dfc766b 100644 --- a/playground/js/shapeseq/projection.js +++ b/playground/js/shapeseq/projection.js @@ -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) { - 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 }; +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 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' }, - ], -}; diff --git a/playground/js/shapeseq/sequencer.js b/playground/js/shapeseq/sequencer.js index df0e230..c8f3989 100644 --- a/playground/js/shapeseq/sequencer.js +++ b/playground/js/shapeseq/sequencer.js @@ -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); - } - const result = createProjectionChain(preset); - if (!result.valid) { - throw new Error('Projection chain invalid: ' + result.error); - } - this._projectionChain = result; + 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, + }, + }); + } + + /** + * Get the current projection config. + * @returns {{ velocityCurve: string, gateThreshold: number, pitchRange: { low: number, high: number } }} + */ + getProjection() { + return this._projectionChain; } // ── Chain access (for UI binding) ────────────────────────────────── diff --git a/playground/js/shapeseq/tests/projection.test.js b/playground/js/shapeseq/tests/projection.test.js new file mode 100644 index 0000000..1156582 --- /dev/null +++ b/playground/js/shapeseq/tests/projection.test.js @@ -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); + }); +});