2026-03-24 01:10:04 +01:00
|
|
|
/**
|
|
|
|
|
* ShapeSeq Engine — central orchestrator
|
|
|
|
|
*
|
|
|
|
|
* Wires together the sequence MLP, param mapping, primitive chain,
|
|
|
|
|
* projection layer, clock engine, and C15 bridge.
|
|
|
|
|
*
|
|
|
|
|
* Main loop (triggered by setSequenceInputs):
|
|
|
|
|
* 1. Forward inputs to sequenceIML
|
|
|
|
|
* 2. Run MLP inference to get 16 outputs
|
|
|
|
|
* 3. Map 16 outputs to N primitive params via param-map
|
|
|
|
|
* 4. Evaluate the chain to produce a pattern description
|
|
|
|
|
* 5. Apply projection transforms
|
|
|
|
|
* 6. Schedule the pattern on the clock
|
|
|
|
|
*
|
|
|
|
|
* Bridge integration:
|
|
|
|
|
* - Subscribes to seq.noteOn / seq.noteOff on the event bus
|
|
|
|
|
* - Forwards to C15Bridge.noteOn / noteOff
|
|
|
|
|
* - Tracks active notes to avoid orphans
|
|
|
|
|
*
|
|
|
|
|
* @module shapeseq/sequencer
|
|
|
|
|
*/
|
|
|
|
|
|
2026-04-07 00:45:55 +02:00
|
|
|
import { createSequenceIML, SEQ_DEFAULT_OUTPUT_COUNT } from './seq-iml.js';
|
2026-03-24 01:10:04 +01:00
|
|
|
import { Chain } from './chain.js';
|
|
|
|
|
import { ClockEngine } from './clock.js';
|
|
|
|
|
import { map } from './param-map.js';
|
2026-04-07 00:41:30 +02:00
|
|
|
import { createProjection, applyProjection } from './projection.js';
|
2026-04-07 00:51:57 +02:00
|
|
|
import { SEQ, UI } from './event-bus.js';
|
2026-03-24 01:10:04 +01:00
|
|
|
import {
|
|
|
|
|
EuclideanRhythm,
|
|
|
|
|
ProbabilityGate,
|
|
|
|
|
PitchWalker,
|
|
|
|
|
IntervalLock,
|
|
|
|
|
VelocityShaper,
|
|
|
|
|
} from './primitives.js';
|
|
|
|
|
|
|
|
|
|
// ── Defaults ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
const DEFAULT_BPM = 120;
|
|
|
|
|
const DEFAULT_STEP_COUNT = 8;
|
|
|
|
|
const DEFAULT_MASTER_SEED = 42;
|
|
|
|
|
const DEFAULT_SPREAD = 0.6;
|
|
|
|
|
|
|
|
|
|
// ── ShapeSeqEngine ───────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export class ShapeSeqEngine {
|
|
|
|
|
/**
|
|
|
|
|
* @param {{ audioContext: AudioContext, eventBus: import('./event-bus.js').EventBus, c15Bridge: import('../synth/c15-bridge.js').C15Bridge }} opts
|
|
|
|
|
*/
|
|
|
|
|
constructor({ audioContext, eventBus, c15Bridge }) {
|
|
|
|
|
if (!audioContext) throw new TypeError('ShapeSeqEngine requires an audioContext');
|
|
|
|
|
if (!eventBus) throw new TypeError('ShapeSeqEngine requires an eventBus');
|
|
|
|
|
if (!c15Bridge) throw new TypeError('ShapeSeqEngine requires a c15Bridge');
|
|
|
|
|
|
|
|
|
|
/** @private */ this._audioCtx = audioContext;
|
|
|
|
|
/** @private */ this._bus = eventBus;
|
|
|
|
|
/** @private */ this._c15 = c15Bridge;
|
|
|
|
|
|
|
|
|
|
/** @private */ this._sequenceIML = null;
|
|
|
|
|
/** @private */ this._chain = null;
|
|
|
|
|
/** @private */ this._clock = null;
|
|
|
|
|
/** @private */ this._projectionChain = null;
|
|
|
|
|
|
2026-04-07 00:45:55 +02:00
|
|
|
/** @private */ this._outputCount = SEQ_DEFAULT_OUTPUT_COUNT;
|
2026-03-24 01:10:04 +01:00
|
|
|
/** @private */ this._stepCount = DEFAULT_STEP_COUNT;
|
|
|
|
|
/** @private */ this._masterSeed = DEFAULT_MASTER_SEED;
|
|
|
|
|
/** @private */ this._playing = false;
|
|
|
|
|
/** @private */ this._initialized = false;
|
|
|
|
|
|
2026-03-24 10:42:25 +01:00
|
|
|
// Dirty-check: skip re-evaluation when inputs haven't changed
|
|
|
|
|
/** @private */ this._lastInputs = [NaN, NaN];
|
|
|
|
|
|
2026-04-07 00:51:57 +02:00
|
|
|
// Generation counter: bumped on config changes to force re-evaluation
|
|
|
|
|
/** @private */ this._generation = 0;
|
|
|
|
|
/** @private */ this._lastGeneration = -1;
|
|
|
|
|
|
2026-03-24 01:10:04 +01:00
|
|
|
// Track active notes for orphan prevention
|
|
|
|
|
/** @private @type {Set<number>} */
|
|
|
|
|
this._activeNotes = new Set();
|
|
|
|
|
|
|
|
|
|
// Bound handlers for event bus (stored for cleanup)
|
|
|
|
|
/** @private */
|
|
|
|
|
this._onNoteOn = (data) => this._handleNoteOn(data);
|
|
|
|
|
/** @private */
|
|
|
|
|
this._onNoteOff = (data) => this._handleNoteOff(data);
|
2026-04-07 00:37:32 +02:00
|
|
|
/** @private */
|
|
|
|
|
this._onLoopStart = () => this._handleLoopStart();
|
2026-04-07 00:51:57 +02:00
|
|
|
/** @private */
|
|
|
|
|
this._onChainEdit = () => this._bumpGeneration();
|
2026-03-24 01:10:04 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Lifecycle ──────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Initialize all subsystems: create sequence IML, default chain,
|
|
|
|
|
* clock, and projection chain. Must be called before start().
|
|
|
|
|
*/
|
|
|
|
|
async init() {
|
|
|
|
|
// 1. Create the sequence MLP
|
2026-04-07 00:45:55 +02:00
|
|
|
this._sequenceIML = await createSequenceIML({ outputCount: this._outputCount });
|
2026-03-24 01:10:04 +01:00
|
|
|
|
|
|
|
|
// Randomize weights with default spread
|
2026-03-24 10:42:25 +01:00
|
|
|
this._sequenceIML.randomiseWeights(DEFAULT_SPREAD);
|
2026-03-24 01:10:04 +01:00
|
|
|
|
|
|
|
|
// 2. Create the default primitive chain
|
|
|
|
|
this._chain = new Chain();
|
|
|
|
|
this._chain.addPrimitive(new EuclideanRhythm());
|
|
|
|
|
this._chain.addPrimitive(new ProbabilityGate());
|
|
|
|
|
this._chain.addPrimitive(new PitchWalker());
|
|
|
|
|
this._chain.addPrimitive(new IntervalLock());
|
|
|
|
|
this._chain.addPrimitive(new VelocityShaper());
|
|
|
|
|
this._chain.setMasterSeed(this._masterSeed);
|
|
|
|
|
|
|
|
|
|
// 3. Set up the clock
|
|
|
|
|
this._clock = new ClockEngine(this._audioCtx, this._bus);
|
|
|
|
|
this._clock.bpm = DEFAULT_BPM;
|
|
|
|
|
|
2026-04-07 00:41:30 +02:00
|
|
|
// 4. Create default projection
|
|
|
|
|
this._projectionChain = createProjection();
|
2026-03-24 01:10:04 +01:00
|
|
|
|
|
|
|
|
// 5. Subscribe to event bus for C15 bridge integration
|
|
|
|
|
this._bus.on(SEQ.NOTE_ON, this._onNoteOn);
|
|
|
|
|
this._bus.on(SEQ.NOTE_OFF, this._onNoteOff);
|
|
|
|
|
|
2026-04-07 00:51:57 +02:00
|
|
|
// 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
|
2026-04-07 00:37:32 +02:00
|
|
|
this._bus.on(SEQ.LOOP_START, this._onLoopStart);
|
|
|
|
|
|
2026-03-24 01:10:04 +01:00
|
|
|
this._initialized = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Start the clock. Requires init() to have been called.
|
|
|
|
|
*/
|
|
|
|
|
start() {
|
|
|
|
|
if (!this._initialized) {
|
|
|
|
|
throw new Error('ShapeSeqEngine.start() called before init()');
|
|
|
|
|
}
|
|
|
|
|
if (this._playing) return;
|
|
|
|
|
|
|
|
|
|
this._playing = true;
|
|
|
|
|
this._clock.start();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Stop the clock and release all active notes.
|
|
|
|
|
*/
|
|
|
|
|
stop() {
|
|
|
|
|
if (!this._playing) return;
|
|
|
|
|
|
|
|
|
|
this._playing = false;
|
|
|
|
|
this._clock.stop();
|
|
|
|
|
this._releaseAllNotes();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Full cleanup: stop playback, unsubscribe from events, destroy IML.
|
|
|
|
|
*/
|
|
|
|
|
destroy() {
|
|
|
|
|
this.stop();
|
|
|
|
|
|
|
|
|
|
// Unsubscribe from event bus
|
|
|
|
|
this._bus.off(SEQ.NOTE_ON, this._onNoteOn);
|
|
|
|
|
this._bus.off(SEQ.NOTE_OFF, this._onNoteOff);
|
2026-04-07 00:51:57 +02:00
|
|
|
this._bus.off(UI.CHAIN_EDIT, this._onChainEdit);
|
2026-04-07 00:37:32 +02:00
|
|
|
this._bus.off(SEQ.LOOP_START, this._onLoopStart);
|
2026-03-24 01:10:04 +01:00
|
|
|
|
|
|
|
|
// Destroy the sequence IML instance
|
|
|
|
|
if (this._sequenceIML) {
|
|
|
|
|
this._sequenceIML.destroy();
|
|
|
|
|
this._sequenceIML = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this._chain = null;
|
|
|
|
|
this._clock = null;
|
|
|
|
|
this._projectionChain = null;
|
|
|
|
|
this._initialized = false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Configuration ──────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Update the clock tempo.
|
|
|
|
|
* @param {number} bpm
|
|
|
|
|
*/
|
|
|
|
|
setTempo(bpm) {
|
|
|
|
|
if (this._clock) {
|
|
|
|
|
this._clock.setTempo(bpm);
|
|
|
|
|
}
|
2026-04-07 00:51:57 +02:00
|
|
|
this._bumpGeneration();
|
2026-03-24 01:10:04 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Set the number of steps in the generated pattern.
|
|
|
|
|
* @param {number} count
|
|
|
|
|
*/
|
|
|
|
|
setStepCount(count) {
|
|
|
|
|
const c = Math.max(1, count | 0);
|
|
|
|
|
this._stepCount = c;
|
2026-04-07 00:51:57 +02:00
|
|
|
this._bumpGeneration();
|
2026-03-24 01:10:04 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-07 00:41:30 +02:00
|
|
|
* Update the projection config. Accepts partial options merged with current.
|
|
|
|
|
* @param {Object} opts - Partial projection config
|
2026-03-24 01:10:04 +01:00
|
|
|
*/
|
2026-04-07 00:41:30 +02:00
|
|
|
setProjection(opts) {
|
|
|
|
|
const cur = this._projectionChain;
|
|
|
|
|
this._projectionChain = createProjection({
|
|
|
|
|
velocityCurve: opts.velocityCurve ?? cur.velocityCurve,
|
|
|
|
|
gateThreshold: opts.gateThreshold ?? cur.gateThreshold,
|
|
|
|
|
pitchRange: {
|
|
|
|
|
low: opts.pitchRange?.low ?? cur.pitchRange.low,
|
|
|
|
|
high: opts.pitchRange?.high ?? cur.pitchRange.high,
|
|
|
|
|
},
|
|
|
|
|
});
|
2026-04-07 00:51:57 +02:00
|
|
|
this._bumpGeneration();
|
2026-04-07 00:41:30 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get the current projection config.
|
|
|
|
|
* @returns {{ velocityCurve: string, gateThreshold: number, pitchRange: { low: number, high: number } }}
|
|
|
|
|
*/
|
|
|
|
|
getProjection() {
|
|
|
|
|
return this._projectionChain;
|
2026-03-24 01:10:04 +01:00
|
|
|
}
|
|
|
|
|
|
2026-04-07 00:45:55 +02:00
|
|
|
/**
|
|
|
|
|
* Change the MLP output count. Destroys and recreates the sequence IML
|
|
|
|
|
* with a new architecture scaled to the requested count, then randomizes
|
|
|
|
|
* weights. Training examples are lost — callers should snapshot first if
|
|
|
|
|
* needed.
|
|
|
|
|
*
|
|
|
|
|
* @param {number} count - desired output count (e.g. 8, 16, 32)
|
|
|
|
|
* @returns {Promise<void>}
|
|
|
|
|
*/
|
|
|
|
|
async setOutputCount(count) {
|
|
|
|
|
if (!this._initialized) {
|
|
|
|
|
throw new Error('setOutputCount() called before init()');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this._outputCount = count;
|
|
|
|
|
|
|
|
|
|
// Tear down old instance
|
|
|
|
|
if (this._sequenceIML) {
|
|
|
|
|
this._sequenceIML.destroy();
|
|
|
|
|
this._sequenceIML = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create new instance with updated architecture
|
|
|
|
|
this._sequenceIML = await createSequenceIML({ outputCount: count });
|
|
|
|
|
this._sequenceIML.randomiseWeights(DEFAULT_SPREAD);
|
2026-04-07 00:51:57 +02:00
|
|
|
|
|
|
|
|
this._bumpGeneration();
|
2026-04-07 00:45:55 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-24 01:10:04 +01:00
|
|
|
// ── Chain access (for UI binding) ──────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/** @returns {Chain} */
|
|
|
|
|
getChain() { return this._chain; }
|
|
|
|
|
|
|
|
|
|
/** @returns {ClockEngine} */
|
|
|
|
|
getClock() { return this._clock; }
|
|
|
|
|
|
|
|
|
|
/** @returns {WasmIML} */
|
|
|
|
|
getSequenceIML() { return this._sequenceIML; }
|
|
|
|
|
|
|
|
|
|
// ── Input routing ──────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Feed new input values to the sequence MLP and run the full pipeline:
|
|
|
|
|
* MLP inference -> param mapping -> chain evaluation -> projection -> clock scheduling.
|
|
|
|
|
*
|
|
|
|
|
* Call this each frame with the routed input values (e.g., [x, y]).
|
|
|
|
|
*
|
|
|
|
|
* @param {number[]} values - input array (typically [x, y])
|
|
|
|
|
*/
|
|
|
|
|
setSequenceInputs(values) {
|
|
|
|
|
if (!this._initialized || !this._sequenceIML) return;
|
|
|
|
|
|
2026-04-07 00:51:57 +02:00
|
|
|
// Dirty-check: skip re-evaluation if inputs AND config haven't changed
|
2026-03-24 10:42:25 +01:00
|
|
|
const EPS = 1e-5;
|
2026-04-07 00:51:57 +02:00
|
|
|
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) {
|
2026-03-24 10:42:25 +01:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
this._lastInputs[0] = values[0];
|
|
|
|
|
this._lastInputs[1] = values[1];
|
2026-04-07 00:51:57 +02:00
|
|
|
this._lastGeneration = this._generation;
|
2026-03-24 10:42:25 +01:00
|
|
|
|
2026-03-24 01:10:04 +01:00
|
|
|
// 1. Forward inputs to the sequence IML
|
|
|
|
|
this._sequenceIML.setInputs(values);
|
|
|
|
|
|
|
|
|
|
// 2. Run MLP inference
|
|
|
|
|
this._sequenceIML.process();
|
|
|
|
|
|
|
|
|
|
// 3. Get the 16 MLP outputs
|
|
|
|
|
const mlpOutputs = this._sequenceIML.getOutputs();
|
|
|
|
|
|
|
|
|
|
// 4. Map 16 outputs to N primitive params
|
|
|
|
|
const paramCount = this._chain.totalParamCount;
|
|
|
|
|
const mappedParams = map(mlpOutputs, paramCount);
|
|
|
|
|
|
|
|
|
|
// 5. Evaluate the chain to produce a pattern description
|
|
|
|
|
const patternDesc = this._chain.evaluate(mappedParams, this._stepCount, this._masterSeed);
|
|
|
|
|
|
|
|
|
|
// 6. Apply projection transforms
|
|
|
|
|
const projectedPattern = applyProjection(this._projectionChain, patternDesc);
|
|
|
|
|
|
|
|
|
|
// 7. Schedule the pattern on the clock
|
|
|
|
|
this._clock.schedulePattern(projectedPattern);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── ML control ─────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/** @returns {boolean} */
|
|
|
|
|
get isPlaying() {
|
|
|
|
|
return this._playing;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 00:51:57 +02:00
|
|
|
// ── 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++;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 00:37:32 +02:00
|
|
|
// ── 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;
|
|
|
|
|
|
2026-04-07 00:51:57 +02:00
|
|
|
// Bump the generation counter so the next setSequenceInputs() call
|
|
|
|
|
// bypasses the dirty-check and re-runs the full pipeline.
|
|
|
|
|
this._bumpGeneration();
|
|
|
|
|
|
2026-04-07 00:37:32 +02:00
|
|
|
// If we have cached inputs, force an immediate re-evaluation now
|
2026-04-07 00:51:57 +02:00
|
|
|
// (rather than waiting for the next setSequenceInputs() frame).
|
2026-04-07 00:37:32 +02:00
|
|
|
if (!isNaN(this._lastInputs[0]) && !isNaN(this._lastInputs[1])) {
|
2026-04-07 00:51:57 +02:00
|
|
|
this.setSequenceInputs(this._lastInputs);
|
2026-04-07 00:37:32 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-24 01:10:04 +01:00
|
|
|
// ── Bridge integration (private) ───────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Handle seq.noteOn events from the event bus.
|
|
|
|
|
* Converts [0,1] pitch to MIDI note number and forwards to C15.
|
|
|
|
|
*
|
|
|
|
|
* @private
|
|
|
|
|
* @param {Object} data - { pitch, velocity, stepIndex, time, accent, isSubdivision }
|
|
|
|
|
*/
|
|
|
|
|
_handleNoteOn(data) {
|
2026-04-07 00:28:01 +02:00
|
|
|
// Use integer midiNote if set (post-IntervalLock), otherwise fall back
|
|
|
|
|
// to the old pitch*127 encoding for backward compatibility.
|
|
|
|
|
const midiNote = data.midiNote != null ? data.midiNote : (Math.round(data.pitch * 127) | 0);
|
2026-03-24 01:10:04 +01:00
|
|
|
const velocity = data.velocity;
|
|
|
|
|
|
|
|
|
|
// Clamp to valid MIDI range
|
|
|
|
|
const note = midiNote < 0 ? 0 : midiNote > 127 ? 127 : midiNote;
|
|
|
|
|
const vel = velocity < 0 ? 0 : velocity > 1 ? 1 : velocity;
|
|
|
|
|
|
|
|
|
|
this._c15.noteOn(note, vel);
|
|
|
|
|
this._activeNotes.add(note);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Handle seq.noteOff events from the event bus.
|
|
|
|
|
*
|
|
|
|
|
* @private
|
|
|
|
|
* @param {Object} data - { pitch, velocity, stepIndex, time }
|
|
|
|
|
*/
|
|
|
|
|
_handleNoteOff(data) {
|
2026-04-07 00:28:01 +02:00
|
|
|
const midiNote = data.midiNote != null ? data.midiNote : (Math.round(data.pitch * 127) | 0);
|
2026-03-24 01:10:04 +01:00
|
|
|
const note = midiNote < 0 ? 0 : midiNote > 127 ? 127 : midiNote;
|
|
|
|
|
|
|
|
|
|
this._c15.noteOff(note);
|
|
|
|
|
this._activeNotes.delete(note);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Release all currently active notes to avoid orphaned noteOns.
|
|
|
|
|
* @private
|
|
|
|
|
*/
|
|
|
|
|
_releaseAllNotes() {
|
|
|
|
|
for (const note of this._activeNotes) {
|
|
|
|
|
this._c15.noteOff(note);
|
|
|
|
|
}
|
|
|
|
|
this._activeNotes.clear();
|
|
|
|
|
}
|
|
|
|
|
}
|