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:
parent
5796d8fd35
commit
122bdb9843
2 changed files with 205 additions and 10 deletions
|
|
@ -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<number>} */
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
165
playground/js/shapeseq/tests/sequencer-generation.test.js
Normal file
165
playground/js/shapeseq/tests/sequencer-generation.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue