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.
275 lines
9.1 KiB
JavaScript
275 lines
9.1 KiB
JavaScript
// 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);
|
|
}
|
|
}
|