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();
|
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) ─────────────────────
|
// ── Per-generator step counts (polyrhythm) ─────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -154,6 +154,14 @@ export class Primitive {
|
||||||
return Object.freeze(frozen);
|
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 */
|
/** @private */
|
||||||
this._seed = 0;
|
this._seed = 0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -147,13 +147,17 @@ export class ProbabilityGate extends Primitive {
|
||||||
|
|
||||||
export class PitchWalker extends Primitive {
|
export class PitchWalker extends Primitive {
|
||||||
constructor() {
|
constructor() {
|
||||||
super('PitchWalker', 'generator', [
|
super('PitchWalker', 'processor', [
|
||||||
{ name: 'stepSize', default: 0.3, boundary: 'clamp' },
|
{ name: 'stepSize', default: 0.3, boundary: 'clamp' },
|
||||||
{ name: 'directionBias', default: 0.5, boundary: 'clamp' },
|
{ name: 'directionBias', default: 0.5, boundary: 'clamp' },
|
||||||
{ name: 'gravity', default: 0.3, boundary: 'clamp' },
|
{ name: 'gravity', default: 0.3, boundary: 'clamp' },
|
||||||
{ name: 'range', default: 0.8, 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 */
|
/** @private */
|
||||||
this._position = 0.5;
|
this._position = 0.5;
|
||||||
}
|
}
|
||||||
|
|
@ -179,16 +183,11 @@ export class PitchWalker extends Primitive {
|
||||||
? state.position
|
? state.position
|
||||||
: this._position;
|
: this._position;
|
||||||
|
|
||||||
const pattern = createPattern(patternDesc.stepCount);
|
const pattern = clonePattern(patternDesc);
|
||||||
let currentRng = rng;
|
let currentRng = rng;
|
||||||
|
|
||||||
// Use incoming pattern's triggers if available, otherwise all triggered
|
for (let i = 0; i < pattern.stepCount; i++) {
|
||||||
const srcSteps = patternDesc.steps;
|
if (pattern.steps[i].trigger) {
|
||||||
|
|
||||||
for (let i = 0; i < patternDesc.stepCount; i++) {
|
|
||||||
const triggered = srcSteps[i].trigger;
|
|
||||||
|
|
||||||
if (triggered) {
|
|
||||||
// Random walk step
|
// Random walk step
|
||||||
const r1 = next(currentRng);
|
const r1 = next(currentRng);
|
||||||
currentRng = r1.nextState;
|
currentRng = r1.nextState;
|
||||||
|
|
@ -207,9 +206,9 @@ export class PitchWalker extends Primitive {
|
||||||
if (position < 0) position = 0;
|
if (position < 0) position = 0;
|
||||||
if (position > 1) position = 1;
|
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;
|
this._position = position;
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,8 @@ export class ShapeSeqEngine {
|
||||||
this._onNoteOn = (data) => this._handleNoteOn(data);
|
this._onNoteOn = (data) => this._handleNoteOn(data);
|
||||||
/** @private */
|
/** @private */
|
||||||
this._onNoteOff = (data) => this._handleNoteOff(data);
|
this._onNoteOff = (data) => this._handleNoteOff(data);
|
||||||
|
/** @private */
|
||||||
|
this._onLoopStart = () => this._handleLoopStart();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Lifecycle ──────────────────────────────────────────────────────
|
// ── Lifecycle ──────────────────────────────────────────────────────
|
||||||
|
|
@ -117,6 +119,9 @@ export class ShapeSeqEngine {
|
||||||
this._bus.on(SEQ.NOTE_ON, this._onNoteOn);
|
this._bus.on(SEQ.NOTE_ON, this._onNoteOn);
|
||||||
this._bus.on(SEQ.NOTE_OFF, this._onNoteOff);
|
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;
|
this._initialized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -153,6 +158,7 @@ export class ShapeSeqEngine {
|
||||||
// Unsubscribe from event bus
|
// Unsubscribe from event bus
|
||||||
this._bus.off(SEQ.NOTE_ON, this._onNoteOn);
|
this._bus.off(SEQ.NOTE_ON, this._onNoteOn);
|
||||||
this._bus.off(SEQ.NOTE_OFF, this._onNoteOff);
|
this._bus.off(SEQ.NOTE_OFF, this._onNoteOff);
|
||||||
|
this._bus.off(SEQ.LOOP_START, this._onLoopStart);
|
||||||
|
|
||||||
// Destroy the sequence IML instance
|
// Destroy the sequence IML instance
|
||||||
if (this._sequenceIML) {
|
if (this._sequenceIML) {
|
||||||
|
|
@ -266,6 +272,30 @@ export class ShapeSeqEngine {
|
||||||
return this._playing;
|
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) ───────────────────────────────────
|
// ── 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