memlnaut-nisps/playground/js/shapeseq/tests/projection.test.js

209 lines
8.1 KiB
JavaScript
Raw Normal View History

/**
* 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);
});
});