PitchWalker was classified as a generator but always consumed the incoming pattern's triggers. Reclassify as a processor so it modifies the pattern produced by an upstream generator (clonePattern instead of createPattern). Add reEvalOnLoop flag on the Primitive base class; when set, the sequencer re-evaluates the chain at each loop start so stateful primitives produce evolving patterns across loops.
214 lines
7.6 KiB
JavaScript
214 lines
7.6 KiB
JavaScript
/**
|
|
* Tests for PitchWalker reclassified as a processor.
|
|
*
|
|
* Verifies that PitchWalker:
|
|
* - Has category 'processor'
|
|
* - Preserves upstream trigger patterns
|
|
* - Preserves upstream step data (velocity, accent, timeOffset, subdivisions)
|
|
* - Only modifies pitch on triggered steps
|
|
* - Leaves untriggered steps completely unchanged
|
|
* - Advances position state
|
|
* - Integrates correctly in a Chain with a generator upstream
|
|
*/
|
|
|
|
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import { PitchWalker, EuclideanRhythm } from '../primitives.js';
|
|
import { createPattern, setStep } from '../pattern.js';
|
|
import { createPRNG } from '../prng.js';
|
|
import { Chain } from '../chain.js';
|
|
|
|
// Helper: build a pattern with specific triggers and custom step data
|
|
function makeTestPattern(stepCount, triggerIndices, stepOverrides) {
|
|
const pattern = createPattern(stepCount);
|
|
for (const idx of triggerIndices) {
|
|
setStep(pattern, idx, { trigger: true });
|
|
}
|
|
if (stepOverrides) {
|
|
for (const [idx, data] of Object.entries(stepOverrides)) {
|
|
setStep(pattern, Number(idx), data);
|
|
}
|
|
}
|
|
return pattern;
|
|
}
|
|
|
|
describe('PitchWalker', () => {
|
|
it('has category "processor"', () => {
|
|
const pw = new PitchWalker();
|
|
assert.equal(pw.category, 'processor');
|
|
});
|
|
|
|
it('preserves upstream trigger pattern', () => {
|
|
const pw = new PitchWalker();
|
|
const rng = createPRNG(42);
|
|
const triggers = [0, 2, 5, 7];
|
|
const input = makeTestPattern(8, triggers);
|
|
|
|
const { patternDesc } = pw.process(
|
|
new Float32Array([0.3, 0.5, 0.3, 0.8]),
|
|
input, {}, rng
|
|
);
|
|
|
|
for (let i = 0; i < 8; i++) {
|
|
const expected = triggers.includes(i);
|
|
assert.equal(patternDesc.steps[i].trigger, expected,
|
|
`step ${i} trigger should be ${expected}`);
|
|
}
|
|
});
|
|
|
|
it('preserves upstream velocity, accent, timeOffset, subdivisions on triggered steps', () => {
|
|
const pw = new PitchWalker();
|
|
const rng = createPRNG(99);
|
|
const input = makeTestPattern(4, [1, 3], {
|
|
1: { trigger: true, velocity: 0.9, accent: true, timeOffset: 0.1, subdivisions: 2 },
|
|
3: { trigger: true, velocity: 0.4, accent: false, timeOffset: -0.2, subdivisions: 3 },
|
|
});
|
|
|
|
const { patternDesc } = pw.process(
|
|
new Float32Array([0.3, 0.5, 0.3, 0.8]),
|
|
input, {}, rng
|
|
);
|
|
|
|
// Step 1
|
|
assert.equal(patternDesc.steps[1].velocity, 0.9);
|
|
assert.equal(patternDesc.steps[1].accent, true);
|
|
assert.equal(patternDesc.steps[1].timeOffset, 0.1);
|
|
assert.equal(patternDesc.steps[1].subdivisions, 2);
|
|
|
|
// Step 3
|
|
assert.equal(patternDesc.steps[3].velocity, 0.4);
|
|
assert.equal(patternDesc.steps[3].accent, false);
|
|
assert.equal(patternDesc.steps[3].timeOffset, -0.2);
|
|
assert.equal(patternDesc.steps[3].subdivisions, 3);
|
|
});
|
|
|
|
it('modifies pitch on triggered steps only', () => {
|
|
const pw = new PitchWalker();
|
|
const rng = createPRNG(7);
|
|
const input = makeTestPattern(8, [0, 3, 6]);
|
|
|
|
// Record original pitches
|
|
const origPitches = input.steps.map(s => s.pitch);
|
|
|
|
const { patternDesc } = pw.process(
|
|
new Float32Array([0.5, 0.5, 0.5, 1.0]),
|
|
input, {}, rng
|
|
);
|
|
|
|
// Triggered steps should have pitch set by random walk (may differ from default 0.5)
|
|
// We just verify they are valid numbers in [0,1]
|
|
for (const idx of [0, 3, 6]) {
|
|
const p = patternDesc.steps[idx].pitch;
|
|
assert.equal(typeof p, 'number');
|
|
assert.ok(p >= 0 && p <= 1, `pitch at step ${idx} should be in [0,1], got ${p}`);
|
|
}
|
|
});
|
|
|
|
it('does NOT modify untriggered steps at all', () => {
|
|
const pw = new PitchWalker();
|
|
const rng = createPRNG(123);
|
|
const input = makeTestPattern(6, [1, 4], {
|
|
0: { velocity: 0.3, accent: true, timeOffset: 0.15 },
|
|
2: { velocity: 0.8, accent: false, timeOffset: -0.1 },
|
|
});
|
|
|
|
// Snapshot untriggered steps before
|
|
const untriggeredBefore = {};
|
|
for (let i = 0; i < 6; i++) {
|
|
if (![1, 4].includes(i)) {
|
|
const s = input.steps[i];
|
|
untriggeredBefore[i] = {
|
|
trigger: s.trigger,
|
|
pitch: s.pitch,
|
|
velocity: s.velocity,
|
|
accent: s.accent,
|
|
timeOffset: s.timeOffset,
|
|
subdivisions: s.subdivisions,
|
|
};
|
|
}
|
|
}
|
|
|
|
const { patternDesc } = pw.process(
|
|
new Float32Array([0.3, 0.5, 0.3, 0.8]),
|
|
input, {}, rng
|
|
);
|
|
|
|
for (const [idx, before] of Object.entries(untriggeredBefore)) {
|
|
const after = patternDesc.steps[Number(idx)];
|
|
assert.equal(after.trigger, before.trigger, `step ${idx} trigger unchanged`);
|
|
assert.equal(after.pitch, before.pitch, `step ${idx} pitch unchanged`);
|
|
assert.equal(after.velocity, before.velocity, `step ${idx} velocity unchanged`);
|
|
assert.equal(after.accent, before.accent, `step ${idx} accent unchanged`);
|
|
assert.equal(after.timeOffset, before.timeOffset, `step ${idx} timeOffset unchanged`);
|
|
assert.equal(after.subdivisions, before.subdivisions, `step ${idx} subdivisions unchanged`);
|
|
}
|
|
});
|
|
|
|
it('advances the position state', () => {
|
|
const pw = new PitchWalker();
|
|
const rng = createPRNG(55);
|
|
const input = makeTestPattern(8, [0, 1, 2, 3, 4, 5, 6, 7]);
|
|
|
|
const initialPosition = 0.5;
|
|
const { nextState } = pw.process(
|
|
new Float32Array([0.5, 0.5, 0.3, 1.0]),
|
|
input, { position: initialPosition }, rng
|
|
);
|
|
|
|
assert.equal(typeof nextState.position, 'number');
|
|
// With 8 triggered steps and nonzero stepSize/range, position should move
|
|
assert.notEqual(nextState.position, initialPosition,
|
|
'position should change after processing triggered steps');
|
|
});
|
|
|
|
it('does not mutate the input pattern', () => {
|
|
const pw = new PitchWalker();
|
|
const rng = createPRNG(42);
|
|
const input = makeTestPattern(4, [0, 2]);
|
|
const origPitch0 = input.steps[0].pitch;
|
|
const origPitch1 = input.steps[1].pitch;
|
|
|
|
pw.process(new Float32Array([0.3, 0.5, 0.3, 0.8]), input, {}, rng);
|
|
|
|
// clonePattern should prevent mutation of the original
|
|
assert.equal(input.steps[0].pitch, origPitch0);
|
|
assert.equal(input.steps[1].pitch, origPitch1);
|
|
});
|
|
});
|
|
|
|
describe('PitchWalker integration with Chain', () => {
|
|
it('runs after generators and receives their trigger pattern', () => {
|
|
const chain = new Chain();
|
|
chain.addPrimitive(new EuclideanRhythm());
|
|
chain.addPrimitive(new PitchWalker());
|
|
|
|
// EuclideanRhythm params: steps=0.5, pulses=0.5, rotation=0.0
|
|
// PitchWalker params: stepSize=0.3, directionBias=0.5, gravity=0.3, range=0.8
|
|
const params = new Float32Array([0.5, 0.5, 0.0, 0.3, 0.5, 0.3, 0.8]);
|
|
const stepCount = 8;
|
|
const result = chain.evaluate(params, stepCount, 42);
|
|
|
|
// The result should have stepCount steps
|
|
assert.equal(result.stepCount, stepCount);
|
|
|
|
// Count triggers — EuclideanRhythm with steps=0.5 (maps to ~5) and pulses=0.5
|
|
// should produce some triggers
|
|
const triggeredSteps = result.steps.filter(s => s.trigger);
|
|
assert.ok(triggeredSteps.length > 0,
|
|
'should have at least one triggered step from EuclideanRhythm');
|
|
|
|
// Triggered steps should have pitch values set by PitchWalker (not default 0.5)
|
|
// With enough steps, at least some should differ from 0.5
|
|
const pitches = triggeredSteps.map(s => s.pitch);
|
|
const allDefault = pitches.every(p => p === 0.5);
|
|
assert.ok(!allDefault || pitches.length <= 1,
|
|
'PitchWalker should have modified pitch on triggered steps');
|
|
|
|
// Untriggered steps should retain default pitch
|
|
const untriggeredSteps = result.steps.filter(s => !s.trigger);
|
|
for (const s of untriggeredSteps) {
|
|
assert.equal(s.pitch, 0.5, 'untriggered steps should have default pitch');
|
|
}
|
|
});
|
|
});
|