/** * Phase 4 UI — Output Pipeline + Visualization + Polish * * Integrates: * - Output freeze button on floating bar * - Weight health + gradient flow panel in settings drawer * - Session preset UI in settings drawer * - Wires output pipeline sliders to actual OutputPipeline instance * * Usage: * import { initPhase4UI } from './ui/phase4-ui.js'; * const phase4 = initPhase4UI({ outputPipeline, iml, controlSurface, ... }); * * @module phase4-ui */ import { WeightHealthIndicator } from './weight-health.js'; import { GradientFlowIndicator } from './gradient-flow.js'; import { SessionPresetManager } from './session-presets.js'; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const HEALTH_UPDATE_INTERVAL = 500; // ms between weight health updates // Snowflake SVG icon (for output freeze button) const SNOWFLAKE_SVG = ` `; // --------------------------------------------------------------------------- // Main initialization // --------------------------------------------------------------------------- /** * Initialize Phase 4 UI components. * * @param {object} opts * @param {import('./output-pipeline.js').OutputPipeline} opts.outputPipeline * @param {object} opts.iml — WasmIML or IML instance * @param {import('./control-surface.js').ControlSurface} opts.controlSurface * @param {function} opts.getGroupOverrides — returns current groupOverrides array * @param {function} opts.getActiveSynthPresetId — returns active synth preset id * @param {import('./input-pipeline.js').InputPipeline} opts.inputPipeline * @param {function} opts.onSessionLoad — callback(preset) when a session is loaded * @returns {object} { weightHealth, gradientFlow, sessionManager, destroy } */ export function initPhase4UI(opts) { const { outputPipeline, iml, controlSurface, getGroupOverrides, getActiveSynthPresetId, inputPipeline, onSessionLoad, } = opts; // ---- Weight Health ---- const weightHealth = new WeightHealthIndicator(); // ---- Gradient Flow ---- // layerSizes from the IML instance const layerSizes = iml.layerSizes || [3, 32, 48, 64, 126]; const gradientFlow = new GradientFlowIndicator(layerSizes); // ---- Session Presets ---- const sessionManager = new SessionPresetManager(); // ---- 1. Output Freeze Button ---- const freezeBtn = createFreezeButton(outputPipeline); // ---- 2. Network Health Panel (in settings drawer) ---- const healthPanel = createHealthPanel(weightHealth, gradientFlow); // ---- 3. Session Preset UI (in settings drawer) ---- const sessionUI = createSessionUI(sessionManager, { controlSurface, outputPipeline, inputPipeline, getGroupOverrides, getActiveSynthPresetId, onSessionLoad, }); // ---- 4. Wire output pipeline sliders ---- wireOutputPipelineSliders(outputPipeline, controlSurface); // ---- Periodic weight health updates ---- let healthInterval = setInterval(() => { updateWeightHealth(iml, weightHealth, healthPanel.canvas, healthPanel.ctx, gradientFlow, healthPanel.gradCanvas, healthPanel.gradCtx); }, HEALTH_UPDATE_INTERVAL); // ---- Inject styles ---- injectPhase4Styles(); // ---- Cleanup ---- function destroy() { clearInterval(healthInterval); freezeBtn.remove(); healthPanel.container.remove(); sessionUI.container.remove(); const styleEl = document.getElementById('phase4-styles'); if (styleEl) styleEl.remove(); } return { weightHealth, gradientFlow, sessionManager, freezeBtn, /** * Call before iml.train() / iml.trainAsync() to capture weight snapshot. */ captureBeforeTrain() { const weights = getFlatWeights(iml); if (weights) gradientFlow.captureBeforeTrain(weights); }, /** * Call after training completes to capture weight snapshot and update gradient flow. */ captureAfterTrain() { const weights = getFlatWeights(iml); if (weights) { gradientFlow.captureAfterTrain(weights); // Immediately refresh the health panel updateWeightHealth(iml, weightHealth, healthPanel.canvas, healthPanel.ctx, gradientFlow, healthPanel.gradCanvas, healthPanel.gradCtx); } }, destroy, }; } // --------------------------------------------------------------------------- // Freeze Button // --------------------------------------------------------------------------- function createFreezeButton(outputPipeline) { const $barContent = document.querySelector('.floating-bar-content') || document.getElementById('floating-bar'); if (!$barContent) { console.warn('[Phase4] floating-bar not found'); return document.createElement('button'); } const btn = document.createElement('button'); btn.className = 'float-btn p4-freeze-btn'; btn.id = 'btn-freeze-output'; btn.title = 'Freeze output (synth/visual stays locked)'; btn.innerHTML = SNOWFLAKE_SVG; btn.addEventListener('click', () => { const frozen = !outputPipeline.isFrozen(); outputPipeline.setFrozen(frozen); btn.classList.toggle('active', frozen); btn.title = frozen ? 'Unfreeze output' : 'Freeze output'; }); $barContent.appendChild(btn); return btn; } // --------------------------------------------------------------------------- // Network Health Panel // --------------------------------------------------------------------------- function createHealthPanel(weightHealth, gradientFlow) { // Find the settings drawer body const drawerBody = document.querySelector('.cs-drawer-body'); if (!drawerBody) { // Drawer not yet created; defer by creating a detached element const container = document.createElement('div'); return { container, canvas: document.createElement('canvas'), ctx: null, gradCanvas: document.createElement('canvas'), gradCtx: null, }; } const container = document.createElement('div'); container.className = 'cs-section p4-health-section'; container.innerHTML = `
Network Health
Weight Health
Gradient Flow
`; // Collapse toggle const header = container.querySelector('.cs-section-header'); header.addEventListener('click', () => { container.classList.toggle('collapsed'); }); // Append to drawer body (at the bottom) drawerBody.appendChild(container); const canvas = container.querySelector('.p4-health-canvas'); const gradCanvas = container.querySelector('.p4-grad-canvas'); return { container, canvas, ctx: canvas.getContext('2d'), gradCanvas, gradCtx: gradCanvas.getContext('2d'), }; } // --------------------------------------------------------------------------- // Session Preset UI // --------------------------------------------------------------------------- function createSessionUI(sessionManager, deps) { const drawerBody = document.querySelector('.cs-drawer-body'); if (!drawerBody) { return { container: document.createElement('div') }; } const container = document.createElement('div'); container.className = 'p4-session-section'; const presets = sessionManager.list(); container.innerHTML = `
Session Presets
`; // Insert at the top of the drawer body (before the preset row) const firstChild = drawerBody.firstChild; drawerBody.insertBefore(container, firstChild); // Wire interactions const $select = container.querySelector('#p4-session-select'); const $loadBtn = container.querySelector('#p4-session-load'); const $deleteBtn = container.querySelector('#p4-session-delete'); const $nameInput = container.querySelector('#p4-session-name'); const $saveBtn = container.querySelector('#p4-session-save'); const $shareBtn = container.querySelector('#p4-session-share'); function refreshDropdown() { const list = sessionManager.list(); $select.innerHTML = `` + list.map(p => ``).join(''); } function getCurrentState() { return { controlSurface: deps.controlSurface ? deps.controlSurface.getState() : null, synthPresetId: deps.getActiveSynthPresetId ? deps.getActiveSynthPresetId() : null, groupOverrides: deps.getGroupOverrides ? deps.getGroupOverrides() : null, inputPipeline: deps.inputPipeline ? deps.inputPipeline.getConfig() : null, outputPipeline: deps.outputPipeline ? deps.outputPipeline.getConfig() : null, }; } $saveBtn.addEventListener('click', () => { const name = $nameInput.value.trim(); if (!name) return; sessionManager.save(name, getCurrentState()); $nameInput.value = ''; refreshDropdown(); $select.value = name; }); $loadBtn.addEventListener('click', () => { const name = $select.value; if (!name) return; const preset = sessionManager.load(name); if (preset && deps.onSessionLoad) { deps.onSessionLoad(preset); } }); $deleteBtn.addEventListener('click', () => { const name = $select.value; if (!name) return; sessionManager.delete(name); refreshDropdown(); }); $shareBtn.addEventListener('click', () => { const state = getCurrentState(); const preset = sessionManager.capture('share', state); const params = sessionManager.toURL(preset); const url = new URL(window.location.href); // Clear existing session params for (const key of ['sp', 'cs', 'op', 'co']) { url.searchParams.delete(key); } // Append session params const sessionParams = new URLSearchParams(params); for (const [k, v] of sessionParams) { url.searchParams.set(k, v); } navigator.clipboard.writeText(url.toString()).then(() => { $shareBtn.textContent = 'Copied!'; setTimeout(() => { $shareBtn.textContent = 'URL'; }, 1500); }).catch(() => { $shareBtn.textContent = 'Error'; setTimeout(() => { $shareBtn.textContent = 'URL'; }, 1500); }); }); return { container, refreshDropdown }; } // --------------------------------------------------------------------------- // Wire Output Pipeline Sliders // --------------------------------------------------------------------------- /** * Listen for controlsurface:change events and pipe output-related params * into the OutputPipeline instance. */ function wireOutputPipelineSliders(outputPipeline, controlSurface) { document.addEventListener('controlsurface:change', (e) => { const p = e.detail; if (p.outputSmoothing != null) { outputPipeline.setSmoothing(p.outputSmoothing); } if (p.outputSlewRate != null) { outputPipeline.setSlewRate(p.outputSlewRate); } if (p.globalCurve != null) { outputPipeline.setGlobalCurve(p.globalCurve); } // Note: 'tame' is handled at groupOverrides init time (baked into min/max), // not as a runtime pipeline stage. }); } // --------------------------------------------------------------------------- // Weight Health Update // --------------------------------------------------------------------------- function getFlatWeights(iml) { // WasmIML has _getFlatWeights() if (typeof iml._getFlatWeights === 'function') { return iml._getFlatWeights(); } // JS IML: flatten from mlp.getWeights() structure if (iml.mlp && typeof iml.mlp.getWeights === 'function') { const structured = iml.mlp.getWeights(); const flat = []; for (const layer of structured) { for (const node of layer) { flat.push(...node.weights, node.bias); } } return new Float32Array(flat); } return null; } function updateWeightHealth(iml, weightHealth, canvas, ctx, gradientFlow, gradCanvas, gradCtx) { const weights = getFlatWeights(iml); if (weights) { weightHealth.update(weights); } if (ctx && canvas) { ctx.clearRect(0, 0, canvas.width, canvas.height); weightHealth.draw(ctx, 0, 0, canvas.width, canvas.height); } if (gradCtx && gradCanvas) { gradCtx.clearRect(0, 0, gradCanvas.width, gradCanvas.height); gradientFlow.draw(gradCtx, 0, 0, gradCanvas.width, gradCanvas.height); } } // --------------------------------------------------------------------------- // CSS injection // --------------------------------------------------------------------------- function injectPhase4Styles() { if (document.getElementById('phase4-styles')) return; const style = document.createElement('style'); style.id = 'phase4-styles'; style.textContent = ` /* ---- Phase 4: Freeze Button ---- */ .p4-freeze-btn { display: flex; align-items: center; justify-content: center; padding: 0 6px; min-width: 28px; color: rgba(255, 255, 255, 0.5); transition: color 0.15s, background 0.15s; } .p4-freeze-btn.active { color: #5bcefa; background: rgba(91, 206, 250, 0.12); border-color: rgba(91, 206, 250, 0.3); } .p4-freeze-btn:hover { color: #5bcefa; } /* ---- Phase 4: Network Health Section ---- */ .p4-health-section { border-top: 1px solid rgba(255, 255, 255, 0.06); margin-top: 4px; } .p4-health-row { display: flex; align-items: center; gap: 8px; padding: 4px 0; } .p4-health-label { font-size: 10px; color: rgba(255, 255, 255, 0.45); min-width: 70px; flex-shrink: 0; } .p4-health-canvas { border-radius: 3px; flex: 1; max-width: 140px; height: 32px; } .p4-grad-canvas { border-radius: 3px; flex: 1; max-width: 140px; height: 48px; } /* ---- Phase 4: Session Presets ---- */ .p4-session-section { padding: 10px 16px 8px; border-bottom: 1px solid rgba(255, 255, 255, 0.06); } .p4-session-header { font-size: 10px; font-weight: 600; color: rgba(255, 255, 255, 0.6); text-transform: uppercase; letter-spacing: 0.7px; margin-bottom: 6px; } .p4-session-row { display: flex; align-items: center; gap: 4px; margin-bottom: 4px; } .p4-session-select { flex: 1; min-width: 0; background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 4px; color: rgba(255, 255, 255, 0.8); font-size: 11px; padding: 4px 6px; font-family: inherit; } .p4-session-name { flex: 1; min-width: 0; background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 4px; color: rgba(255, 255, 255, 0.8); font-size: 11px; padding: 4px 6px; font-family: inherit; outline: none; } .p4-session-name::placeholder { color: rgba(255, 255, 255, 0.25); } .p4-session-name:focus { border-color: var(--accent, #ff6a00); } .p4-session-btn { background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 4px; color: rgba(255, 255, 255, 0.6); font-size: 10px; padding: 4px 8px; cursor: pointer; font-family: inherit; white-space: nowrap; transition: all 0.12s; } .p4-session-btn:hover { background: rgba(255, 255, 255, 0.1); color: rgba(255, 255, 255, 0.9); } .p4-session-save { color: var(--accent, #ff6a00); border-color: rgba(255, 106, 0, 0.2); } .p4-session-save:hover { background: rgba(255, 106, 0, 0.12); } .p4-session-del { color: rgba(255, 80, 80, 0.7); font-size: 14px; padding: 2px 6px; line-height: 1; } .p4-session-del:hover { color: rgba(255, 80, 80, 1); background: rgba(255, 80, 80, 0.1); } .p4-session-share { color: rgba(150, 180, 255, 0.7); border-color: rgba(150, 180, 255, 0.15); } .p4-session-share:hover { background: rgba(150, 180, 255, 0.1); color: rgba(150, 180, 255, 1); } `; document.head.appendChild(style); }