feat(playground): EOC chain wired into audio graph, bypass mode manual sliders (meml-xp3)

- 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
This commit is contained in:
w1n5t0n 2026-04-03 17:49:14 +01:00
parent f1b6e1aba0
commit 25caf726d8
4 changed files with 93 additions and 5 deletions

View file

@ -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;
}
// ---------------------------------------------------------------------------

View file

@ -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) ---

View file

@ -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);

View file

@ -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');