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