memlnaut-nisps/playground/js/ui/eoc-chain-ui.js
w1n5t0n 5ac67df699 Merge EOC Delay + Saturation + Master Bus modules (meml-2sl, meml-2zm)
Resolved conflicts: merged all 6 module imports in eoc-chain-ui.js and index.js.
All moduleFactory cases now return real Faust WASM implementations — no stubs remain.
2026-04-03 18:22:00 +01:00

396 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 { EQModule } from '../eoc/modules/eq-module.js';
import { CompressorModule } from '../eoc/modules/compressor-module.js';
import { ReverbModule } from '../eoc/modules/reverb-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) {
// Real Faust WASM implementations
switch (id) {
case 'eq': return new EQModule();
case 'compressor': return new CompressorModule();
case 'reverb': return new ReverbModule();
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;
},
};