diff --git a/playground/js/eoc/eoc-chain.js b/playground/js/eoc/eoc-chain.js new file mode 100644 index 0000000..38c8136 --- /dev/null +++ b/playground/js/eoc/eoc-chain.js @@ -0,0 +1,347 @@ +// eoc-chain.js — End-of-Chain container. +// +// Manages an ordered list of EOCModule instances, wires them into the Web Audio +// graph, and exposes a flat NISPS parameter surface across all enabled modules. +// +// Canonical module order (used as insertion default, not enforced): +// Saturation → EQ → Compressor → Reverb → Delay → Master Bus +// +// Usage: +// const chain = new EOCChain(); +// await chain.init(audioCtx); +// chain.addModule(new SaturationModule()); +// chain.addModule(new ReverbModule()); +// chain.connect(synthOutputNode, audioCtx.destination); +// +// The chain dispatches 'eoc:change' CustomEvents on window whenever the +// structure changes (add/remove/reorder/bypass/nispsMode). + +import { EOCModule } from './eoc-module.js'; + +// Default slot order — used to sort modules by type when no explicit position +// is given. Modules not in this list are appended after the last known slot. +const DEFAULT_ORDER = ['saturation', 'eq', 'compressor', 'reverb', 'delay', 'master']; + +export class EOCChain { + constructor() { + /** @type {EOCModule[]} */ + this._modules = []; + this._audioCtx = null; + this._inputNode = null; // stored after first connect() call + this._outputNode = null; // stored after first connect() call + this._initialized = false; + + // NISPS integration mode. + // 'bypass' — EOC params not used by NISPS at all (default) + // 'shared' — EOC params appended to synth param pool (future) + // 'linked' — EOC params driven by a separate, linked NISPS instance (future) + // 'independent'— EOC has its own independent NISPS instance (future) + this._nispsMode = 'bypass'; + } + + // --------------------------------------------------------------------------- + // Module management + // --------------------------------------------------------------------------- + + /** + * Insert a module at a given position. If no position is given, the module + * is appended in DEFAULT_ORDER slot sequence. + * + * @param {EOCModule} module + * @param {number} [position] — 0-based insertion index (optional) + */ + addModule(module, position) { + if (!(module instanceof EOCModule)) { + throw new TypeError('EOCChain.addModule: argument must be an EOCModule instance'); + } + if (this._modules.some(m => m.id === module.id)) { + throw new Error(`EOCChain.addModule: module '${module.id}' is already in the chain`); + } + + if (position !== undefined) { + this._modules.splice(Math.max(0, Math.min(position, this._modules.length)), 0, module); + } else { + // Insert at canonical slot position + const targetSlot = DEFAULT_ORDER.indexOf(module.id); + if (targetSlot === -1) { + // Unknown module type — append at end + this._modules.push(module); + } else { + // Find the first existing module whose canonical slot is after this one + const insertAt = this._modules.findIndex(m => { + const slot = DEFAULT_ORDER.indexOf(m.id); + return slot === -1 || slot > targetSlot; + }); + if (insertAt === -1) { + this._modules.push(module); + } else { + this._modules.splice(insertAt, 0, module); + } + } + } + + // If the chain is already initialised, init the new module immediately + if (this._initialized && this._audioCtx) { + module.init(this._audioCtx).then(() => { + this._rewire(); + this._dispatchChange('module-added'); + }); + } else { + this._dispatchChange('module-added'); + } + } + + /** + * Remove a module from the chain by id. + * Disposes the module and rewires. + * + * @param {string} id + */ + removeModule(id) { + const idx = this._modules.findIndex(m => m.id === id); + if (idx === -1) { + console.warn(`EOCChain.removeModule: no module with id '${id}'`); + return; + } + const [removed] = this._modules.splice(idx, 1); + removed.dispose(); + this._rewire(); + this._dispatchChange('module-removed'); + } + + /** + * Move a module to a new position. + * + * @param {string} id + * @param {number} newPosition — 0-based target index (after removal) + */ + moveModule(id, newPosition) { + const idx = this._modules.findIndex(m => m.id === id); + if (idx === -1) { + console.warn(`EOCChain.moveModule: no module with id '${id}'`); + return; + } + const [module] = this._modules.splice(idx, 1); + const clampedPos = Math.max(0, Math.min(newPosition, this._modules.length)); + this._modules.splice(clampedPos, 0, module); + this._rewire(); + this._dispatchChange('module-moved'); + } + + /** + * Retrieve a module by id. + * + * @param {string} id + * @returns {EOCModule|undefined} + */ + getModule(id) { + return this._modules.find(m => m.id === id); + } + + /** + * Ordered array of all modules in the chain (enabled and bypassed). + * @returns {EOCModule[]} + */ + get modules() { + return [...this._modules]; + } + + // --------------------------------------------------------------------------- + // Chain lifecycle + // --------------------------------------------------------------------------- + + /** + * Initialize all currently registered modules, then store the AudioContext. + * Safe to call before or after addModule() calls. + * + * @param {AudioContext} audioCtx + * @returns {Promise} + */ + async init(audioCtx) { + this._audioCtx = audioCtx; + await Promise.all(this._modules.map(m => m.init(audioCtx))); + this._initialized = true; + } + + /** + * Dispose all modules and disconnect the chain. + */ + dispose() { + this._disconnect(); + this._modules.forEach(m => m.dispose()); + this._modules = []; + this._audioCtx = null; + this._inputNode = null; + this._outputNode = null; + this._initialized = false; + } + + // --------------------------------------------------------------------------- + // Audio graph wiring + // --------------------------------------------------------------------------- + + /** + * Wire the full chain into the audio graph: + * inputNode → [enabled modules in order] → outputNode + * + * If no modules are enabled, inputNode connects directly to outputNode (all-bypass). + * + * Stores inputNode/outputNode for _rewire() calls triggered by later + * structural changes (add/remove/reorder/bypass toggle). + * + * @param {AudioNode} inputNode — upstream node (e.g. synth output GainNode) + * @param {AudioNode} outputNode — downstream node (e.g. AudioContext.destination) + */ + connect(inputNode, outputNode) { + this._inputNode = inputNode; + this._outputNode = outputNode; + this._rewire(); + } + + // --------------------------------------------------------------------------- + // NISPS integration + // --------------------------------------------------------------------------- + + /** + * Current NISPS integration mode. + * @returns {'bypass'|'shared'|'linked'|'independent'} + */ + get nispsMode() { + return this._nispsMode; + } + + /** + * Set NISPS integration mode. + * Only stores the value and dispatches an event; actual wiring is handled + * by the tasks that implement each mode (meml-xp3 and later). + * + * @param {'bypass'|'shared'|'linked'|'independent'} mode + */ + set nispsMode(mode) { + const valid = ['bypass', 'shared', 'linked', 'independent']; + if (!valid.includes(mode)) { + throw new Error(`EOCChain.nispsMode: invalid mode '${mode}'. Must be one of: ${valid.join(', ')}`); + } + this._nispsMode = mode; + this._dispatchChange('nispsMode-changed'); + } + + /** + * Total parameter count across all enabled modules. + * @returns {number} + */ + get paramCount() { + return this._enabledModules().reduce((sum, m) => sum + m.paramCount, 0); + } + + /** + * Flat parameter metadata array across all enabled modules, in chain order. + * Each entry carries an additional `_moduleId` field for routing. + * + * @returns {Array<{id:string, name:string, min:number, max:number, init:number, curve:number, group:string, _moduleId:string}>} + */ + get paramMeta() { + const result = []; + for (const module of this._enabledModules()) { + for (const meta of module.paramMeta) { + result.push({ ...meta, _moduleId: module.id }); + } + } + return result; + } + + /** + * Route a normalized [0,1] value to the correct module by global index. + * + * @param {number} globalIndex — flat index across all enabled modules' params + * @param {number} value — [0, 1] + */ + setParam(globalIndex, value) { + let offset = 0; + for (const module of this._enabledModules()) { + if (globalIndex < offset + module.paramCount) { + module.setParam(globalIndex - offset, value); + return; + } + offset += module.paramCount; + } + console.warn(`EOCChain.setParam: globalIndex ${globalIndex} out of range (paramCount=${this.paramCount})`); + } + + // --------------------------------------------------------------------------- + // Internal helpers + // --------------------------------------------------------------------------- + + /** + * Return only the currently enabled modules, in chain order. + * @returns {EOCModule[]} + */ + _enabledModules() { + return this._modules.filter(m => m.enabled); + } + + /** + * Disconnect all inter-module and endpoint connections, then reconnect + * in the current order. Safe to call at any time after connect() has been + * called at least once. + */ + _rewire() { + if (!this._inputNode || !this._outputNode) return; + + // Tear down: disconnect the input node and each module's output node. + // We only disconnect from the nodes we own in the chain to avoid clobbering + // any other connections the caller may have set up on inputNode/outputNode. + try { this._inputNode.disconnect(); } catch (_) { /* not yet connected */ } + for (const m of this._modules) { + try { m.getOutputNode().disconnect(); } catch (_) { /* not yet connected */ } + } + + const active = this._enabledModules(); + + if (active.length === 0) { + // All modules disabled (or chain is empty): straight wire + this._inputNode.connect(this._outputNode); + return; + } + + // inputNode → first module + this._inputNode.connect(active[0].getInputNode()); + + // module[i] → module[i+1] + for (let i = 0; i < active.length - 1; i++) { + active[i].getOutputNode().connect(active[i + 1].getInputNode()); + } + + // last module → outputNode + active[active.length - 1].getOutputNode().connect(this._outputNode); + } + + /** + * Disconnect everything the chain owns. + * Called during dispose(). + */ + _disconnect() { + if (!this._inputNode) return; + try { this._inputNode.disconnect(); } catch (_) { /* ok */ } + for (const m of this._modules) { + try { m.getOutputNode().disconnect(); } catch (_) { /* ok */ } + } + } + + /** + * Dispatch an 'eoc:change' CustomEvent on window. + * + * @param {string} reason — short description of what changed + */ + _dispatchChange(reason) { + window.dispatchEvent(new CustomEvent('eoc:change', { + detail: { + reason, + chain: this, + modules: this.modules, + paramCount: this.paramCount, + nispsMode: this.nispsMode, + } + })); + } +} diff --git a/playground/js/eoc/eoc-module.js b/playground/js/eoc/eoc-module.js new file mode 100644 index 0000000..5d661cc --- /dev/null +++ b/playground/js/eoc/eoc-module.js @@ -0,0 +1,275 @@ +// eoc-module.js — Base class for a single End-of-Chain effect module. +// +// All EOC effect modules extend this class. The contract mirrors the SynthEngine +// interface pattern (engine-interface.js) so both layers feel consistent. +// +// Subclasses MUST override: +// get id() — stable machine ID +// get displayName() — human label +// get paramMeta() — [{id, name, min, max, init, curve, group}] +// async init(audioCtx) — create AudioNodes, call super.init() first +// +// Subclasses MAY override: +// setParam(index, normalizedValue) +// dispose() +// +// Usage: +// class ReverbModule extends EOCModule { +// get id() { return 'reverb'; } +// get displayName() { return 'Reverb'; } +// get paramMeta() { return [...]; } +// async init(audioCtx) { +// await super.init(audioCtx); +// // create effect nodes, wire between this._bypassIn → effect → this._bypassOut +// } +// } + +export class EOCModule { + constructor() { + this._enabled = true; + this._audioCtx = null; + // Bypass graph nodes — allocated in init(), used by _applyBypass() + this._bypassIn = null; // GainNode: input entry point + this._bypassOut = null; // GainNode: output exit point + this._bypassDry = null; // GainNode: direct input→output path when bypassed + this._initialized = false; + } + + // --------------------------------------------------------------------------- + // Identity (override in subclass) + // --------------------------------------------------------------------------- + + /** + * Stable machine ID. + * One of: 'eq' | 'compressor' | 'reverb' | 'delay' | 'saturation' | 'master' + * @returns {string} + */ + get id() { + throw new Error(`${this.constructor.name}: id not implemented`); + } + + /** + * Human-readable label shown in the UI. + * @returns {string} + */ + get displayName() { + throw new Error(`${this.constructor.name}: displayName not implemented`); + } + + // --------------------------------------------------------------------------- + // Enable / bypass + // --------------------------------------------------------------------------- + + /** + * Whether this module is active in the signal chain. + * When false, audio passes directly from input to output (true bypass). + * @returns {boolean} + */ + get enabled() { + return this._enabled; + } + + /** + * Toggle bypass. Reconnects the internal audio graph immediately. + * @param {boolean} v + */ + set enabled(v) { + const changed = this._enabled !== !!v; + this._enabled = !!v; + if (changed && this._initialized) { + this._applyBypass(); + } + } + + // --------------------------------------------------------------------------- + // Parameter schema (override in subclass) + // --------------------------------------------------------------------------- + + /** + * Number of continuous parameters this module exposes. + * Derived automatically from paramMeta — override paramMeta, not this. + * @returns {number} + */ + get paramCount() { + return this.paramMeta.length; + } + + /** + * Array of parameter descriptors, one per controllable parameter. + * + * Each entry must have: + * id {string} — stable machine ID (used for presets / NISPS routing) + * name {string} — short display name + * min {number} — raw minimum value (units depend on param) + * max {number} — raw maximum value + * init {number} — default normalized value [0, 1] + * curve {number} — power-curve bias: 0.5 = linear, <0.5 = log, >0.5 = exp + * group {string} — section label (for group drawer / colour coding) + * + * @returns {Array<{id:string, name:string, min:number, max:number, init:number, curve:number, group:string}>} + */ + get paramMeta() { + throw new Error(`${this.constructor.name}: paramMeta not implemented`); + } + + // --------------------------------------------------------------------------- + // Lifecycle + // --------------------------------------------------------------------------- + + /** + * Allocate AudioNodes and connect the internal graph. + * Subclasses MUST call super.init(audioCtx) first, then wire their effect + * nodes between this._bypassIn and this._bypassOut. + * + * @param {AudioContext} audioCtx + * @returns {Promise} + */ + async init(audioCtx) { + if (this._initialized) return; + this._audioCtx = audioCtx; + + // Two GainNodes bracket the effect processing path. + // A third (dry) provides a direct signal route for bypass. + this._bypassIn = audioCtx.createGain(); + this._bypassOut = audioCtx.createGain(); + this._bypassDry = audioCtx.createGain(); + + // Wire the dry path (always present; gain toggled by _applyBypass) + this._bypassIn.connect(this._bypassDry); + this._bypassDry.connect(this._bypassOut); + + this._initialized = true; + // Note: subclass connects its effect nodes, then calls _applyBypass() + // via _finishInit() to set initial gain values correctly. + } + + /** + * Call from the end of a subclass init() once effect nodes are wired, to + * apply the correct initial bypass state. + * + * Subclass pattern: + * async init(audioCtx) { + * await super.init(audioCtx); + * // ... wire effect nodes ... + * this._finishInit(); + * } + */ + _finishInit() { + this._applyBypass(); + } + + /** + * Disconnect all AudioNodes and release resources. + * Override in subclasses to clean up effect-specific nodes. + */ + dispose() { + if (!this._initialized) return; + try { + this._bypassIn.disconnect(); + this._bypassOut.disconnect(); + this._bypassDry.disconnect(); + } catch (_) { /* already disconnected */ } + this._initialized = false; + this._audioCtx = null; + } + + // --------------------------------------------------------------------------- + // Real-time control + // --------------------------------------------------------------------------- + + /** + * Set a single parameter by index. + * + * @param {number} index — 0-based index into paramMeta + * @param {number} normalizedValue — [0, 1] + */ + setParam(index, normalizedValue) { // eslint-disable-line no-unused-vars + // default no-op — override in subclass + } + + // --------------------------------------------------------------------------- + // Audio graph + // --------------------------------------------------------------------------- + + /** + * The AudioNode that upstream sources should connect to. + * Everything flows into this node. + * @returns {AudioNode} + */ + getInputNode() { + return this._bypassIn; + } + + /** + * The AudioNode to connect downstream (to next module or destination). + * Everything exits through this node. + * @returns {AudioNode} + */ + getOutputNode() { + return this._bypassOut; + } + + // --------------------------------------------------------------------------- + // Internal helpers + // --------------------------------------------------------------------------- + + /** + * Apply bypass state to the internal audio graph. + * + * When enabled: + * - dry path (bypassIn → bypassOut) gain = 0 + * - effect path is live (subclass manages its own nodes) + * + * When bypassed: + * - dry path gain = 1 (signal passes straight through) + * - effect processing gains are muted (subclass handles via _onBypassChange) + * + * Both _bypassIn and _bypassOut remain in the graph at all times so the + * EOCChain's wiring never needs to change when bypass is toggled. + */ + _applyBypass() { + if (!this._bypassDry) return; + const t = this._audioCtx.currentTime; + if (this._enabled) { + // Effect active: cut the dry path + this._bypassDry.gain.setTargetAtTime(0, t, 0.005); + } else { + // Bypassed: open dry path + this._bypassDry.gain.setTargetAtTime(1, t, 0.005); + } + // Let subclass mute/unmute its own effect nodes + this._onBypassChange(this._enabled); + } + + /** + * Called by _applyBypass() after the dry-path gain is updated. + * Subclasses should override to mute/unmute effect processing nodes. + * + * @param {boolean} enabled — true = effect active, false = bypassed + */ + _onBypassChange(enabled) { // eslint-disable-line no-unused-vars + // default no-op — override in subclass if effect has its own gain to manage + } + + // --------------------------------------------------------------------------- + // Utility: normalize a [0,1] value to the raw param range + // --------------------------------------------------------------------------- + + /** + * Convert a normalized [0,1] value to the raw range defined by paramMeta[index]. + * Applies the curve bias: value^(1/curve) gives log-like feel for small curve, + * value^curve gives exp-like feel for large curve. + * + * @param {number} index + * @param {number} normalizedValue [0, 1] + * @returns {number} raw value in [min, max] + */ + _denormalize(index, normalizedValue) { + const meta = this.paramMeta[index]; + if (!meta) return 0; + const { min, max, curve = 0.5 } = meta; + // Apply power curve: curve=0.5 is linear, <0.5 pulls toward min, >0.5 toward max + const shaped = Math.pow(Math.max(0, Math.min(1, normalizedValue)), curve === 0.5 ? 1 : 1 / (curve * 2)); + return min + shaped * (max - min); + } +} diff --git a/playground/js/eoc/index.js b/playground/js/eoc/index.js new file mode 100644 index 0000000..79811c0 --- /dev/null +++ b/playground/js/eoc/index.js @@ -0,0 +1,7 @@ +// eoc/index.js — barrel re-export for the End-of-Chain effects system. +// +// Import from here to get the full EOC API: +// import { EOCModule, EOCChain } from './eoc/index.js'; + +export { EOCModule } from './eoc-module.js'; +export { EOCChain } from './eoc-chain.js';