memlnaut-nisps/playground/js/shapeseq/sequencer.js

347 lines
11 KiB
JavaScript
Raw Normal View History

/**
* 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
*/
import { createSequenceIML, SEQ_N_OUTPUTS } from './seq-iml.js';
import { Chain } from './chain.js';
import { ClockEngine } from './clock.js';
import { map } from './param-map.js';
import { createProjectionChain, applyProjection, PRESETS } from './projection.js';
import { SEQ } from './event-bus.js';
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;
/** @private */ this._stepCount = DEFAULT_STEP_COUNT;
/** @private */ this._masterSeed = DEFAULT_MASTER_SEED;
/** @private */ this._playing = false;
/** @private */ this._initialized = false;
// Dirty-check: skip re-evaluation when inputs haven't changed
/** @private */ this._lastInputs = [NaN, NaN];
// 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);
/** @private */
this._onLoopStart = () => this._handleLoopStart();
}
// ── 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
this._sequenceIML = await createSequenceIML();
// Randomize weights with default spread
this._sequenceIML.randomiseWeights(DEFAULT_SPREAD);
// 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;
// 4. Create default projection chain (expressive preset)
const result = createProjectionChain(PRESETS.expressive);
if (!result.valid) {
throw new Error('Default projection chain invalid: ' + result.error);
}
this._projectionChain = result;
// 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);
// 6. Subscribe to loop start for stateful primitive re-evaluation
this._bus.on(SEQ.LOOP_START, this._onLoopStart);
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);
this._bus.off(SEQ.LOOP_START, this._onLoopStart);
// 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);
}
}
/**
* Set the number of steps in the generated pattern.
* @param {number} count
*/
setStepCount(count) {
const c = Math.max(1, count | 0);
this._stepCount = c;
}
/**
* Set the projection preset by name.
* @param {'expressive'|'percussive'|'fullRange'} presetName
*/
setProjectionPreset(presetName) {
const preset = PRESETS[presetName];
if (!preset) {
throw new Error('Unknown projection preset: ' + presetName);
}
const result = createProjectionChain(preset);
if (!result.valid) {
throw new Error('Projection chain invalid: ' + result.error);
}
this._projectionChain = result;
}
// ── 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;
// Dirty-check: skip re-evaluation if inputs haven't changed
const EPS = 1e-5;
if (Math.abs(values[0] - this._lastInputs[0]) < EPS &&
Math.abs(values[1] - this._lastInputs[1]) < EPS) {
return;
}
this._lastInputs[0] = values[0];
this._lastInputs[1] = values[1];
// 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;
}
// ── 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;
// If we have cached inputs, force an immediate re-evaluation now
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);
}
}
// ── 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) {
// 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);
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) {
const midiNote = data.midiNote != null ? data.midiNote : (Math.round(data.pitch * 127) | 0);
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();
}
}