From 25caf726d8768b50aca242f0894d1ff509200012 Mon Sep 17 00:00:00 2001 From: w1n5t0n Date: Fri, 3 Apr 2026 17:49:14 +0100 Subject: [PATCH] feat(playground): EOC chain wired into audio graph, bypass mode manual sliders (meml-xp3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wire EOCChain into audio graph after c15.start() (both start-btn and quick-play paths); guarded by _eocInited flag so init happens once per AudioContext lifetime - getOutputNode() on C15Adapter now returns limiter (last node before destination) so EOC inserts correctly between limiter and destination - Expose limiterNode getter on C15Bridge - Add getCurrentParamValue(i) to EOCModule base class, backed by _paramValues array that setParam() writes to - Add _buildParamSliders() to EOCChainUI: collapsible ▶ params toggle per module row, one range slider per paramMeta entry, calls module.setParam() on input; labelled "manual" when nispsMode=bypass - eoc:change listener in a-app.js logs paramCount (hook point for future modes) - saveState()/loadState() persist eocModules (id, enabled, params) and eocNispsMode; restore re-adds modules via moduleFactory and re-applies values - CSS: eoc-params-section, eoc-params-toggle, eoc-param-row/label/slider; eoc-module-row gains flex-wrap to accommodate params section below controls --- playground/js/eoc/eoc-module.js | 21 ++++++++++- playground/js/synth/c15-adapter.js | 10 +++-- playground/js/synth/c15-bridge.js | 8 ++++ playground/js/ui/eoc-chain-ui.js | 59 ++++++++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 5 deletions(-) diff --git a/playground/js/eoc/eoc-module.js b/playground/js/eoc/eoc-module.js index 5d661cc..2ed71d4 100644 --- a/playground/js/eoc/eoc-module.js +++ b/playground/js/eoc/eoc-module.js @@ -33,6 +33,8 @@ export class EOCModule { this._bypassOut = null; // GainNode: output exit point this._bypassDry = null; // GainNode: direct input→output path when bypassed this._initialized = false; + // Stored normalized param values — set via setParam(), read by getCurrentParamValue() + this._paramValues = []; } // --------------------------------------------------------------------------- @@ -184,7 +186,24 @@ export class EOCModule { * @param {number} normalizedValue — [0, 1] */ setParam(index, normalizedValue) { // eslint-disable-line no-unused-vars - // default no-op — override in subclass + // Store the value so getCurrentParamValue() can read it back + this._paramValues[index] = normalizedValue; + // default no-op — override in subclass for actual audio effect + } + + /** + * Get the last normalized value set for a parameter. + * Returns the param's init value (from paramMeta) if never explicitly set. + * + * @param {number} index — 0-based index into paramMeta + * @returns {number} normalized value [0, 1] + */ + getCurrentParamValue(index) { + if (this._paramValues[index] !== undefined) { + return this._paramValues[index]; + } + const meta = this.paramMeta[index]; + return meta ? (meta.init ?? 0) : 0; } // --------------------------------------------------------------------------- diff --git a/playground/js/synth/c15-adapter.js b/playground/js/synth/c15-adapter.js index 94f8fc0..051abbe 100644 --- a/playground/js/synth/c15-adapter.js +++ b/playground/js/synth/c15-adapter.js @@ -144,13 +144,15 @@ export class C15Adapter extends SynthEngine { // --- Audio graph --- /** - * Return the master gain node. Connect this to a compressor or destination. - * Only available after init() completes. + * Return the limiter node — the last AudioNode before destination. + * This is the correct insertion point for post-processing (e.g. EOCChain). + * Falls back to masterGain before start() is called. + * Only useful after init() completes. * - * @returns {GainNode} + * @returns {AudioNode} */ getOutputNode() { - return this._bridge.masterGain; + return this._bridge.limiterNode ?? this._bridge.masterGain; } // --- C15-specific passthrough (for MIDIInput and volume controls) --- diff --git a/playground/js/synth/c15-bridge.js b/playground/js/synth/c15-bridge.js index f94d87d..c0e440a 100644 --- a/playground/js/synth/c15-bridge.js +++ b/playground/js/synth/c15-bridge.js @@ -80,6 +80,14 @@ export class C15Bridge { /** SharedArrayBuffer for the ring buffer — available after start() */ get sharedBuffer() { return this._sab; } + /** + * The limiter node — the last AudioNode before destination. + * Use this as the input to any post-processing chain (e.g. EOCChain). + * Only available after start(). + * @returns {DynamicsCompressorNode|null} + */ + get limiterNode() { return this.limiter ?? null; } + _status(msg) { console.log('[C15]', msg); this._onStatusChange?.(msg); diff --git a/playground/js/ui/eoc-chain-ui.js b/playground/js/ui/eoc-chain-ui.js index 9077f5d..bb9ad15 100644 --- a/playground/js/ui/eoc-chain-ui.js +++ b/playground/js/ui/eoc-chain-ui.js @@ -224,6 +224,12 @@ export const EOCChainUI = { }); row.appendChild(removeBtn); + // --- Manual param sliders (shown in bypass mode, or when module has params) --- + if (mod.paramCount > 0) { + const paramsSection = this._buildParamSliders(mod); + row.appendChild(paramsSection); + } + // --- HTML5 Drag-and-drop --- row.addEventListener('dragstart', (e) => { this._dragId = mod.id; @@ -283,6 +289,59 @@ export const EOCChainUI = { return row; }, + // Per-module param sliders (collapsible, shown only when paramCount > 0) + _buildParamSliders(mod) { + const section = document.createElement('div'); + section.className = 'eoc-params-section'; + + // Toggle button (chevron + label) + const toggleBtn = document.createElement('button'); + toggleBtn.className = 'eoc-params-toggle'; + const isManual = this._chain.nispsMode === 'bypass'; + toggleBtn.textContent = isManual ? '▶ params (manual)' : '▶ params'; + toggleBtn.title = 'Expand to show parameter sliders'; + + const sliderList = document.createElement('div'); + sliderList.className = 'eoc-params-list eoc-params-collapsed'; + + toggleBtn.addEventListener('click', () => { + const collapsed = sliderList.classList.toggle('eoc-params-collapsed'); + toggleBtn.textContent = collapsed + ? (isManual ? '▶ params (manual)' : '▶ params') + : (isManual ? '▼ params (manual)' : '▼ params'); + }); + + // One slider per param + mod.paramMeta.forEach((meta, i) => { + const row = document.createElement('div'); + row.className = 'eoc-param-row'; + + const label = document.createElement('label'); + label.className = 'eoc-param-label'; + label.textContent = meta.name ?? meta.id ?? `Param ${i}`; + + const slider = document.createElement('input'); + slider.type = 'range'; + slider.min = '0'; + slider.max = '1'; + slider.step = '0.001'; + slider.value = String(mod.getCurrentParamValue(i)); + slider.className = 'eoc-param-slider'; + + slider.addEventListener('input', () => { + mod.setParam(i, parseFloat(slider.value)); + }); + + row.appendChild(label); + row.appendChild(slider); + sliderList.appendChild(row); + }); + + section.appendChild(toggleBtn); + section.appendChild(sliderList); + return section; + }, + // Add-module bar _buildAddBar() { const bar = document.createElement('div');