/* ===== OPLabs Band Channel - ModGUI Application ===== */ (function () { 'use strict'; // ---- Constants ---- var INSTRUMENTS = ['Guitar', 'Bass', 'Keys', 'Vocals', 'Backing']; var INSTRUMENT_COLORS = ['#ff8c00', '#2196f3', '#9c27b0', '#e91e63', '#009688']; var DB_MIN = -60; var DB_MAX_VOL = 6; // volume max var DB_MAX_METER = 0; // meter max // ---- State ---- var state = { volume: 0, pan: 0, mute: 0, solo: 0, instrument: 0, levelL: -60, levelR: -60 }; // Peak hold state for meters var peakL = -60; var peakR = -60; var peakTimerL = null; var peakTimerR = null; var PEAK_HOLD_MS = 1500; // Drag state for knobs var dragState = null; // { knob, startY, startVal, min, max, onChange } // ---- DOM References ---- var els = {}; function cacheElements() { els.btnMute = document.getElementById('btnMute'); els.btnSolo = document.getElementById('btnSolo'); els.instrumentBadge = document.getElementById('instrumentBadge'); els.instrumentSelect = document.getElementById('instrumentSelect'); els.meterFillL = document.getElementById('meterFillL'); els.meterFillR = document.getElementById('meterFillR'); els.meterPeakL = document.getElementById('meterPeakL'); els.meterPeakR = document.getElementById('meterPeakR'); els.meterDbReadout = document.getElementById('meterDbReadout'); els.volumeReadout = document.getElementById('volumeReadout'); els.panReadout = document.getElementById('panReadout'); els.knobVolumeGrip = document.getElementById('knobVolumeGrip'); els.knobPanGrip = document.getElementById('knobPanGrip'); els.knobVolume = document.getElementById('knobVolume'); els.knobPan = document.getElementById('knobPan'); els.channelStrip = document.getElementById('channelStrip'); } // ---- Utility ---- function clamp(val, min, max) { return Math.min(max, Math.max(min, val)); } function dbToPercent(db, dbMax) { dbMax = dbMax || DB_MAX_METER; var clamped = clamp(db, DB_MIN, dbMax); var normalized = (clamped - DB_MIN) / (dbMax - DB_MIN); // 0..1 return Math.sqrt(normalized) * 100; } function getMeterColor(pct) { if (pct < 60) return 'var(--meter-green)'; if (pct < 85) return 'var(--meter-yellow)'; return 'var(--meter-red)'; } // ---- Knob angle mapping ---- // Maps a value in [min, max] to rotation angle [-135, 135] degrees function valueToAngle(val, min, max) { var t = (val - min) / (max - min); return -135 + t * 270; } function setKnobAngle(gripEl, angleDeg) { gripEl.style.transform = 'rotate(' + angleDeg + 'deg)'; } // ---- Rotary knob drag handler ---- function initKnob(knobEl, gripEl, symbol, min, max, step, displayFn) { function onPointerDown(e) { e.preventDefault(); if (dragState) return; var startVal = state[symbol]; if (typeof startVal !== 'number') startVal = min; dragState = { symbol: symbol, startY: e.clientY || e.touches[0].clientY, startVal: startVal, min: min, max: max, step: step, gripEl: gripEl, knobEl: knobEl, displayFn: displayFn }; document.addEventListener('pointermove', onPointerMove); document.addEventListener('pointerup', onPointerUp); document.addEventListener('touchmove', onTouchMove, { passive: false }); document.addEventListener('touchend', onPointerUp); } function onPointerMove(e) { if (!dragState) return; e.preventDefault(); var clientY = e.clientY; dragKnob(clientY); } function onTouchMove(e) { if (!dragState) return; e.preventDefault(); var touch = e.touches[0]; dragKnob(touch.clientY); } function dragKnob(clientY) { var d = dragState; var deltaY = d.startY - clientY; // positive = drag up = increase var range = d.max - d.min; // Sensitivity: 300px for full range var deltaVal = (deltaY / 300) * range; var newVal = clamp(d.startVal + deltaVal, d.min, d.max); // Apply step if (d.step > 0) { newVal = Math.round(newVal / d.step) * d.step; newVal = clamp(newVal, d.min, d.max); } // Update state and host state[d.symbol] = newVal; sendValue(d.symbol, newVal); updateKnobVisual(d); if (d.displayFn) d.displayFn(newVal); } function onPointerUp() { if (!dragState) return; dragState = null; document.removeEventListener('pointermove', onPointerMove); document.removeEventListener('pointerup', onPointerUp); document.removeEventListener('touchmove', onTouchMove); document.removeEventListener('touchend', onPointerUp); } // Mouse knobEl.addEventListener('pointerdown', onPointerDown); // Touch knobEl.addEventListener('touchstart', function (e) { // For touch, prevent default to avoid scrolling e.preventDefault(); onPointerDown(e); }, { passive: false }); } function updateKnobVisual(d) { var val = state[d.symbol]; var angle = valueToAngle(val, d.min, d.max); setKnobAngle(d.gripEl, angle); } // ---- Send value to host ---- function sendValue(symbol, value) { if (window.modguijs && typeof window.modguijs.setValue === 'function') { window.modguijs.setValue(symbol, value); } } // ---- Update UI from state ---- function updateVolumeDisplay(val) { var display = val <= DB_MIN ? '-∞' : (val >= 0 ? '+' : '') + val.toFixed(1) + ' dB'; els.volumeReadout.textContent = display; var angle = valueToAngle(val, DB_MIN, DB_MAX_VOL); setKnobAngle(els.knobVolumeGrip, angle); } function updatePanDisplay(val) { var display; if (val === 0) display = 'C'; else if (val < 0) display = 'L' + Math.abs(Math.round(val * 100)); else display = 'R' + Math.round(val * 100); els.panReadout.textContent = display; var angle = valueToAngle(val, -1, 1); setKnobAngle(els.knobPanGrip, angle); } function updateMuteDisplay(val) { var active = val >= 0.5; els.btnMute.classList.toggle('active', active); } function updateSoloDisplay(val) { var active = val >= 0.5; els.btnSolo.classList.toggle('active', active); } function updateInstrumentDisplay(val) { var idx = Math.round(val) || 0; idx = clamp(idx, 0, 4); els.instrumentSelect.value = String(idx); els.instrumentBadge.textContent = INSTRUMENTS[idx]; els.instrumentBadge.style.color = INSTRUMENT_COLORS[idx]; els.instrumentBadge.style.borderColor = INSTRUMENT_COLORS[idx]; els.instrumentBadge.style.background = INSTRUMENT_COLORS[idx] + '22'; // Update border accent on channel strip els.channelStrip.style.borderTopColor = INSTRUMENT_COLORS[idx]; } function updateMeterDisplay(levelL, levelR) { // Update fill heights var pctL = dbToPercent(levelL, DB_MAX_METER); var pctR = dbToPercent(levelR, DB_MAX_METER); els.meterFillL.style.height = Math.min(100, pctL) + '%'; els.meterFillR.style.height = Math.min(100, pctR) + '%'; // Update fill colors els.meterFillL.style.background = getMeterColor(pctL); els.meterFillR.style.background = getMeterColor(pctR); els.meterFillL.className = 'meter-fill' + (pctL >= 85 ? ' hot' : pctL >= 60 ? ' warm' : ''); els.meterFillR.className = 'meter-fill' + (pctR >= 85 ? ' hot' : pctR >= 60 ? ' warm' : ''); // Peak hold logic updatePeak('L', levelL, pctL); updatePeak('R', levelR, pctR); // dB readout var dbL = levelL <= DB_MIN ? '-∞' : levelL.toFixed(1); var dbR = levelR <= DB_MIN ? '-∞' : levelR.toFixed(1); els.meterDbReadout.innerHTML = '' + dbL + '' + '' + dbR + ''; } function updatePeak(ch, level, pct) { var peakEl = ch === 'L' ? els.meterPeakL : els.meterPeakR; if (ch === 'L') { if (level > peakL) { peakL = level; if (peakTimerL) { clearTimeout(peakTimerL); peakTimerL = null; } peakTimerL = setTimeout(function () { peakL = Math.max(state.levelL, DB_MIN); peakEl.style.bottom = Math.min(100, dbToPercent(peakL, DB_MAX_METER)) + '%'; }, PEAK_HOLD_MS); } } else { if (level > peakR) { peakR = level; if (peakTimerR) { clearTimeout(peakTimerR); peakTimerR = null; } peakTimerR = setTimeout(function () { peakR = Math.max(state.levelR, DB_MIN); peakEl.style.bottom = Math.min(100, dbToPercent(peakR, DB_MAX_METER)) + '%'; }, PEAK_HOLD_MS); } } peakEl.style.bottom = Math.min(100, pct) + '%'; } // ---- Full UI update ---- function updateAll() { updateVolumeDisplay(state.volume); updatePanDisplay(state.pan); updateMuteDisplay(state.mute); updateSoloDisplay(state.solo); updateInstrumentDisplay(state.instrument); updateMeterDisplay(state.levelL, state.levelR); } // ---- UI Event Handlers ---- function setupUIHandlers() { // Mute button els.btnMute.addEventListener('click', function () { var newVal = state.mute >= 0.5 ? 0 : 1; state.mute = newVal; sendValue('mute', newVal); updateMuteDisplay(newVal); }); // Solo button els.btnSolo.addEventListener('click', function () { var newVal = state.solo >= 0.5 ? 0 : 1; state.solo = newVal; sendValue('solo', newVal); updateSoloDisplay(newVal); }); // Instrument selector els.instrumentSelect.addEventListener('change', function () { var newVal = parseInt(this.value, 10); state.instrument = newVal; sendValue('instrument', newVal); updateInstrumentDisplay(newVal); }); } // ---- Knobs setup ---- function setupKnobs() { // Volume knob: -60 to +6, step 0.5 initKnob( els.knobVolume, els.knobVolumeGrip, 'volume', DB_MIN, DB_MAX_VOL, 0.5, function (val) { updateVolumeDisplay(val); } ); // Pan knob: -1 to 1, step 0.01 initKnob( els.knobPan, els.knobPanGrip, 'pan', -1, 1, 0.01, function (val) { updatePanDisplay(val); } ); // Initial knob angles updateVolumeDisplay(state.volume); updatePanDisplay(state.pan); } // ---- ModGUI API Integration ---- function setupModGUI() { // Try to read initial values if (window.modguijs) { // Get initial values from host var params = ['volume', 'pan', 'mute', 'solo', 'instrument', 'levelL', 'levelR']; params.forEach(function (symbol) { if (typeof window.modguijs.getValue === 'function') { try { var val = window.modguijs.getValue(symbol); if (val !== undefined && val !== null) { state[symbol] = val; } } catch (e) { // Ignore } } }); // Subscribe to changes if (typeof window.modguijs.onChange === 'function') { params.forEach(function (symbol) { window.modguijs.onChange(symbol, function (value) { state[symbol] = value; switch (symbol) { case 'volume': updateVolumeDisplay(value); break; case 'pan': updatePanDisplay(value); break; case 'mute': updateMuteDisplay(value); break; case 'solo': updateSoloDisplay(value); break; case 'instrument': updateInstrumentDisplay(value); break; case 'levelL': updateMeterDisplay(state.levelL, state.levelR); break; case 'levelR': updateMeterDisplay(state.levelL, state.levelR); break; } }); }); } // If no onChange, poll manually if (typeof window.modguijs.onChange !== 'function') { startPolling(); } } else { // Fallback: poll if modguijs not ready yet, wait then retry setTimeout(function () { if (window.modguijs) { setupModGUI(); } else { // Still no API — run in demo mode startDemoMode(); } }, 500); } } // ---- Polling fallback (if onChange not supported) ---- var pollInterval = null; function startPolling() { if (pollInterval) return; pollInterval = setInterval(function () { if (!window.modguijs || typeof window.modguijs.getValue !== 'function') return; var params = ['volume', 'pan', 'mute', 'solo', 'instrument', 'levelL', 'levelR']; params.forEach(function (symbol) { try { var val = window.modguijs.getValue(symbol); if (val !== undefined && val !== null && val !== state[symbol]) { state[symbol] = val; } } catch (e) {} }); // Batch update meter updateMeterDisplay(state.levelL, state.levelR); }, 50); // 20fps for meters } // ---- Demo mode (when not in PiPedal) ---- var demoInterval = null; function startDemoMode() { console.log('OPLabs Band Channel: No modguijs API found, running demo mode'); // Animate levels for visual testing var t = 0; demoInterval = setInterval(function () { t += 0.05; var l = -40 + 30 * (Math.sin(t) * 0.5 + 0.5); var r = -40 + 30 * (Math.cos(t * 0.7) * 0.5 + 0.5); state.levelL = l; state.levelR = r; updateMeterDisplay(l, r); }, 100); } // ---- Initialization ---- function init() { cacheElements(); setupUIHandlers(); setupKnobs(); updateAll(); setupModGUI(); } // Wait for DOM if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } // Clean up on unload window.addEventListener('beforeunload', function () { if (pollInterval) clearInterval(pollInterval); if (demoInterval) clearInterval(demoInterval); if (peakTimerL) clearTimeout(peakTimerL); if (peakTimerR) clearTimeout(peakTimerR); }); })();