From f1b6e1aba001c3a876cf13262bebb9faebd19632 Mon Sep 17 00:00:00 2001 From: w1n5t0n Date: Fri, 3 Apr 2026 17:49:07 +0100 Subject: [PATCH] feat(playground): engine switcher UI component (meml-phg) Adds EngineSwitcher module with clickable engine cards (active highlight, coming-soon greyed state, loading bar animation, confirm-on-switch when training data exists). Wired into the Engine drawer; C15 active, Additive/FM marked coming-soon with toast. Dock title reflects active engine; engineId persisted to/restored from localStorage. --- playground/css/a-immersive.css | 200 ++++++++++++++++++++++++++++ playground/js/a-app.js | 158 +++++++++++++++++++++- playground/js/ui/engine-switcher.js | 134 +++++++++++++++++++ 3 files changed, 489 insertions(+), 3 deletions(-) create mode 100644 playground/js/ui/engine-switcher.js diff --git a/playground/css/a-immersive.css b/playground/css/a-immersive.css index 52db2a7..433ad29 100644 --- a/playground/css/a-immersive.css +++ b/playground/css/a-immersive.css @@ -2062,6 +2062,7 @@ html, body { .eoc-module-row { display: flex; align-items: center; + flex-wrap: wrap; gap: 6px; padding: 5px 6px; border-radius: 6px; @@ -2219,3 +2220,202 @@ html, body { font-size: 10px; padding: 3px 8px; } + +/* Per-module param sliders */ +.eoc-params-section { + width: 100%; + margin-top: 4px; +} + +.eoc-params-toggle { + background: none; + border: none; + color: var(--text-dim, rgba(255, 255, 255, 0.4)); + font-size: 9px; + cursor: pointer; + padding: 2px 0; + text-align: left; + width: 100%; +} + +.eoc-params-toggle:hover { + color: var(--text-muted, rgba(255, 255, 255, 0.65)); +} + +.eoc-params-list { + display: flex; + flex-direction: column; + gap: 4px; + padding: 4px 0 2px 0; +} + +.eoc-params-list.eoc-params-collapsed { + display: none; +} + +.eoc-param-row { + display: flex; + align-items: center; + gap: 6px; +} + +.eoc-param-label { + font-size: 9px; + color: var(--text-dim, rgba(255, 255, 255, 0.4)); + min-width: 60px; + flex-shrink: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.eoc-param-slider { + flex: 1; + height: 3px; + accent-color: var(--accent, #ff6a00); + cursor: pointer; +} + +/* ---- Engine Switcher ---- */ +.engine-switcher-section { + margin-bottom: 4px; +} + +.engine-switcher-heading { + font-size: 9px; + font-weight: 700; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 6px; + padding: 0 2px; +} + +.engine-card-grid { + display: grid; + grid-template-columns: 1fr; + gap: 5px; +} + +.engine-card { + padding: 7px 9px; + border-radius: var(--radius-sm); + border: 1px solid var(--glass-border); + background: rgba(255, 255, 255, 0.03); + cursor: pointer; + transition: border-color 0.15s, background 0.15s, opacity 0.15s; + position: relative; + overflow: hidden; +} + +.engine-card:hover:not(.coming-soon):not(.loading) { + background: rgba(255, 255, 255, 0.06); + border-color: rgba(255, 255, 255, 0.14); +} + +.engine-card:active:not(.coming-soon) { + transform: scale(0.98); +} + +.engine-card.active { + border-color: rgba(255, 106, 0, 0.45); + background: rgba(255, 106, 0, 0.07); +} + +.engine-card.loading { + opacity: 0.5; + cursor: wait; + pointer-events: none; +} + +.engine-card.loading::after { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; + height: 2px; + background: linear-gradient(90deg, transparent, var(--accent), transparent); + animation: engine-loading-bar 1.2s linear infinite; +} + +@keyframes engine-loading-bar { + from { transform: translateX(-100%); } + to { transform: translateX(300%); } +} + +.engine-card.coming-soon { + opacity: 0.38; + cursor: default; +} + +.engine-card-name-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + margin-bottom: 3px; +} + +.engine-card-name { + font-size: 10px; + font-weight: 700; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.engine-card.active .engine-card-name { + color: var(--accent); +} + +.engine-card-params { + font-size: 8px; + padding: 1px 5px; + border-radius: 4px; + background: rgba(255, 255, 255, 0.07); + color: var(--text-dim); + white-space: nowrap; + flex-shrink: 0; +} + +.engine-card.active .engine-card-params { + background: rgba(255, 106, 0, 0.15); + color: var(--accent); +} + +.engine-card-desc { + font-size: 9px; + color: var(--text-dim); + line-height: 1.4; + display: block; +} + +.engine-switcher-divider { + height: 1px; + background: var(--glass-border); + margin: 8px 0 6px; +} + +/* ---- Toast notification ---- */ +.nisps-toast { + position: fixed; + bottom: 28px; + left: 50%; + transform: translateX(-50%) translateY(8px); + padding: 5px 14px; + border-radius: 10px; + background: rgba(20, 20, 20, 0.92); + border: 1px solid var(--glass-border); + color: var(--text); + font-size: 11px; + white-space: nowrap; + pointer-events: none; + z-index: 60; + opacity: 0; + transition: opacity 0.18s, transform 0.18s; +} + +.nisps-toast.visible { + opacity: 1; + transform: translateX(-50%) translateY(0); +} diff --git a/playground/js/a-app.js b/playground/js/a-app.js index 405c212..c536a61 100644 --- a/playground/js/a-app.js +++ b/playground/js/a-app.js @@ -17,6 +17,7 @@ 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'; +import { EngineSwitcher } from './ui/engine-switcher.js'; // ---- Constants ---- const N_JOY_INPUTS = 2; @@ -88,6 +89,10 @@ let midiInput = null; let outputMode = 'visual'; +// EOC Effects Chain — module-scoped so audio-start and setActiveEngine can reference it +let eocChain = null; +let _eocInited = false; // guard: init once per AudioContext lifetime + // ---- MIDI CC state ---- let midiOutput = null; let midiCCMap = loadCCMap(); @@ -730,6 +735,33 @@ function outputCountForMode(mode) { * Joystick IML uses warm-start weight transfer to preserve learned mappings. * Training examples are always cleared (dataset is JS-side and output-count-specific). */ +// ---- EOC audio graph wiring ---- +/** + * Wire the EOC chain into the audio graph after the C15 AudioContext is created. + * Safe to call multiple times — guarded by _eocInited flag. + * + * Call this immediately after any c15.start() / activeEngine.init() that brings + * the AudioContext into existence. + */ +async function _startEocChain() { + if (_eocInited || !eocChain) return; + const audioCtx = activeEngine._bridge?.audioContext; + if (!audioCtx) { + console.warn('[EOC] _startEocChain: no AudioContext yet — skipping'); + return; + } + + await eocChain.init(audioCtx); + _eocInited = true; + + // Disconnect the limiter from destination (it auto-connects there in C15Bridge.start()) + const outputNode = activeEngine.getOutputNode(); + try { outputNode.disconnect(); } catch (_) { /* already disconnected */ } + + eocChain.connect(outputNode, audioCtx.destination); + console.log('[EOC] chain wired into audio graph'); +} + // ---- Engine switching (stub for meml-phg UI) ---- /** * Hot-swap the active synth engine. @@ -744,7 +776,20 @@ async function setActiveEngine(engine) { arpeggiator.setEngine(engine); const btn = document.getElementById('synth-mode-btn'); if (btn) btn.textContent = engine.displayName; + const engineDockBtn = document.querySelector('[data-drawer="params"]'); + if (engineDockBtn) engineDockBtn.title = `Engine: ${engine.displayName}`; + EngineSwitcher.setActive(engine.id); + EngineSwitcher.setLoading(engine.id, false); await resizeMLP(engine.paramCount); + + // Rewire EOC chain to the new engine's output node + if (eocChain && _eocInited) { + const outputNode = engine.getOutputNode(); + if (outputNode) { + try { outputNode.disconnect(); } catch (_) { /* not yet connected */ } + eocChain.connect(outputNode, engine._bridge.audioContext.destination); + } + } } async function resizeMLP(newOutputCount) { @@ -853,6 +898,45 @@ async function init() { // Build raw param sliders buildEngineParams(); + // Engine switcher — prepended above the tuning sliders in the Engine drawer + const ENGINES = [ + { + id: 'shaper-feedback', + displayName: 'C15 Shaper-Feedback', + paramCount: 126, + description: 'Phase-aligned waveshapers with feedback mixer. Complex harmonic textures.', + }, + { + id: 'additive', + displayName: 'Additive', + paramCount: 48, + description: 'Spectral envelope additive synthesis. 64 harmonics shaped by ML.', + comingSoon: true, + }, + { + id: 'fm', + displayName: 'FM Matrix', + paramCount: 55, + description: '4-operator FM with continuous routing matrix. Algorithm emerges from exploration.', + comingSoon: true, + }, + ]; + const engineSwitcherEl = document.getElementById('engine-params'); + if (engineSwitcherEl) { + EngineSwitcher.init(engineSwitcherEl, ENGINES, async (engineId) => { + if (engineId === activeEngine.id) return; + if (engineId !== 'shaper-feedback') { + showToast(`${engineId} engine coming soon`); + return; + } + }, { + hasTrainingData: () => (imlJoy?.exampleCount ?? 0) > 0 || (imlHand?.exampleCount ?? 0) > 0, + }); + EngineSwitcher.setActive(activeEngine.id); + const engineDockBtn = document.querySelector('[data-drawer="params"]'); + if (engineDockBtn) engineDockBtn.title = `Engine: ${activeEngine.displayName}`; + } + // Wire events wireJoystick(); wireDock(); @@ -901,12 +985,17 @@ async function init() { setInterval(saveState, 10000); // EOC Effects Chain — initialise chain and wire drawer UI - const eocChain = new EOCChain(); + eocChain = new EOCChain(); const eocDrawerBody = document.getElementById('eoc-drawer-body'); if (eocDrawerBody) { EOCChainUI.init(eocChain, eocDrawerBody); } + // Log EOC structural changes; future NISPS modes will act on this event + window.addEventListener('eoc:change', () => { + console.log('[EOC] chain changed, paramCount:', eocChain.paramCount); + }); + // 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')) { @@ -2175,8 +2264,14 @@ function drawLossPlot() { // ---- Raw param sliders ---- // ---- Engine Parameters (NISPS tuning) ---- function buildEngineParams() { - const container = $engineParams; - if (!container) return; + if (!$engineParams) return; + // Use a sub-container so the EngineSwitcher section (prepended) is not clobbered. + let container = $engineParams.querySelector('.engine-tuning'); + if (!container) { + container = document.createElement('div'); + container.className = 'engine-tuning'; + $engineParams.appendChild(container); + } container.innerHTML = ''; const params = [ @@ -2300,6 +2395,7 @@ function wireSynthControls() { if (quickPlay) quickPlay.classList.add('audio-needs-init'); } else { await c15.start(); + await _startEocChain(); startBtn.textContent = 'Stop Audio'; if (quickPlay) quickPlay.classList.remove('audio-needs-init'); routeOutputs(iml.getOutputs()); @@ -2766,6 +2862,7 @@ function wireQuickPlayControls() { if (arpToggle) arpToggle.textContent = 'Play'; } else { await c15.start(); + await _startEocChain(); arpeggiator.start(); routeOutputs(iml.getOutputs()); // Also update the bottom sheet controls @@ -3190,6 +3287,14 @@ function saveState() { midiCCOverrides, audioCanvasState: audioCanvas ? audioCanvas.getState() : null, synthPresetId: activeSynthPresetId, + engineId: activeEngine ? activeEngine.id : 'shaper-feedback', + // EOC state + eocModules: eocChain ? eocChain.modules.map(m => ({ + id: m.id, + enabled: m.enabled, + params: m.paramMeta.map((_, i) => m.getCurrentParamValue(i)), + })) : [], + eocNispsMode: eocChain ? eocChain.nispsMode : 'bypass', }; localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch (e) { @@ -3290,12 +3395,59 @@ async function loadState() { } // Note: don't auto-restore inputMode='hands' — requires camera permission + + // Restore engine selection (currently always 'shaper-feedback'; pattern ready for future engines) + if (typeof state.engineId === 'string' && state.engineId !== (activeEngine ? activeEngine.id : null)) { + // Future: if (ENGINES.find(e => e.id === state.engineId && !e.comingSoon)) setActiveEngine(...) + // For now, sync the switcher highlight to whatever is active + EngineSwitcher.setActive(activeEngine ? activeEngine.id : 'shaper-feedback'); + } + + // Restore EOC chain state (modules, enabled flags, param values, nispsMode) + if (eocChain && Array.isArray(state.eocModules) && state.eocModules.length > 0) { + for (const saved of state.eocModules) { + // Only add if not already in the chain + if (!eocChain.getModule(saved.id)) { + try { + eocChain.addModule(moduleFactory(saved.id)); + } catch (e) { + console.warn(`[EOC] Could not restore module '${saved.id}':`, e.message); + continue; + } + } + const mod = eocChain.getModule(saved.id); + if (mod) { + mod.enabled = saved.enabled ?? true; + if (Array.isArray(saved.params)) { + saved.params.forEach((v, i) => mod.setParam(i, v)); + } + } + } + } + if (eocChain && typeof state.eocNispsMode === 'string') { + try { eocChain.nispsMode = state.eocNispsMode; } catch (_) { /* invalid mode in old save */ } + } + console.log(`[NISPS] Restored ${state.features?.length || 0} joy examples, ${state.handFeatures?.length || 0} hand examples from storage`); } catch (e) { console.warn('[NISPS] Failed to load state:', e); } } +function showToast(message, durationMs = 2500) { + let toast = document.getElementById('nisps-toast'); + if (!toast) { + toast = document.createElement('div'); + toast.id = 'nisps-toast'; + toast.className = 'nisps-toast'; + document.body.appendChild(toast); + } + toast.textContent = message; + toast.classList.add('visible'); + clearTimeout(toast._hideTimer); + toast._hideTimer = setTimeout(() => toast.classList.remove('visible'), durationMs); +} + function clearState() { try { localStorage.removeItem(STORAGE_KEY); diff --git a/playground/js/ui/engine-switcher.js b/playground/js/ui/engine-switcher.js new file mode 100644 index 0000000..e7aa6d6 --- /dev/null +++ b/playground/js/ui/engine-switcher.js @@ -0,0 +1,134 @@ +/** + * EngineSwitcher — engine selection UI for the Engine drawer. + * + * Usage: + * EngineSwitcher.init(containerEl, engines, onSwitch); + * + * @param {HTMLElement} containerEl — element to prepend the switcher into + * @param {Array<{id, displayName, paramCount, description, comingSoon?}>} engines + * @param {(engineId: string) => void} onSwitch — called when user confirms a switch + */ +export const EngineSwitcher = { + _containerEl: null, + _engines: [], + _activeId: null, + _onSwitch: null, + _hasTrainingData: null, // () => boolean + + /** + * @param {HTMLElement} containerEl + * @param {Array<{id:string, displayName:string, paramCount:number, description:string, comingSoon?:boolean}>} engines + * @param {(engineId:string) => void} onSwitch + * @param {{ hasTrainingData?: () => boolean }} [opts] + */ + init(containerEl, engines, onSwitch, opts = {}) { + this._containerEl = containerEl; + this._engines = engines; + this._onSwitch = onSwitch; + this._hasTrainingData = opts.hasTrainingData || (() => false); + + this._activeId = engines[0]?.id ?? null; + this._render(); + }, + + /** Update which card appears active (call after engine swap completes). */ + setActive(engineId) { + this._activeId = engineId; + const section = this._containerEl?.querySelector('.engine-switcher-section'); + if (!section) return; + section.querySelectorAll('.engine-card').forEach(card => { + card.classList.toggle('active', card.dataset.engineId === engineId); + }); + }, + + /** Show/hide the loading spinner on a card. */ + setLoading(engineId, loading) { + const section = this._containerEl?.querySelector('.engine-switcher-section'); + if (!section) return; + const card = section.querySelector(`[data-engine-id="${engineId}"]`); + if (card) card.classList.toggle('loading', loading); + }, + + _render() { + const container = this._containerEl; + if (!container) return; + + // Remove any previous section if re-initialised + const existing = container.querySelector('.engine-switcher-section'); + if (existing) existing.remove(); + + const section = document.createElement('div'); + section.className = 'engine-switcher-section'; + + const heading = document.createElement('div'); + heading.className = 'engine-switcher-heading'; + heading.textContent = 'Synthesis Engine'; + section.appendChild(heading); + + const grid = document.createElement('div'); + grid.className = 'engine-card-grid'; + + for (const eng of this._engines) { + const card = document.createElement('div'); + card.className = 'engine-card'; + card.dataset.engineId = eng.id; + if (eng.id === this._activeId) card.classList.add('active'); + if (eng.comingSoon) card.classList.add('coming-soon'); + + const nameRow = document.createElement('div'); + nameRow.className = 'engine-card-name-row'; + + const name = document.createElement('span'); + name.className = 'engine-card-name'; + name.textContent = eng.displayName; + + const badge = document.createElement('span'); + badge.className = 'engine-card-params'; + badge.textContent = eng.comingSoon ? 'soon' : `${eng.paramCount} params`; + + nameRow.appendChild(name); + nameRow.appendChild(badge); + + const desc = document.createElement('span'); + desc.className = 'engine-card-desc'; + desc.textContent = eng.description; + + card.appendChild(nameRow); + card.appendChild(desc); + grid.appendChild(card); + + if (!eng.comingSoon) { + card.addEventListener('click', () => this._handleClick(eng)); + } + } + + section.appendChild(grid); + + const divider = document.createElement('div'); + divider.className = 'engine-switcher-divider'; + section.appendChild(divider); + + // Prepend before any existing drawer content (e.g. engine-tuning sliders) + container.insertBefore(section, container.firstChild); + }, + + _handleClick(eng) { + if (eng.id === this._activeId) return; + + const proceed = () => { + this.setLoading(eng.id, true); + try { + this._onSwitch(eng.id); + } finally { + // Loading state cleared by caller via setLoading/setActive + } + }; + + if (this._hasTrainingData()) { + const msg = 'Switching engines will reset training examples. Network weights will be partially preserved. Continue?'; + if (window.confirm(msg)) proceed(); + } else { + proceed(); + } + }, +};