memlnaut-nisps/playground/js/shapeseq/sequencer.js
w1n5t0n 9b08918b1e feat(shapeseq): freeze-as-algorithm/pattern + delta controller
Two freeze modes for capturing and preserving musical state:

- Algorithm freeze: snapshot all primitive params + seeds. Individual
  params can be selectively unfrozen for ML-driven exploration via
  DeltaController (applies MLP deltas to frozen base values with
  per-param boundary enforcement).

- Pattern freeze: snapshot the realized note pattern. The sequencer
  bypasses the primitive chain entirely and loops the frozen pattern.

Extracts _runPipeline() from setSequenceInputs() for reuse.
2026-04-06 23:55:23 +01:00

499 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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_DEFAULT_OUTPUT_COUNT } from './seq-iml.js';
import { Chain } from './chain.js';
import { ClockEngine } from './clock.js';
import { map } from './param-map.js';
import { createProjection, applyProjection } from './projection.js';
import { SEQ, UI } from './event-bus.js';
import {
EuclideanRhythm,
ProbabilityGate,
PitchWalker,
IntervalLock,
VelocityShaper,
} from './primitives.js';
import { FreezeManager } from './freeze.js';
import { DeltaController } from './delta.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._outputCount = SEQ_DEFAULT_OUTPUT_COUNT;
/** @private */ this._stepCount = DEFAULT_STEP_COUNT;
// Freeze-as-algorithm manager
/** @private */ this._freezeManager = new FreezeManager();
// Last evaluated params — needed for freeze snapshot
/** @private @type {Float32Array|null} */ this._lastEvaluatedParams = null;
// Last projected pattern — needed for freeze-as-pattern snapshot
/** @private @type {Object|null} */ this._lastPattern = null;
/** @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];
// 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();
// 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();
/** @private */
this._onChainEdit = () => this._bumpGeneration();
}
// ── 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({ outputCount: this._outputCount });
// 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
this._projectionChain = createProjection();
// 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 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;
}
/**
* 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(UI.CHAIN_EDIT, this._onChainEdit);
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);
}
this._bumpGeneration();
}
/**
* Set the number of steps in the generated pattern.
* @param {number} count
*/
setStepCount(count) {
const c = Math.max(1, count | 0);
this._stepCount = c;
this._bumpGeneration();
}
/**
* Update the projection config. Accepts partial options merged with current.
* @param {Object} opts - Partial projection config
*/
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,
},
});
this._bumpGeneration();
}
/**
* Get the current projection config.
* @returns {{ velocityCurve: string, gateThreshold: number, pitchRange: { low: number, high: number } }}
*/
getProjection() {
return this._projectionChain;
}
/**
* 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);
this._bumpGeneration();
}
// ── Chain access (for UI binding) ──────────────────────────────────
/** @returns {Chain} */
getChain() { return this._chain; }
/** @returns {ClockEngine} */
getClock() { return this._clock; }
/** @returns {WasmIML} */
getSequenceIML() { return this._sequenceIML; }
/** @returns {FreezeManager} */
get freezeManager() { return this._freezeManager; }
// ── Freeze-as-algorithm ────────────────────────────────────────────
/**
* Freeze the current state.
*
* In **algorithm** mode (default): capture param values, seeds, and states.
* All params start frozen; use toggleParamFreeze() to re-expose
* individual params for ML-driven exploration.
*
* In **pattern** mode: capture the last projected pattern. The sequencer
* bypasses the primitive chain entirely and loops the frozen pattern.
*
* @param {'algorithm'|'pattern'} [mode='algorithm']
*/
freeze(mode = 'algorithm') {
if (mode === 'pattern') {
// Need the last evaluated pattern
this._freezeManager.freeze(null, null, null, 'pattern', this._lastPattern);
} else {
if (!this._chain) return;
const currentParams = this._lastEvaluatedParams || new Float32Array(this._chain.totalParamCount);
this._freezeManager.freeze(this._chain, currentParams, this._masterSeed, 'algorithm');
}
}
/**
* Unfreeze: clear captured state and return to normal ML control.
*/
unfreeze() {
this._freezeManager.unfreeze();
this._bumpGeneration();
}
/**
* Toggle a single param between frozen and live.
* @param {number} flatIndex
*/
toggleParamFreeze(flatIndex) {
this._freezeManager.toggleParam(flatIndex);
this._bumpGeneration();
}
// ── 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;
// Freeze-as-pattern: skip entire pipeline, just keep looping frozen pattern
if (this._freezeManager.isFrozen && this._freezeManager.freezeMode === 'pattern') {
return;
}
// Dirty-check: skip re-evaluation if inputs AND config haven't changed
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;
// 1. Forward inputs to the sequence IML
this._sequenceIML.setInputs(values);
// 2. Run MLP inference
this._sequenceIML.process();
// 3. Get the MLP outputs
const mlpOutputs = this._sequenceIML.getOutputs();
// 47. Run downstream pipeline
this._runPipeline(mlpOutputs);
}
/**
* Shared downstream pipeline: param mapping -> chain -> projection -> clock.
*
* @private
* @param {Float32Array} mlpOutputs - raw MLP outputs (sequence slice)
*/
_runPipeline(mlpOutputs) {
// Map N outputs to M primitive params
const paramCount = this._chain.totalParamCount;
let mappedParams = map(mlpOutputs, paramCount);
// Apply freeze: frozen params use captured values, live params get delta control
if (this._freezeManager.isFrozen) {
const schemas = this._chain.getParamSchemas();
mappedParams = DeltaController.computeEffective(
this._freezeManager.getFrozenParams(),
this._freezeManager.getLiveFlags(),
mappedParams,
schemas,
0.3 // deltaScale — could be configurable later
);
}
// Track last evaluated params for freeze snapshot
this._lastEvaluatedParams = mappedParams;
// Evaluate the chain to produce a pattern description
const patternDesc = this._chain.evaluate(mappedParams, this._stepCount, this._masterSeed);
// Apply projection transforms
const projectedPattern = applyProjection(this._projectionChain, patternDesc);
// Track last pattern for freeze-as-pattern snapshot
this._lastPattern = projectedPattern;
// Schedule the pattern on the clock
this._clock.schedulePattern(projectedPattern);
}
// ── ML control ─────────────────────────────────────────────────────
/** @returns {boolean} */
get isPlaying() {
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) ──────────────────────────────────
/**
* 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._freezeManager.shouldSuppressReEval()) 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])) {
this.setSequenceInputs(this._lastInputs);
}
}
// ── 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();
}
}