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.
This commit is contained in:
w1n5t0n 2026-04-06 23:51:57 +01:00
parent 5796d8fd35
commit 122bdb9843
2 changed files with 205 additions and 10 deletions

View file

@ -25,7 +25,7 @@ import { Chain } from './chain.js';
import { ClockEngine } from './clock.js'; import { ClockEngine } from './clock.js';
import { map } from './param-map.js'; import { map } from './param-map.js';
import { createProjection, applyProjection } from './projection.js'; import { createProjection, applyProjection } from './projection.js';
import { SEQ } from './event-bus.js'; import { SEQ, UI } from './event-bus.js';
import { import {
EuclideanRhythm, EuclideanRhythm,
ProbabilityGate, ProbabilityGate,
@ -70,6 +70,10 @@ export class ShapeSeqEngine {
// Dirty-check: skip re-evaluation when inputs haven't changed // Dirty-check: skip re-evaluation when inputs haven't changed
/** @private */ this._lastInputs = [NaN, NaN]; /** @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 // Track active notes for orphan prevention
/** @private @type {Set<number>} */ /** @private @type {Set<number>} */
this._activeNotes = new Set(); this._activeNotes = new Set();
@ -81,6 +85,8 @@ export class ShapeSeqEngine {
this._onNoteOff = (data) => this._handleNoteOff(data); this._onNoteOff = (data) => this._handleNoteOff(data);
/** @private */ /** @private */
this._onLoopStart = () => this._handleLoopStart(); this._onLoopStart = () => this._handleLoopStart();
/** @private */
this._onChainEdit = () => this._bumpGeneration();
} }
// ── Lifecycle ────────────────────────────────────────────────────── // ── Lifecycle ──────────────────────────────────────────────────────
@ -116,7 +122,10 @@ 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 // 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._bus.on(SEQ.LOOP_START, this._onLoopStart);
this._initialized = true; this._initialized = true;
@ -155,6 +164,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(UI.CHAIN_EDIT, this._onChainEdit);
this._bus.off(SEQ.LOOP_START, this._onLoopStart); this._bus.off(SEQ.LOOP_START, this._onLoopStart);
// Destroy the sequence IML instance // Destroy the sequence IML instance
@ -179,6 +189,7 @@ export class ShapeSeqEngine {
if (this._clock) { if (this._clock) {
this._clock.setTempo(bpm); this._clock.setTempo(bpm);
} }
this._bumpGeneration();
} }
/** /**
@ -188,6 +199,7 @@ export class ShapeSeqEngine {
setStepCount(count) { setStepCount(count) {
const c = Math.max(1, count | 0); const c = Math.max(1, count | 0);
this._stepCount = c; this._stepCount = c;
this._bumpGeneration();
} }
/** /**
@ -204,6 +216,7 @@ export class ShapeSeqEngine {
high: opts.pitchRange?.high ?? cur.pitchRange.high, high: opts.pitchRange?.high ?? cur.pitchRange.high,
}, },
}); });
this._bumpGeneration();
} }
/** /**
@ -239,6 +252,8 @@ export class ShapeSeqEngine {
// Create new instance with updated architecture // Create new instance with updated architecture
this._sequenceIML = await createSequenceIML({ outputCount: count }); this._sequenceIML = await createSequenceIML({ outputCount: count });
this._sequenceIML.randomiseWeights(DEFAULT_SPREAD); this._sequenceIML.randomiseWeights(DEFAULT_SPREAD);
this._bumpGeneration();
} }
// ── Chain access (for UI binding) ────────────────────────────────── // ── Chain access (for UI binding) ──────────────────────────────────
@ -265,14 +280,17 @@ export class ShapeSeqEngine {
setSequenceInputs(values) { setSequenceInputs(values) {
if (!this._initialized || !this._sequenceIML) return; 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; const EPS = 1e-5;
if (Math.abs(values[0] - this._lastInputs[0]) < EPS && const inputsSame = Math.abs(values[0] - this._lastInputs[0]) < EPS &&
Math.abs(values[1] - this._lastInputs[1]) < EPS) { Math.abs(values[1] - this._lastInputs[1]) < EPS;
const generationSame = this._generation === this._lastGeneration;
if (inputsSame && generationSame) {
return; return;
} }
this._lastInputs[0] = values[0]; this._lastInputs[0] = values[0];
this._lastInputs[1] = values[1]; this._lastInputs[1] = values[1];
this._lastGeneration = this._generation;
// 1. Forward inputs to the sequence IML // 1. Forward inputs to the sequence IML
this._sequenceIML.setInputs(values); this._sequenceIML.setInputs(values);
@ -304,6 +322,17 @@ export class ShapeSeqEngine {
return this._playing; 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) ────────────────────────────────── // ── Loop re-evaluation (private) ──────────────────────────────────
/** /**
@ -318,13 +347,14 @@ export class ShapeSeqEngine {
if (!this._initialized || !this._chain || !this._sequenceIML) return; if (!this._initialized || !this._chain || !this._sequenceIML) return;
if (!this._chain.hasReEvalPrimitives()) 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 // 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])) { if (!isNaN(this._lastInputs[0]) && !isNaN(this._lastInputs[1])) {
const savedInputs = [this._lastInputs[0], this._lastInputs[1]]; this.setSequenceInputs(this._lastInputs);
// Reset dirty-check so setSequenceInputs re-runs the pipeline
this._lastInputs[0] = NaN;
this._lastInputs[1] = NaN;
this.setSequenceInputs(savedInputs);
} }
} }

View file

@ -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);
});
});