From 122bdb9843e57803f622ca550222c714fd00f9c5 Mon Sep 17 00:00:00 2001 From: w1n5t0n Date: Mon, 6 Apr 2026 23:51:57 +0100 Subject: [PATCH] feat(sequencer): generation counter forces re-eval on config changes The dirty-check that skips re-evaluation when inputs haven't changed now also checks a generation counter. Config changes (tempo, step count, projection, chain edits) bump the counter, ensuring the pipeline re-runs even when joystick position is static. --- playground/js/shapeseq/sequencer.js | 50 ++++-- .../tests/sequencer-generation.test.js | 165 ++++++++++++++++++ 2 files changed, 205 insertions(+), 10 deletions(-) create mode 100644 playground/js/shapeseq/tests/sequencer-generation.test.js diff --git a/playground/js/shapeseq/sequencer.js b/playground/js/shapeseq/sequencer.js index b0e52ac..311645e 100644 --- a/playground/js/shapeseq/sequencer.js +++ b/playground/js/shapeseq/sequencer.js @@ -25,7 +25,7 @@ import { Chain } from './chain.js'; import { ClockEngine } from './clock.js'; import { map } from './param-map.js'; import { createProjection, applyProjection } from './projection.js'; -import { SEQ } from './event-bus.js'; +import { SEQ, UI } from './event-bus.js'; import { EuclideanRhythm, ProbabilityGate, @@ -70,6 +70,10 @@ export class ShapeSeqEngine { // Dirty-check: skip re-evaluation when inputs haven't changed /** @private */ this._lastInputs = [NaN, NaN]; + // Generation counter: bumped on config changes to force re-evaluation + /** @private */ this._generation = 0; + /** @private */ this._lastGeneration = -1; + // Track active notes for orphan prevention /** @private @type {Set} */ this._activeNotes = new Set(); @@ -81,6 +85,8 @@ export class ShapeSeqEngine { this._onNoteOff = (data) => this._handleNoteOff(data); /** @private */ this._onLoopStart = () => this._handleLoopStart(); + /** @private */ + this._onChainEdit = () => this._bumpGeneration(); } // ── Lifecycle ────────────────────────────────────────────────────── @@ -116,7 +122,10 @@ 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 + // 6. Subscribe to chain edits so config changes force re-evaluation + this._bus.on(UI.CHAIN_EDIT, this._onChainEdit); + + // 7. Subscribe to loop start for stateful primitive re-evaluation this._bus.on(SEQ.LOOP_START, this._onLoopStart); this._initialized = true; @@ -155,6 +164,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(UI.CHAIN_EDIT, this._onChainEdit); this._bus.off(SEQ.LOOP_START, this._onLoopStart); // Destroy the sequence IML instance @@ -179,6 +189,7 @@ export class ShapeSeqEngine { if (this._clock) { this._clock.setTempo(bpm); } + this._bumpGeneration(); } /** @@ -188,6 +199,7 @@ export class ShapeSeqEngine { setStepCount(count) { const c = Math.max(1, count | 0); this._stepCount = c; + this._bumpGeneration(); } /** @@ -204,6 +216,7 @@ export class ShapeSeqEngine { high: opts.pitchRange?.high ?? cur.pitchRange.high, }, }); + this._bumpGeneration(); } /** @@ -239,6 +252,8 @@ export class ShapeSeqEngine { // Create new instance with updated architecture this._sequenceIML = await createSequenceIML({ outputCount: count }); this._sequenceIML.randomiseWeights(DEFAULT_SPREAD); + + this._bumpGeneration(); } // ── Chain access (for UI binding) ────────────────────────────────── @@ -265,14 +280,17 @@ export class ShapeSeqEngine { setSequenceInputs(values) { if (!this._initialized || !this._sequenceIML) return; - // Dirty-check: skip re-evaluation if inputs haven't changed + // Dirty-check: skip re-evaluation if inputs AND config haven't changed const EPS = 1e-5; - if (Math.abs(values[0] - this._lastInputs[0]) < EPS && - Math.abs(values[1] - this._lastInputs[1]) < EPS) { + const inputsSame = Math.abs(values[0] - this._lastInputs[0]) < EPS && + Math.abs(values[1] - this._lastInputs[1]) < EPS; + const generationSame = this._generation === this._lastGeneration; + if (inputsSame && generationSame) { return; } this._lastInputs[0] = values[0]; this._lastInputs[1] = values[1]; + this._lastGeneration = this._generation; // 1. Forward inputs to the sequence IML this._sequenceIML.setInputs(values); @@ -304,6 +322,17 @@ export class ShapeSeqEngine { return this._playing; } + // ── Generation counter (private) ─────────────────────────────────── + + /** + * Increment the generation counter to force re-evaluation on next + * setSequenceInputs() call, even if inputs haven't changed. + * @private + */ + _bumpGeneration() { + this._generation++; + } + // ── Loop re-evaluation (private) ────────────────────────────────── /** @@ -318,13 +347,14 @@ export class ShapeSeqEngine { if (!this._initialized || !this._chain || !this._sequenceIML) return; if (!this._chain.hasReEvalPrimitives()) return; + // Bump the generation counter so the next setSequenceInputs() call + // bypasses the dirty-check and re-runs the full pipeline. + this._bumpGeneration(); + // If we have cached inputs, force an immediate re-evaluation now + // (rather than waiting for the next setSequenceInputs() frame). 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); + this.setSequenceInputs(this._lastInputs); } } diff --git a/playground/js/shapeseq/tests/sequencer-generation.test.js b/playground/js/shapeseq/tests/sequencer-generation.test.js new file mode 100644 index 0000000..0e9e524 --- /dev/null +++ b/playground/js/shapeseq/tests/sequencer-generation.test.js @@ -0,0 +1,165 @@ +/** + * Tests for ShapeSeqEngine generation counter dirty-check. + * + * The full ShapeSeqEngine requires AudioContext, C15Bridge, and WASM, + * so we test the generation counter logic by creating a minimal subclass + * that stubs out the heavy dependencies while preserving the dirty-check + * and config-mutation methods. + * + * Run with Node >= 18: node --test playground/js/shapeseq/tests/sequencer-generation.test.js + */ + +import { describe, it, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; + +// ── Minimal stub of EventBus ──────────────────────────────────────── + +class StubEventBus { + constructor() { this._listeners = new Map(); } + on(event, fn) { + if (!this._listeners.has(event)) this._listeners.set(event, new Set()); + this._listeners.get(event).add(fn); + } + off(event, fn) { + this._listeners.get(event)?.delete(fn); + } + emit(event, data) { + for (const fn of this._listeners.get(event) ?? []) fn(data); + } +} + +// ── Minimal harness that replicates the generation + dirty-check logic +// from ShapeSeqEngine without needing AudioContext / WASM / C15 ──── + +class GenerationTestHarness { + constructor(eventBus) { + this._bus = eventBus; + + // Dirty-check state (mirrors sequencer.js) + this._lastInputs = [NaN, NaN]; + this._generation = 0; + this._lastGeneration = -1; + + // Track how many times the pipeline actually ran + this.evaluationCount = 0; + + // Bound handler for chain edits + this._onChainEdit = () => this._bumpGeneration(); + this._bus.on('ui.chainEdit', this._onChainEdit); + } + + // Config mutators (same as sequencer.js) + setTempo(_bpm) { this._bumpGeneration(); } + setStepCount(_count) { this._bumpGeneration(); } + setProjection(_name) { this._bumpGeneration(); } + + /** Mirrors ShapeSeqEngine.setSequenceInputs dirty-check exactly. */ + setSequenceInputs(values) { + const EPS = 1e-5; + const inputsSame = Math.abs(values[0] - this._lastInputs[0]) < EPS && + Math.abs(values[1] - this._lastInputs[1]) < EPS; + const generationSame = this._generation === this._lastGeneration; + if (inputsSame && generationSame) { + return; + } + this._lastInputs[0] = values[0]; + this._lastInputs[1] = values[1]; + this._lastGeneration = this._generation; + + // Instead of running MLP pipeline, just count. + this.evaluationCount++; + } + + _bumpGeneration() { this._generation++; } + + destroy() { + this._bus.off('ui.chainEdit', this._onChainEdit); + } +} + +// ── Tests ─────────────────────────────────────────────────────────── + +describe('ShapeSeqEngine generation counter', () => { + let bus; + let engine; + + beforeEach(() => { + bus = new StubEventBus(); + engine = new GenerationTestHarness(bus); + }); + + it('evaluates on first call even with default inputs', () => { + engine.setSequenceInputs([0.5, 0.5]); + assert.equal(engine.evaluationCount, 1); + }); + + it('skips evaluation when inputs are identical', () => { + engine.setSequenceInputs([0.5, 0.5]); + engine.setSequenceInputs([0.5, 0.5]); + assert.equal(engine.evaluationCount, 1); + }); + + it('re-evaluates when inputs change', () => { + engine.setSequenceInputs([0.5, 0.5]); + engine.setSequenceInputs([0.6, 0.5]); + assert.equal(engine.evaluationCount, 2); + }); + + it('re-evaluates on setTempo even with same inputs', () => { + engine.setSequenceInputs([0.5, 0.5]); + assert.equal(engine.evaluationCount, 1); + + engine.setTempo(140); + engine.setSequenceInputs([0.5, 0.5]); + assert.equal(engine.evaluationCount, 2); + }); + + it('re-evaluates on setStepCount even with same inputs', () => { + engine.setSequenceInputs([0.5, 0.5]); + engine.setStepCount(16); + engine.setSequenceInputs([0.5, 0.5]); + assert.equal(engine.evaluationCount, 2); + }); + + it('re-evaluates on setProjection() even with same inputs', () => { + engine.setSequenceInputs([0.5, 0.5]); + engine.setProjection({ gateThreshold: 0.5 }); + engine.setSequenceInputs([0.5, 0.5]); + assert.equal(engine.evaluationCount, 2); + }); + + it('re-evaluates on ui.chainEdit event even with same inputs', () => { + engine.setSequenceInputs([0.5, 0.5]); + bus.emit('ui.chainEdit', { action: 'add', name: 'EuclideanRhythm' }); + engine.setSequenceInputs([0.5, 0.5]); + assert.equal(engine.evaluationCount, 2); + }); + + it('generation bump is consumed after one evaluation', () => { + engine.setSequenceInputs([0.5, 0.5]); + engine.setTempo(140); + engine.setSequenceInputs([0.5, 0.5]); // consumes the bump + engine.setSequenceInputs([0.5, 0.5]); // should be skipped + assert.equal(engine.evaluationCount, 2); + }); + + it('multiple config changes before evaluation only cause one extra eval', () => { + engine.setSequenceInputs([0.5, 0.5]); + engine.setTempo(140); + engine.setStepCount(16); + engine.setProjection({ velocityCurve: 'sCurve' }); + bus.emit('ui.chainEdit', { action: 'remove', index: 0 }); + engine.setSequenceInputs([0.5, 0.5]); + assert.equal(engine.evaluationCount, 2); + }); + + it('generation counter increments correctly', () => { + assert.equal(engine._generation, 0); + engine.setTempo(100); + assert.equal(engine._generation, 1); + engine.setStepCount(4); + assert.equal(engine._generation, 2); + bus.emit('ui.chainEdit', { action: 'reorder', from: 0, to: 1 }); + assert.equal(engine._generation, 3); + }); +});