diff --git a/playground/js/shapeseq/chain.js b/playground/js/shapeseq/chain.js index b3364ee..27cf5d1 100644 --- a/playground/js/shapeseq/chain.js +++ b/playground/js/shapeseq/chain.js @@ -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) ───────────────────── /** diff --git a/playground/js/shapeseq/primitive.js b/playground/js/shapeseq/primitive.js index ebec5b6..ee29bf2 100644 --- a/playground/js/shapeseq/primitive.js +++ b/playground/js/shapeseq/primitive.js @@ -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; } diff --git a/playground/js/shapeseq/primitives.js b/playground/js/shapeseq/primitives.js index 517dd95..d65dde0 100644 --- a/playground/js/shapeseq/primitives.js +++ b/playground/js/shapeseq/primitives.js @@ -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; diff --git a/playground/js/shapeseq/sequencer.js b/playground/js/shapeseq/sequencer.js index 7fe39dc..df0e230 100644 --- a/playground/js/shapeseq/sequencer.js +++ b/playground/js/shapeseq/sequencer.js @@ -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) ─────────────────────────────────── /** diff --git a/playground/js/shapeseq/tests/pitch-walker.test.js b/playground/js/shapeseq/tests/pitch-walker.test.js new file mode 100644 index 0000000..76e4ecd --- /dev/null +++ b/playground/js/shapeseq/tests/pitch-walker.test.js @@ -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'); + } + }); +}); diff --git a/playground/js/shapeseq/tests/re-eval.test.js b/playground/js/shapeseq/tests/re-eval.test.js new file mode 100644 index 0000000..6554fba --- /dev/null +++ b/playground/js/shapeseq/tests/re-eval.test.js @@ -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)