// RPi Audio Mixer — Web UI Application let ws = null; let sessionToken = null; let connected = false; let channelCount = 16; let paramState = {}; // channel -> { param_type -> value } // ── Logging ──────────────────────────────────────────────────────────── function log(msg, cls) { const el = document.getElementById('log'); const div = document.createElement('div'); div.textContent = new Date().toLocaleTimeString() + ' ' + msg; div.className = cls || ''; el.prepend(div); if (el.children.length > 50) el.lastChild.remove(); } // ── Connection ────────────────────────────────────────────────────────── async function login() { const apiKey = document.getElementById('apikey-input').value; try { const resp = await fetch('/api/v1/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey }, body: JSON.stringify({ api_key: apiKey }), }); const data = await resp.json(); if (data.token) { sessionToken = data.token; document.getElementById('connect-btn').disabled = false; log('Session obtained (expires in ' + data.expires_in + 's)', 'log-info'); // Auto-connect connectWS(); } else { log('Login failed: ' + JSON.stringify(data), 'log-error'); } } catch (e) { log('Login error: ' + e.message, 'log-error'); } } function connectWS() { if (!sessionToken) { log('No session token — login first', 'log-error'); return; } if (ws) ws.close(); const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const url = proto + '//' + location.host + '/ws?session=' + sessionToken; setStatus('connecting', '● Connecting...'); ws = new WebSocket(url); ws.onopen = () => { connected = true; setStatus('connected', '● Connected'); document.getElementById('connect-btn').disabled = true; log('WebSocket connected', 'log-state'); }; ws.onclose = (e) => { connected = false; setStatus('disconnected', '● Disconnected'); document.getElementById('connect-btn').disabled = false; log('WebSocket closed: ' + (e.reason || 'code ' + e.code), 'log-error'); }; ws.onerror = () => log('WebSocket error', 'log-error'); ws.onmessage = handleMessage; } function disconnectWS() { if (ws) ws.close(); } function setStatus(cls, text) { const el = document.getElementById('conn-status'); el.className = 'status ' + cls; el.textContent = text; } // ── Message handling ──────────────────────────────────────────────────── function handleMessage(event) { try { const msg = JSON.parse(event.data); switch (msg.type) { case 'full_state': handleFullState(msg.payload); break; case 'parameter_update': handleParameterUpdate(msg.payload); break; case 'transport_command': handleTransportUpdate(msg.payload); break; case 'error': log('Server error: ' + msg.payload.message, 'log-error'); break; default: log('Unknown msg type: ' + msg.type, 'log-info'); } } catch (e) { log('Parse error: ' + e.message, 'log-error'); } } function handleFullState(state) { log('Full state received: ' + (state.channels ? state.channels.length : 0) + ' channels', 'log-state'); if (state.channels) { channelCount = state.channels.length; buildChannelStrips(state.channels); } if (state.transport) { updateTransportUI(state.transport); } if (state.master) { updateMasterUI(state.master); } } function handleParameterUpdate(payload) { const { param_type, value, channel } = payload; if (!paramState[channel]) paramState[channel] = {}; paramState[channel][param_type] = value; updateChannelUI(channel, param_type, value); if (param_type === 'master_volume') { document.getElementById('master-vol-val').textContent = value.toFixed(1) + ' dB'; document.getElementById('master-fader').querySelector('input').value = value; } } function handleTransportUpdate(payload) { updateTransportUI(payload); } // ── Channel strip builder ─────────────────────────────────────────────── function buildChannelStrips(channels) { const container = document.getElementById('channels'); container.innerHTML = ''; channels.forEach((ch, i) => { const strip = document.createElement('div'); strip.className = 'channel-strip'; strip.id = 'ch-' + i; const volDb = ch.volume_db !== undefined ? ch.volume_db : 0; strip.innerHTML = `

${ch.label || 'CH' + (i + 1)}

Vol ${volDb.toFixed(1)} dB
Pan: ${((ch.pan || 0) * 100).toFixed(0)}%
`; container.appendChild(strip); // Initialize state if (!paramState[i]) paramState[i] = {}; paramState[i].mute = ch.mute || false; paramState[i].solo = ch.solo || false; paramState[i].volume = volDb; paramState[i].pan = ch.pan || 0; }); // Update channel count in master section context if needed } function updateChannelUI(channel, paramType, value) { const strip = document.getElementById('ch-' + channel); if (!strip) return; switch (paramType) { case 'volume': { const fader = strip.querySelector('.fader-v input'); const label = strip.querySelector('.fader-value'); if (fader) fader.value = value; if (label) label.textContent = value.toFixed(1) + ' dB'; break; } case 'pan': { const knob = strip.querySelector('.pan-knob input'); const label = strip.querySelector('.pan-value'); if (knob) knob.value = value; if (label) label.textContent = 'Pan: ' + (value * 100).toFixed(0) + '%'; break; } case 'mute': { const btn = document.getElementById('mute-' + channel); if (btn) { btn.className = 'chan-btn' + (value >= 0.5 ? ' muted' : ''); } break; } case 'solo': { const btn = document.getElementById('solo-' + channel); if (btn) { btn.className = 'chan-btn' + (value >= 0.5 ? ' soloed' : ''); } break; } } } function updateMasterUI(master) { if (master.volume_db !== undefined) { document.getElementById('master-fader').querySelector('input').value = master.volume_db; document.getElementById('master-vol-val').textContent = master.volume_db.toFixed(1) + ' dB'; } } function updateTransportUI(transport) { document.getElementById('btn-play').className = 'tbtn' + (transport.playing ? ' active' : ''); document.getElementById('btn-record').className = 'tbtn' + (transport.recording ? ' active' : ''); document.getElementById('btn-loop').className = 'tbtn' + (transport.loop ? ' active' : ''); if (transport.tempo_bpm) { document.getElementById('tempo-display').textContent = transport.tempo_bpm.toFixed(1) + ' BPM'; } if (transport.position_sec !== undefined) { const secs = Math.floor(transport.position_sec); const h = Math.floor(secs / 3600); const m = Math.floor((secs % 3600) / 60); const s = secs % 60; document.getElementById('time-display').textContent = String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0'); } } function toggleMute(channel) { const current = paramState[channel] && paramState[channel].mute ? 0 : 1; sendParam('mute', current, channel); } function toggleSolo(channel) { const current = paramState[channel] && paramState[channel].solo ? 0 : 1; sendParam('solo', current, channel); } // ── Sending ───────────────────────────────────────────────────────────── function sendParam(paramType, value, channel) { if (!ws || ws.readyState !== WebSocket.OPEN) return; const msg = { type: 'parameter_update', payload: { param_type: String(paramType), value: parseFloat(value), channel: parseInt(channel), timestamp: Date.now() / 1000, }, }; ws.send(JSON.stringify(msg)); } function sendTransport(command) { if (!ws || ws.readyState !== WebSocket.OPEN) return; ws.send(JSON.stringify({ type: 'transport_command', payload: { command: command }, })); } // ── Startup ───────────────────────────────────────────────────────────── // Auto-login on page load window.addEventListener('DOMContentLoaded', () => { log('Mixer UI loaded — login to connect', 'log-info'); // Auto-login with default key login(); });