memlnaut-nisps/playground/js/ui/eoc-chain-ui.js
w1n5t0n a9e2b5a8f9 feat(playground/eoc): Delay + Saturation + Master Bus modules (meml-2sl, meml-2zm)
Faust DSP sources, compiled WASM + JSON descriptors, AudioWorklet processors,
and EOCModule subclasses for all three effects.
2026-04-03 18:19:32 +01:00

391 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// eoc-chain-ui.js — Drawer UI for the End-of-Chain effects rack.
//
// Usage:
// import { EOCChainUI, moduleFactory } from './ui/eoc-chain-ui.js';
// EOCChainUI.init(chain, document.getElementById('eoc-drawer-body'));
//
// The drawer renders:
// 1. NISPS mode selector (Bypass / Shared / Linked / Independent)
// 2. Ordered, draggable list of active modules (bypass toggle, remove, param count)
// 3. Add-module bar (one button per available but absent module)
//
// Reacts to 'eoc:change' events on window to stay in sync with external mutations.
import { EOCModule } from '../eoc/eoc-module.js';
import { DelayModule } from '../eoc/modules/delay-module.js';
import { SaturationModule } from '../eoc/modules/saturation-module.js';
import { MasterModule } from '../eoc/modules/master-module.js';
// ---------------------------------------------------------------------------
// Stub module factory
// ---------------------------------------------------------------------------
const STUB_DEFS = {
saturation: { displayName: 'Saturation', paramMeta: [] },
eq: { displayName: 'EQ', paramMeta: [] },
compressor: { displayName: 'Compressor', paramMeta: [] },
reverb: { displayName: 'Reverb', paramMeta: [] },
delay: { displayName: 'Delay', paramMeta: [] },
master: { displayName: 'Master Bus', paramMeta: [] },
};
// Canonical slot order (matches EOCChain's DEFAULT_ORDER)
const MODULE_ORDER = ['saturation', 'eq', 'compressor', 'reverb', 'delay', 'master'];
/**
* Build a minimal EOCModule stub for the given id.
* Exported so a-app.js can replace it with real implementations later.
*
* @param {string} id — one of saturation | eq | compressor | reverb | delay | master
* @returns {EOCModule}
*/
export function moduleFactory(id) {
// Use real implementations where available; fall back to stubs for modules
// whose implementations are being built in parallel tasks (eq, compressor, reverb).
switch (id) {
case 'delay': return new DelayModule();
case 'saturation': return new SaturationModule();
case 'master': return new MasterModule();
default: break;
}
const def = STUB_DEFS[id];
if (!def) throw new Error(`moduleFactory: unknown module id '${id}'`);
// Build stub class inline, scoped to the specific id/def
class StubModule extends EOCModule {
get id() { return id; }
get displayName() { return def.displayName; }
get paramMeta() { return def.paramMeta; }
async init(audioCtx) {
// Minimal super call to satisfy the base class contract without
// requiring a real AudioContext in stub/test scenarios
if (audioCtx && typeof audioCtx.createGain === 'function') {
await super.init(audioCtx);
this._finishInit();
} else {
this._initialized = true;
}
}
}
return new StubModule();
}
// ---------------------------------------------------------------------------
// EOCChainUI
// ---------------------------------------------------------------------------
export const EOCChainUI = {
/**
* @type {import('../eoc/eoc-chain.js').EOCChain|null}
*/
_chain: null,
/** @type {HTMLElement|null} */
_container: null,
/** Dragged module id during HTML5 DnD */
_dragId: null,
/**
* Initialise the UI.
*
* @param {import('../eoc/eoc-chain.js').EOCChain} chain
* @param {HTMLElement} containerEl
*/
init(chain, containerEl) {
this._chain = chain;
this._container = containerEl;
// Listen for external chain changes
window.addEventListener('eoc:change', () => this._render());
this._render();
},
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
_render() {
if (!this._chain || !this._container) return;
const c = this._container;
c.innerHTML = '';
c.appendChild(this._buildNispsBar());
c.appendChild(this._buildModuleList());
c.appendChild(this._buildAddBar());
},
// NISPS mode selector bar
_buildNispsBar() {
const modes = [
{ id: 'bypass', label: 'Bypass' },
{ id: 'shared', label: 'Shared' },
{ id: 'linked', label: 'Linked' },
{ id: 'independent', label: 'Indep' },
];
const bar = document.createElement('div');
bar.className = 'eoc-nisps-bar';
const label = document.createElement('span');
label.className = 'eoc-section-label';
label.textContent = 'NISPS';
bar.appendChild(label);
const toggle = document.createElement('div');
toggle.className = 'pill-toggle pill-toggle-sm eoc-nisps-toggle';
modes.forEach(({ id, label: lbl }) => {
const btn = document.createElement('button');
btn.className = 'pill-opt' + (this._chain.nispsMode === id ? ' active' : '');
btn.textContent = lbl;
btn.addEventListener('click', () => {
this._chain.nispsMode = id;
// re-render is triggered by eoc:change event from chain
});
toggle.appendChild(btn);
});
bar.appendChild(toggle);
return bar;
},
// Ordered list of module rows
_buildModuleList() {
const list = document.createElement('div');
list.className = 'eoc-module-list';
const modules = this._chain.modules;
if (modules.length === 0) {
const empty = document.createElement('div');
empty.className = 'eoc-empty-hint';
empty.textContent = 'No modules — add one below';
list.appendChild(empty);
return list;
}
modules.forEach((mod, idx) => {
const row = this._buildModuleRow(mod, idx, modules.length, list);
list.appendChild(row);
});
return list;
},
_buildModuleRow(mod, idx, totalCount, listEl) {
const row = document.createElement('div');
row.className = 'eoc-module-row';
row.dataset.id = mod.id;
row.draggable = true;
// --- Drag handle ---
const handle = document.createElement('span');
handle.className = 'eoc-drag-handle';
handle.title = 'Drag to reorder';
handle.textContent = '⠿';
row.appendChild(handle);
// --- Module name ---
const name = document.createElement('span');
name.className = 'eoc-module-name';
name.textContent = mod.displayName;
row.appendChild(name);
// --- Param count badge ---
const badge = document.createElement('span');
badge.className = 'eoc-param-badge';
badge.textContent = mod.paramCount === 1
? '1 param'
: `${mod.paramCount} params`;
row.appendChild(badge);
// --- Bypass toggle ---
const toggleLabel = document.createElement('label');
toggleLabel.className = 'eoc-bypass-toggle';
toggleLabel.title = mod.enabled ? 'Active — click to bypass' : 'Bypassed — click to enable';
const toggleInput = document.createElement('input');
toggleInput.type = 'checkbox';
toggleInput.checked = mod.enabled;
toggleInput.addEventListener('change', () => {
mod.enabled = toggleInput.checked;
// _applyBypass wires audio graph internally; dispatch change so UI refreshes
window.dispatchEvent(new CustomEvent('eoc:change', {
detail: { reason: 'bypass-toggled', chain: this._chain }
}));
});
const toggleSlider = document.createElement('span');
toggleSlider.className = 'eoc-bypass-slider';
toggleLabel.appendChild(toggleInput);
toggleLabel.appendChild(toggleSlider);
row.appendChild(toggleLabel);
// --- Remove button ---
const removeBtn = document.createElement('button');
removeBtn.className = 'eoc-remove-btn';
removeBtn.title = `Remove ${mod.displayName}`;
removeBtn.textContent = '×';
removeBtn.addEventListener('click', () => {
this._chain.removeModule(mod.id);
});
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;
row.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', mod.id);
});
row.addEventListener('dragend', () => {
this._dragId = null;
row.classList.remove('dragging');
// Clean up all drop indicators
listEl.querySelectorAll('.eoc-drop-indicator').forEach(el => el.remove());
});
row.addEventListener('dragover', (e) => {
if (!this._dragId || this._dragId === mod.id) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
// Show drop-indicator above or below based on cursor position
const rect = row.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
const insertBefore = e.clientY < midY;
// Remove all existing indicators then place one
listEl.querySelectorAll('.eoc-drop-indicator').forEach(el => el.remove());
const indicator = document.createElement('div');
indicator.className = 'eoc-drop-indicator';
if (insertBefore) {
listEl.insertBefore(indicator, row);
} else {
row.insertAdjacentElement('afterend', indicator);
}
});
row.addEventListener('drop', (e) => {
if (!this._dragId || this._dragId === mod.id) return;
e.preventDefault();
// Determine new position
const rect = row.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
const insertBefore = e.clientY < midY;
const allRows = [...listEl.querySelectorAll('.eoc-module-row')];
const targetIdx = allRows.findIndex(r => r.dataset.id === mod.id);
// After moving the dragged element out, the target index needs adjustment
const dragIdx = allRows.findIndex(r => r.dataset.id === this._dragId);
let newPos = insertBefore ? targetIdx : targetIdx + 1;
if (dragIdx < targetIdx) newPos -= 1; // account for removal shifting indices
this._chain.moveModule(this._dragId, Math.max(0, newPos));
// eoc:change will re-render
});
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');
bar.className = 'eoc-add-bar';
const activeIds = new Set(this._chain.modules.map(m => m.id));
const available = MODULE_ORDER.filter(id => !activeIds.has(id));
if (available.length === 0) {
const hint = document.createElement('span');
hint.className = 'eoc-add-hint';
hint.textContent = 'All modules added';
bar.appendChild(hint);
return bar;
}
available.forEach(id => {
const def = STUB_DEFS[id];
if (!def) return;
const btn = document.createElement('button');
btn.className = 'action-btn eoc-add-btn';
btn.textContent = `+ ${def.displayName}`;
btn.title = `Add ${def.displayName} to chain`;
btn.addEventListener('click', () => {
this._chain.addModule(moduleFactory(id));
// eoc:change re-renders
});
bar.appendChild(btn);
});
return bar;
},
};