memlnaut-nisps/playground/js/eoc/eoc-chain.js
w1n5t0n 70d807cdce feat(playground): EOC module base class + chain container (meml-vx2)
Adds playground/js/eoc/ with EOCModule base class, EOCChain container,
and barrel index. Implements bypass GainNode graph, connect/rewire
algorithm, ordered module management, flat NISPS param routing, and
eoc:change CustomEvent dispatch on structural changes.
2026-04-03 17:38:22 +01:00

347 lines
11 KiB
JavaScript

// 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<void>}
*/
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,
}
}));
}
}