refactor(PitchWalker): redesign as processor, add reEvalOnLoop

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.
This commit is contained in:
w1n5t0n 2026-04-06 23:37:32 +01:00
parent b4b4025f59
commit af4f19bc72
6 changed files with 364 additions and 11 deletions

View file

@ -119,6 +119,22 @@ export class Chain {
return this._primitives.slice();
}
/**
* Returns true if any primitive in the chain has reEvalOnLoop === true.
* Used by the sequencer to decide whether to re-evaluate the pipeline
* on each loop start, even when inputs haven't changed.
*
* @returns {boolean}
*/
hasReEvalPrimitives() {
for (let i = 0; i < this._primitives.length; i++) {
if (this._primitives[i].reEvalOnLoop === true) {
return true;
}
}
return false;
}
// ── Per-generator step counts (polyrhythm) ─────────────────────
/**

View file

@ -154,6 +154,14 @@ export class Primitive {
return Object.freeze(frozen);
}));
/**
* When true, the sequencer re-evaluates the chain at each loop start
* even if inputs haven't changed. Useful for stateful primitives
* (e.g. PitchWalker) whose output evolves across evaluations.
* @type {boolean}
*/
this.reEvalOnLoop = false;
/** @private */
this._seed = 0;
}

View file

@ -147,13 +147,17 @@ export class ProbabilityGate extends Primitive {
export class PitchWalker extends Primitive {
constructor() {
super('PitchWalker', 'generator', [
super('PitchWalker', 'processor', [
{ name: 'stepSize', default: 0.3, boundary: 'clamp' },
{ name: 'directionBias', default: 0.5, boundary: 'clamp' },
{ name: 'gravity', default: 0.3, boundary: 'clamp' },
{ name: 'range', default: 0.8, boundary: 'clamp' },
]);
// PitchWalker is stateful — its random walk evolves across evaluations,
// so re-evaluate on each loop start to produce evolving patterns.
this.reEvalOnLoop = true;
/** @private */
this._position = 0.5;
}
@ -179,16 +183,11 @@ export class PitchWalker extends Primitive {
? state.position
: this._position;
const pattern = createPattern(patternDesc.stepCount);
const pattern = clonePattern(patternDesc);
let currentRng = rng;
// Use incoming pattern's triggers if available, otherwise all triggered
const srcSteps = patternDesc.steps;
for (let i = 0; i < patternDesc.stepCount; i++) {
const triggered = srcSteps[i].trigger;
if (triggered) {
for (let i = 0; i < pattern.stepCount; i++) {
if (pattern.steps[i].trigger) {
// Random walk step
const r1 = next(currentRng);
currentRng = r1.nextState;
@ -207,9 +206,9 @@ export class PitchWalker extends Primitive {
if (position < 0) position = 0;
if (position > 1) position = 1;
setStep(pattern, i, { trigger: true, pitch: position });
pattern.steps[i].pitch = position;
}
// Untriggered steps keep default pitch, trigger=false
// Untriggered steps: preserve existing data unchanged
}
this._position = position;

View file

@ -78,6 +78,8 @@ export class ShapeSeqEngine {
this._onNoteOn = (data) => this._handleNoteOn(data);
/** @private */
this._onNoteOff = (data) => this._handleNoteOff(data);
/** @private */
this._onLoopStart = () => this._handleLoopStart();
}
// ── Lifecycle ──────────────────────────────────────────────────────
@ -117,6 +119,9 @@ export class ShapeSeqEngine {
this._bus.on(SEQ.NOTE_ON, this._onNoteOn);
this._bus.on(SEQ.NOTE_OFF, this._onNoteOff);
// 6. Subscribe to loop start for stateful primitive re-evaluation
this._bus.on(SEQ.LOOP_START, this._onLoopStart);
this._initialized = true;
}
@ -153,6 +158,7 @@ export class ShapeSeqEngine {
// Unsubscribe from event bus
this._bus.off(SEQ.NOTE_ON, this._onNoteOn);
this._bus.off(SEQ.NOTE_OFF, this._onNoteOff);
this._bus.off(SEQ.LOOP_START, this._onLoopStart);
// Destroy the sequence IML instance
if (this._sequenceIML) {
@ -266,6 +272,30 @@ export class ShapeSeqEngine {
return this._playing;
}
// ── Loop re-evaluation (private) ──────────────────────────────────
/**
* Handle seq.loopStart events. If the chain contains stateful primitives
* with reEvalOnLoop === true, force a pipeline re-evaluation using the
* last known inputs. This lets stateful generators (e.g. PitchWalker)
* produce evolving patterns across loops even when inputs stay still.
*
* @private
*/
_handleLoopStart() {
if (!this._initialized || !this._chain || !this._sequenceIML) return;
if (!this._chain.hasReEvalPrimitives()) return;
// If we have cached inputs, force an immediate re-evaluation now
if (!isNaN(this._lastInputs[0]) && !isNaN(this._lastInputs[1])) {
const savedInputs = [this._lastInputs[0], this._lastInputs[1]];
// Reset dirty-check so setSequenceInputs re-runs the pipeline
this._lastInputs[0] = NaN;
this._lastInputs[1] = NaN;
this.setSequenceInputs(savedInputs);
}
}
// ── Bridge integration (private) ───────────────────────────────────
/**

View file

@ -0,0 +1,214 @@
/**
* 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');
}
});
});

View file

@ -0,0 +1,86 @@
import { describe, it } from 'node:test';
import { strict as assert } from 'node:assert';
import { Primitive } from '../primitive.js';
import { PitchWalker, EuclideanRhythm, ProbabilityGate } from '../primitives.js';
import { Chain } from '../chain.js';
// ---------------------------------------------------------------------------
// Primitive.reEvalOnLoop default
// ---------------------------------------------------------------------------
describe('Primitive.reEvalOnLoop', () => {
it('defaults to false on the base class', () => {
// Use a concrete subclass that does not override reEvalOnLoop
const prim = new EuclideanRhythm();
assert.strictEqual(prim.reEvalOnLoop, false);
});
it('defaults to false for non-stateful primitives', () => {
const gate = new ProbabilityGate();
assert.strictEqual(gate.reEvalOnLoop, false);
});
});
// ---------------------------------------------------------------------------
// PitchWalker.reEvalOnLoop
// ---------------------------------------------------------------------------
describe('PitchWalker.reEvalOnLoop', () => {
it('is true', () => {
const walker = new PitchWalker();
assert.strictEqual(walker.reEvalOnLoop, true);
});
});
// ---------------------------------------------------------------------------
// Chain.hasReEvalPrimitives()
// ---------------------------------------------------------------------------
describe('Chain.hasReEvalPrimitives()', () => {
it('returns false for an empty chain', () => {
const chain = new Chain();
assert.strictEqual(chain.hasReEvalPrimitives(), false);
});
it('returns false when no primitives have reEvalOnLoop', () => {
const chain = new Chain();
chain.addPrimitive(new EuclideanRhythm());
chain.addPrimitive(new ProbabilityGate());
assert.strictEqual(chain.hasReEvalPrimitives(), false);
});
it('returns true when at least one primitive has reEvalOnLoop', () => {
const chain = new Chain();
chain.addPrimitive(new EuclideanRhythm());
chain.addPrimitive(new PitchWalker());
assert.strictEqual(chain.hasReEvalPrimitives(), true);
});
it('returns false after removing the only reEvalOnLoop primitive', () => {
const chain = new Chain();
chain.addPrimitive(new EuclideanRhythm());
chain.addPrimitive(new PitchWalker());
assert.strictEqual(chain.hasReEvalPrimitives(), true);
// PitchWalker is at index 1
chain.removePrimitive(1);
assert.strictEqual(chain.hasReEvalPrimitives(), false);
});
});
// ---------------------------------------------------------------------------
// Sequencer integration (requires browser APIs: AudioContext, WASM, etc.)
// ---------------------------------------------------------------------------
// NOTE: ShapeSeqEngine integration tests for loop-start re-evaluation
// require browser APIs (AudioContext, WASM IML, ClockEngine with
// requestAnimationFrame). These must be tested in a browser environment
// or with appropriate mocks (e.g. Playwright e2e tests).
//
// Key behaviors to verify in integration tests:
// 1. seq.loopStart event triggers _handleLoopStart()
// 2. _handleLoopStart() bumps generation and calls setSequenceInputs()
// when chain.hasReEvalPrimitives() is true
// 3. _handleLoopStart() is a no-op when chain has no reEval primitives
// 4. Unsubscribes from seq.loopStart on destroy()
// 5. freeze-as-algorithm (meml-9h1) suppresses re-evaluation (future)