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.
This commit is contained in:
parent
7cbda2304b
commit
70d807cdce
3 changed files with 629 additions and 0 deletions
347
playground/js/eoc/eoc-chain.js
Normal file
347
playground/js/eoc/eoc-chain.js
Normal file
|
|
@ -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<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,
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
275
playground/js/eoc/eoc-module.js
Normal file
275
playground/js/eoc/eoc-module.js
Normal file
|
|
@ -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<void>}
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
7
playground/js/eoc/index.js
Normal file
7
playground/js/eoc/index.js
Normal file
|
|
@ -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';
|
||||||
Loading…
Reference in a new issue