diff --git a/playground/js/event-bus.js b/playground/js/event-bus.js new file mode 100644 index 0000000..abd7821 --- /dev/null +++ b/playground/js/event-bus.js @@ -0,0 +1,117 @@ +// Namespaced Event Bus +// Lightweight pub/sub with seq.*/ml.*/ui.* namespaces and wildcard support. + +// ── Event name constants ──────────────────────────────────────────── + +export const SEQ = Object.freeze({ + STEP: 'seq.step', + NOTE_ON: 'seq.noteOn', + NOTE_OFF: 'seq.noteOff', + PARAM_CHANGE: 'seq.paramChange', + LOOP_START: 'seq.loopStart', +}); + +export const ML = Object.freeze({ + TRAINED: 'ml.trained', + FROZEN: 'ml.frozen', + UNFROZEN: 'ml.unfrozen', + DELTA_UPDATE: 'ml.deltaUpdate', +}); + +export const UI = Object.freeze({ + PARAM_SELECT: 'ui.paramSelect', + CHAIN_EDIT: 'ui.chainEdit', + PRESET_LOAD: 'ui.presetLoad', + FREEZE_TOGGLE: 'ui.freezeToggle', +}); + +// ── Bus implementation ────────────────────────────────────────────── + +export class EventBus { + /** @param {AudioContext} [audioCtx] - optional, used for seq.* timestamps */ + constructor(audioCtx) { + this._listeners = new Map(); // event -> Set + this._wildcards = new Map(); // namespace prefix (e.g. "seq") -> Set + this._audioCtx = audioCtx ?? null; + } + + /** Provide or replace the AudioContext used for seq.* timestamps. */ + setAudioContext(ctx) { + this._audioCtx = ctx; + } + + /** + * Subscribe to an event or a wildcard namespace. + * on('seq.step', cb) — exact match + * on('seq.*', cb) — all events in the seq namespace + */ + on(event, callback) { + if (event.endsWith('.*')) { + const ns = event.slice(0, -2); // "seq.*" -> "seq" + if (!this._wildcards.has(ns)) this._wildcards.set(ns, new Set()); + this._wildcards.get(ns).add(callback); + } else { + if (!this._listeners.has(event)) this._listeners.set(event, new Set()); + this._listeners.get(event).add(callback); + } + } + + /** Unsubscribe. Mirrors the on() signature. */ + off(event, callback) { + if (event.endsWith('.*')) { + const ns = event.slice(0, -2); + const set = this._wildcards.get(ns); + if (set) { + set.delete(callback); + if (set.size === 0) this._wildcards.delete(ns); + } + } else { + const set = this._listeners.get(event); + if (set) { + set.delete(callback); + if (set.size === 0) this._listeners.delete(event); + } + } + } + + /** + * Emit an event. + * A `timestamp` field is added automatically: + * - seq.* events use AudioContext.currentTime (seconds) + * - all others use performance.now() (milliseconds) + */ + emit(event, data = {}) { + const ns = event.split('.')[0]; + const stamped = Object.assign({ timestamp: this._stamp(ns) }, data); + + // Exact listeners + const exact = this._listeners.get(event); + if (exact) { + for (const cb of exact) cb(stamped, event); + } + + // Wildcard listeners for this namespace + const wild = this._wildcards.get(ns); + if (wild) { + for (const cb of wild) cb(stamped, event); + } + } + + // ── private ── + + _stamp(ns) { + if (ns === 'seq' && this._audioCtx) return this._audioCtx.currentTime; + return performance.now(); + } +} + +// ── Singleton convenience ─────────────────────────────────────────── + +let _default = null; + +/** Return (and lazily create) the shared default bus. */ +export function getDefaultBus(audioCtx) { + if (!_default) _default = new EventBus(audioCtx); + else if (audioCtx) _default.setAudioContext(audioCtx); + return _default; +} diff --git a/playground/js/shapeseq/event-bus.js b/playground/js/shapeseq/event-bus.js index eaf22ab..4a8d585 100644 --- a/playground/js/shapeseq/event-bus.js +++ b/playground/js/shapeseq/event-bus.js @@ -1,117 +1,2 @@ -// ShapeSeq Namespaced Event Bus -// Lightweight pub/sub with seq.*/ml.*/ui.* namespaces and wildcard support. - -// ── Event name constants ──────────────────────────────────────────── - -export const SEQ = Object.freeze({ - STEP: 'seq.step', - NOTE_ON: 'seq.noteOn', - NOTE_OFF: 'seq.noteOff', - PARAM_CHANGE: 'seq.paramChange', - LOOP_START: 'seq.loopStart', -}); - -export const ML = Object.freeze({ - TRAINED: 'ml.trained', - FROZEN: 'ml.frozen', - UNFROZEN: 'ml.unfrozen', - DELTA_UPDATE: 'ml.deltaUpdate', -}); - -export const UI = Object.freeze({ - PARAM_SELECT: 'ui.paramSelect', - CHAIN_EDIT: 'ui.chainEdit', - PRESET_LOAD: 'ui.presetLoad', - FREEZE_TOGGLE: 'ui.freezeToggle', -}); - -// ── Bus implementation ────────────────────────────────────────────── - -export class EventBus { - /** @param {AudioContext} [audioCtx] - optional, used for seq.* timestamps */ - constructor(audioCtx) { - this._listeners = new Map(); // event -> Set - this._wildcards = new Map(); // namespace prefix (e.g. "seq") -> Set - this._audioCtx = audioCtx ?? null; - } - - /** Provide or replace the AudioContext used for seq.* timestamps. */ - setAudioContext(ctx) { - this._audioCtx = ctx; - } - - /** - * Subscribe to an event or a wildcard namespace. - * on('seq.step', cb) — exact match - * on('seq.*', cb) — all events in the seq namespace - */ - on(event, callback) { - if (event.endsWith('.*')) { - const ns = event.slice(0, -2); // "seq.*" -> "seq" - if (!this._wildcards.has(ns)) this._wildcards.set(ns, new Set()); - this._wildcards.get(ns).add(callback); - } else { - if (!this._listeners.has(event)) this._listeners.set(event, new Set()); - this._listeners.get(event).add(callback); - } - } - - /** Unsubscribe. Mirrors the on() signature. */ - off(event, callback) { - if (event.endsWith('.*')) { - const ns = event.slice(0, -2); - const set = this._wildcards.get(ns); - if (set) { - set.delete(callback); - if (set.size === 0) this._wildcards.delete(ns); - } - } else { - const set = this._listeners.get(event); - if (set) { - set.delete(callback); - if (set.size === 0) this._listeners.delete(event); - } - } - } - - /** - * Emit an event. - * A `timestamp` field is added automatically: - * - seq.* events use AudioContext.currentTime (seconds) - * - all others use performance.now() (milliseconds) - */ - emit(event, data = {}) { - const ns = event.split('.')[0]; - const stamped = Object.assign({ timestamp: this._stamp(ns) }, data); - - // Exact listeners - const exact = this._listeners.get(event); - if (exact) { - for (const cb of exact) cb(stamped, event); - } - - // Wildcard listeners for this namespace - const wild = this._wildcards.get(ns); - if (wild) { - for (const cb of wild) cb(stamped, event); - } - } - - // ── private ── - - _stamp(ns) { - if (ns === 'seq' && this._audioCtx) return this._audioCtx.currentTime; - return performance.now(); - } -} - -// ── Singleton convenience ─────────────────────────────────────────── - -let _default = null; - -/** Return (and lazily create) the shared default bus. */ -export function getDefaultBus(audioCtx) { - if (!_default) _default = new EventBus(audioCtx); - else if (audioCtx) _default.setAudioContext(audioCtx); - return _default; -} +// playground/js/shapeseq/event-bus.js — re-exports from shared module +export { EventBus, SEQ, ML, UI, getDefaultBus } from '../event-bus.js';