feat(shapeseq): implement Layer 3 — chain builder UI and sequencer core
chain-ui.js (1002 lines): Vertical stack chain builder (pedalboard- style) with expandable primitive cards, category-colored borders, drag-to-reorder via pointer events, add primitive palette modal, NISPS live-param pulsing indicator, generator combine mode toggle. All styles inline matching dark glass theme. sequencer.js (304 lines): ShapeSeqEngine orchestrator wiring the full pipeline: input routing → sequence MLP (16 outputs) → param mapping → chain evaluation → projection → clock scheduling → C15 bridge. Default chain: Euclidean→ProbGate→PitchWalker→IntervalLock→ VelocityShaper. Tracks active notes to prevent orphaned noteOns.
This commit is contained in:
parent
efcf6d2c06
commit
027510a7e4
2 changed files with 1306 additions and 0 deletions
1002
playground/js/shapeseq/chain-ui.js
Normal file
1002
playground/js/shapeseq/chain-ui.js
Normal file
File diff suppressed because it is too large
Load diff
304
playground/js/shapeseq/sequencer.js
Normal file
304
playground/js/shapeseq/sequencer.js
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue