209 lines
8.1 KiB
JavaScript
209 lines
8.1 KiB
JavaScript
|
|
/**
|
||
|
|
* 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);
|
||
|
|
});
|
||
|
|
});
|