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:
parent
b4b4025f59
commit
af4f19bc72
6 changed files with 364 additions and 11 deletions
|
|
@ -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) ─────────────────────
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) ───────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
|
|||
214
playground/js/shapeseq/tests/pitch-walker.test.js
Normal file
214
playground/js/shapeseq/tests/pitch-walker.test.js
Normal 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');
|
||||
}
|
||||
});
|
||||
});
|
||||
86
playground/js/shapeseq/tests/re-eval.test.js
Normal file
86
playground/js/shapeseq/tests/re-eval.test.js
Normal 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)
|
||||
Loading…
Reference in a new issue