304 lines
9.7 KiB
JavaScript
304 lines
9.7 KiB
JavaScript
/**
|
|
* 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;
|
|
|
|
// 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);
|
|
}
|
|
|
|
// ── 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.drawWeights(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);
|
|
|
|
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);
|
|
|
|
// 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;
|
|
|
|
// 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;
|
|
}
|
|
|
|
// ── 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) {
|
|
// pitch comes from the projection layer; after RangeMap it's already
|
|
// in MIDI note range (e.g., 48-84). Round to nearest integer.
|
|
const midiNote = Math.round(data.pitch) | 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 = Math.round(data.pitch) | 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();
|
|
}
|
|
}
|