diff --git a/playground/a-immersive.html b/playground/a-immersive.html index 32f6cb0..79ad01f 100644 --- a/playground/a-immersive.html +++ b/playground/a-immersive.html @@ -150,6 +150,12 @@ Engine + + + + + FX + @@ -307,6 +313,17 @@ + + + + + Effects Chain + × + + + + + diff --git a/playground/css/a-immersive.css b/playground/css/a-immersive.css index d41e320..52db2a7 100644 --- a/playground/css/a-immersive.css +++ b/playground/css/a-immersive.css @@ -2021,3 +2021,201 @@ html, body { min-width: 20px; text-align: center; } + +/* ---- EOC Chain UI ---- */ + +.eoc-nisps-bar { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 10px; + flex-wrap: wrap; +} + +.eoc-section-label { + font-size: 9px; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.5px; + min-width: 36px; +} + +.eoc-nisps-toggle { + flex: 1; +} + +.eoc-module-list { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 10px; +} + +.eoc-empty-hint { + font-size: 10px; + color: var(--text-dim); + text-align: center; + padding: 10px 0; + opacity: 0.6; +} + +.eoc-module-row { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 6px; + border-radius: 6px; + border: 1px solid rgba(255, 255, 255, 0.06); + background: rgba(255, 255, 255, 0.03); + cursor: default; + transition: background 0.15s; +} + +.eoc-module-row:hover { + background: rgba(255, 255, 255, 0.06); + border-color: rgba(255, 255, 255, 0.1); +} + +.eoc-module-row.dragging { + opacity: 0.4; + cursor: grabbing; +} + +.eoc-drag-handle { + cursor: grab; + touch-action: none; + color: var(--text-dim); + font-size: 14px; + line-height: 1; + padding: 0 2px; + flex-shrink: 0; + user-select: none; +} + +.eoc-drag-handle:active { + cursor: grabbing; +} + +.eoc-module-name { + flex: 1; + font-size: 11px; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.eoc-param-badge { + font-size: 9px; + color: var(--text-dim); + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + padding: 1px 6px; + white-space: nowrap; + flex-shrink: 0; +} + +/* Bypass pill toggle */ +.eoc-bypass-toggle { + position: relative; + display: inline-block; + width: 30px; + height: 16px; + flex-shrink: 0; + cursor: pointer; +} + +.eoc-bypass-toggle input { + opacity: 0; + width: 0; + height: 0; + position: absolute; +} + +.eoc-bypass-slider { + position: absolute; + inset: 0; + border-radius: 8px; + background: rgba(255, 255, 255, 0.12); + border: 1px solid rgba(255, 255, 255, 0.1); + transition: background 0.2s, border-color 0.2s; +} + +.eoc-bypass-slider::after { + content: ''; + position: absolute; + left: 2px; + top: 50%; + transform: translateY(-50%); + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--text-dim); + transition: transform 0.2s, background 0.2s; +} + +.eoc-bypass-toggle input:checked + .eoc-bypass-slider { + background: rgba(255, 106, 0, 0.25); + border-color: rgba(255, 106, 0, 0.4); +} + +.eoc-bypass-toggle input:checked + .eoc-bypass-slider::after { + transform: translateY(-50%) translateX(14px); + background: var(--accent); +} + +.eoc-remove-btn { + background: none; + border: 1px solid transparent; + color: var(--text-dim); + font-size: 15px; + line-height: 1; + width: 18px; + height: 18px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + cursor: pointer; + padding: 0; + flex-shrink: 0; + transition: all 0.15s; +} + +.eoc-remove-btn:hover { + color: var(--danger); + border-color: rgba(255, 68, 102, 0.3); + background: rgba(255, 68, 102, 0.08); +} + +/* Drop indicator line between rows during drag */ +.eoc-drop-indicator { + height: 2px; + border-radius: 1px; + background: var(--accent); + margin: 2px 0; + pointer-events: none; + opacity: 0.8; +} + +/* Add module bar */ +.eoc-add-bar { + display: flex; + flex-wrap: wrap; + gap: 5px; + padding-top: 8px; + border-top: 1px solid rgba(255, 255, 255, 0.05); +} + +.eoc-add-hint { + font-size: 10px; + color: var(--text-dim); + opacity: 0.5; + padding: 4px 0; +} + +.eoc-add-btn { + font-size: 10px; + padding: 3px 8px; +} diff --git a/playground/js/a-app.js b/playground/js/a-app.js index 1bef9d9..405c212 100644 --- a/playground/js/a-app.js +++ b/playground/js/a-app.js @@ -15,6 +15,8 @@ import { GamepadInput } from './ui/gamepad.js'; import { HandTracker } from './ui/hand-tracker.js'; import { createDevPanel } from './ui/dev-panel.js'; import { SYNTH_PRESETS, PRESET_TIERS } from './synth/presets.js'; +import { EOCChain } from './eoc/index.js'; +import { EOCChainUI, moduleFactory } from './ui/eoc-chain-ui.js'; // ---- Constants ---- const N_JOY_INPUTS = 2; @@ -898,6 +900,13 @@ async function init() { // Auto-save every 10 seconds setInterval(saveState, 10000); + // EOC Effects Chain — initialise chain and wire drawer UI + const eocChain = new EOCChain(); + const eocDrawerBody = document.getElementById('eoc-drawer-body'); + if (eocDrawerBody) { + EOCChainUI.init(eocChain, eocDrawerBody); + } + // Debug probe — exposed on window when ?debug=1 is in the URL. // Used by Playwright e2e tests. Zero footprint in production. if (new URLSearchParams(window.location.search).has('debug')) { @@ -946,6 +955,7 @@ async function init() { evalLoss: () => iml.evalLoss(), inferBatch: (points) => iml.inferBatch(points), getLayerStats:() => iml.getLayerStats(), + eocChain, }; } diff --git a/playground/js/ui/eoc-chain-ui.js b/playground/js/ui/eoc-chain-ui.js new file mode 100644 index 0000000..9077f5d --- /dev/null +++ b/playground/js/ui/eoc-chain-ui.js @@ -0,0 +1,320 @@ +// 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'; + +// --------------------------------------------------------------------------- +// 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) { + 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); + + // --- 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; + }, + + // 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; + }, +};