/** * AI Radio Show - Control Panel (Server-Side Audio) */ // --- State --- let currentCaller = null; let isProcessing = false; let isRecording = false; let phoneFilter = false; let autoScroll = true; let logPollInterval = null; let lastLogCount = 0; // Track lists let tracks = []; let sounds = []; let isMusicPlaying = false; let soundboardExpanded = false; // --- Show Clock --- let showStartTime = null; // when ON AIR was pressed let showContentTime = 0; // seconds of "active" content (calls, music, etc.) let showContentTracking = false; // whether we're in active content right now let showClockInterval = null; function initClock() { // Always show current time if (!showClockInterval) { showClockInterval = setInterval(updateShowClock, 1000); updateShowClock(); } } function startShowClock() { showStartTime = Date.now(); showContentTime = 0; showContentTracking = false; document.getElementById('show-timers')?.classList.remove('hidden'); } function stopShowClock() { document.getElementById('show-timers')?.classList.add('hidden'); showStartTime = null; } function updateShowClock() { // Current time const now = new Date(); const timeEl = document.getElementById('clock-time'); if (timeEl) timeEl.textContent = now.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', second: '2-digit', hour12: true }); if (!showStartTime) return; // Track content time — count seconds when a call is active or music is playing const isContent = !!(currentCaller || isMusicPlaying); if (isContent && !showContentTracking) { showContentTracking = true; } else if (!isContent && showContentTracking) { showContentTracking = false; } if (isContent) showContentTime++; // Show runtime (wall clock since ON AIR) const runtimeSec = Math.floor((Date.now() - showStartTime) / 1000); const runtimeEl = document.getElementById('clock-runtime'); if (runtimeEl) runtimeEl.textContent = formatDuration(runtimeSec); // Estimated final length after post-prod // Post-prod removes 2-8 second gaps (TTS latency). Estimate: // - Content time stays ~100% (it's all talking/music) // - Dead air (runtime - content) gets ~70% removed (not all silence is cut) const deadAir = Math.max(0, runtimeSec - showContentTime); const estimatedFinal = showContentTime + (deadAir * 0.3); const estEl = document.getElementById('clock-estimate'); if (estEl) estEl.textContent = formatDuration(Math.round(estimatedFinal)); } function formatDuration(totalSec) { const h = Math.floor(totalSec / 3600); const m = Math.floor((totalSec % 3600) / 60); const s = totalSec % 60; if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; return `${m}:${String(s).padStart(2, '0')}`; } // --- Helpers --- function _isTyping() { const el = document.activeElement; if (!el) return false; const tag = el.tagName; return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable; } // --- Safe JSON parsing --- async function safeFetch(url, options = {}, timeoutMs = 30000) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const res = await fetch(url, { ...options, signal: controller.signal }); clearTimeout(timer); if (!res.ok) { const text = await res.text(); let detail = text; try { detail = JSON.parse(text).detail || text; } catch {} throw new Error(detail); } const text = await res.text(); if (!text) return {}; return JSON.parse(text); } catch (err) { clearTimeout(timer); if (err.name === 'AbortError') throw new Error('Request timed out'); throw err; } } // --- Init --- document.addEventListener('DOMContentLoaded', async () => { console.log('AI Radio Show initializing...'); try { await loadAudioDevices(); await loadCallers(); await loadMusic(); await loadAds(); await loadIdents(); await loadSounds(); await loadSettings(); initEventListeners(); initClock(); loadShowTheme(); loadVoicemails(); setInterval(loadVoicemails, 30000); loadEmails(); setInterval(loadEmails, 30000); log('Ready. Configure audio devices in Settings, then click a caller to start.'); console.log('AI Radio Show ready'); } catch (err) { console.error('Init error:', err); log('Error loading: ' + err.message); } }); function initEventListeners() { // Hangup document.getElementById('hangup-btn')?.addEventListener('click', hangup); // New Session document.getElementById('new-session-btn')?.addEventListener('click', newSession); // On-Air + Recording (linked — toggling one toggles both) const onAirBtn = document.getElementById('on-air-btn'); const recBtn = document.getElementById('rec-btn'); let stemRecording = false; function updateRecBtn(recording) { stemRecording = recording; if (recBtn) { recBtn.classList.toggle('recording', recording); recBtn.textContent = recording ? '⏺ REC' : 'REC'; } } if (onAirBtn) { fetch('/api/on-air').then(r => r.json()).then(data => { updateOnAirBtn(onAirBtn, data.on_air); updateRecBtn(data.recording); }); onAirBtn.addEventListener('click', async () => { const isOn = onAirBtn.classList.contains('on'); const res = await safeFetch('/api/on-air', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ on_air: !isOn }), }); updateOnAirBtn(onAirBtn, res.on_air); updateRecBtn(res.recording); log(res.on_air ? 'Show is ON AIR + Recording' : 'Show is OFF AIR + Recording stopped'); }); } if (recBtn) { recBtn.addEventListener('click', async () => { try { const res = await safeFetch('/api/recording/toggle', { method: 'POST' }); updateRecBtn(res.recording); if (onAirBtn) updateOnAirBtn(onAirBtn, res.on_air); log(res.recording ? 'Recording started + ON AIR' : 'Recording stopped + OFF AIR'); } catch (err) { log('Recording error: ' + err.message); } }); } // Export session document.getElementById('export-session-btn')?.addEventListener('click', exportSession); // Server controls document.getElementById('restart-server-btn')?.addEventListener('click', restartServer); document.getElementById('stop-server-btn')?.addEventListener('click', stopServer); document.getElementById('auto-scroll')?.addEventListener('change', e => { autoScroll = e.target.checked; }); // Log toggle (collapsed by default) const logToggleBtn = document.getElementById('log-toggle-btn'); if (logToggleBtn) { logToggleBtn.addEventListener('click', () => { const logBody = document.querySelector('.log-body'); if (!logBody) return; const collapsed = logBody.classList.toggle('collapsed'); logToggleBtn.textContent = collapsed ? 'Show \u25BC' : 'Hide \u25B2'; }); } // Start log polling startLogPolling(); // Start queue polling startQueuePolling(); // Start cost polling startCostPolling(); // Talk button - now triggers server-side recording const talkBtn = document.getElementById('talk-btn'); if (talkBtn) { talkBtn.addEventListener('mousedown', startRecording); // Listen on document for mouseup so layout shifts don't orphan the release document.addEventListener('mouseup', () => { if (isRecording) stopRecording(); }); talkBtn.addEventListener('touchstart', e => { e.preventDefault(); startRecording(); }); talkBtn.addEventListener('touchend', e => { e.preventDefault(); stopRecording(); }); } // Spacebar push-to-talk — blur buttons so Space doesn't also trigger button click document.addEventListener('keydown', e => { if (e.code !== 'Space' || e.repeat || _isTyping()) return; e.preventDefault(); // Blur any focused button so browser doesn't fire its click if (document.activeElement?.tagName === 'BUTTON') document.activeElement.blur(); startRecording(); }); document.addEventListener('keyup', e => { if (e.code !== 'Space' || _isTyping()) return; e.preventDefault(); stopRecording(); }); // Keyboard shortcuts document.addEventListener('keydown', e => { if (_isTyping()) return; // Don't fire shortcuts when a modal is open (except Escape to close it) const modalOpen = document.querySelector('.modal:not(.hidden)'); if (e.key === 'Escape') { if (modalOpen) { modalOpen.classList.add('hidden'); e.preventDefault(); } return; } if (modalOpen) return; const key = e.key.toLowerCase(); // 1-9, 0: Start call with caller in that slot if (/^[0-9]$/.test(key)) { e.preventDefault(); const idx = key === '0' ? 9 : parseInt(key) - 1; const btns = document.querySelectorAll('.caller-btn'); if (btns[idx]) btns[idx].click(); return; } switch (key) { case 'h': e.preventDefault(); hangup(); break; case 'w': e.preventDefault(); wrapUp(); break; case 'm': e.preventDefault(); toggleMusic(); break; case 'd': e.preventDefault(); document.getElementById('devon-input')?.focus(); break; } }); // Type button document.getElementById('type-btn')?.addEventListener('click', () => { document.getElementById('type-modal')?.classList.remove('hidden'); document.getElementById('type-input')?.focus(); }); document.getElementById('send-type')?.addEventListener('click', sendTypedMessage); document.getElementById('close-type')?.addEventListener('click', () => { document.getElementById('type-modal')?.classList.add('hidden'); }); document.getElementById('type-input')?.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendTypedMessage(); } }); // Music - now server-side document.getElementById('stop-btn')?.addEventListener('click', stopMusic); document.getElementById('volume')?.addEventListener('input', setMusicVolume); // Ads document.getElementById('ad-play-btn')?.addEventListener('click', playAd); document.getElementById('ad-stop-btn')?.addEventListener('click', stopAd); // Idents document.getElementById('ident-play-btn')?.addEventListener('click', playIdent); document.getElementById('ident-stop-btn')?.addEventListener('click', stopIdent); // Devon (Intern) document.getElementById('devon-ask-btn')?.addEventListener('click', () => { const input = document.getElementById('devon-input'); if (input?.value.trim()) { askDevon(input.value.trim()); input.value = ''; } }); document.getElementById('devon-input')?.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); const input = e.target; if (input.value.trim()) { askDevon(input.value.trim()); input.value = ''; } } else if (e.key === 'Escape') { e.target.blur(); } }); document.getElementById('devon-interject-btn')?.addEventListener('click', interjectDevon); document.getElementById('devon-monitor')?.addEventListener('change', e => { toggleInternMonitor(e.target.checked); }); document.getElementById('devon-play-btn')?.addEventListener('click', playDevonSuggestion); document.getElementById('devon-dismiss-btn')?.addEventListener('click', dismissDevonSuggestion); // Show Theme document.getElementById('set-theme-btn')?.addEventListener('click', setShowTheme); document.getElementById('clear-theme-btn')?.addEventListener('click', clearShowTheme); document.getElementById('show-theme-input')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') setShowTheme(); else if (e.key === 'Escape') e.target.blur(); }); // Settings document.getElementById('settings-btn')?.addEventListener('click', async () => { document.getElementById('settings-modal')?.classList.remove('hidden'); await loadSettings(); // Reload settings when modal opens }); document.getElementById('close-settings')?.addEventListener('click', () => { document.getElementById('settings-modal')?.classList.add('hidden'); }); document.getElementById('save-settings')?.addEventListener('click', saveSettings); document.getElementById('provider')?.addEventListener('change', updateProviderUI); document.getElementById('phone-filter')?.addEventListener('change', e => { phoneFilter = e.target.checked; }); document.getElementById('refresh-ollama')?.addEventListener('click', refreshOllamaModels); // Wrap-up button document.getElementById('wrapup-btn')?.addEventListener('click', wrapUp); // Real caller hangup document.getElementById('hangup-real-btn')?.addEventListener('click', async () => { try { await fetch('/api/hangup/real', { method: 'POST' }); hideRealCaller(); log('Real caller disconnected'); } catch (err) { log('Failed to hang up real caller: ' + err.message); } }); // AI caller hangup (small button in AI caller panel) document.getElementById('hangup-ai-btn')?.addEventListener('click', hangup); // AI respond button (manual trigger) document.getElementById('ai-respond-btn')?.addEventListener('click', triggerAiRespond); // Start conversation update polling startConversationPolling(); // AI respond mode toggle document.getElementById('mode-manual')?.addEventListener('click', async () => { document.getElementById('mode-manual')?.classList.add('active'); document.getElementById('mode-auto')?.classList.remove('active'); document.getElementById('ai-respond-btn')?.classList.remove('hidden'); try { await fetch('/api/session/ai-mode', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: 'manual' }), }); } catch (err) { log('Failed to set manual mode'); } }); document.getElementById('mode-auto')?.addEventListener('click', async () => { document.getElementById('mode-auto')?.classList.add('active'); document.getElementById('mode-manual')?.classList.remove('active'); try { await fetch('/api/session/ai-mode', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: 'auto' }), }); } catch (err) { log('Failed to set auto mode'); } }); // Auto follow-up toggle document.getElementById('auto-followup')?.addEventListener('change', async (e) => { try { await fetch('/api/session/auto-followup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: e.target.checked }), }); } catch (err) { log('Failed to toggle auto follow-up'); } }); } async function refreshOllamaModels() { const btn = document.getElementById('refresh-ollama'); const select = document.getElementById('ollama-model'); if (!select) return; btn.textContent = 'Loading...'; btn.disabled = true; try { const res = await fetch('/api/settings'); const data = await res.json(); select.innerHTML = ''; const models = data.available_ollama_models || []; if (models.length === 0) { const option = document.createElement('option'); option.value = ''; option.textContent = '(No models found)'; select.appendChild(option); } else { models.forEach(model => { const option = document.createElement('option'); option.value = model; option.textContent = model; select.appendChild(option); }); } } catch (err) { console.error('Failed to refresh Ollama models:', err); } btn.textContent = 'Refresh Models'; btn.disabled = false; } // --- Audio Devices --- async function loadAudioDevices() { try { const res = await fetch('/api/audio/devices'); const data = await res.json(); const inputSelect = document.getElementById('input-device'); const outputSelect = document.getElementById('output-device'); if (!inputSelect || !outputSelect) return; // Clear selects inputSelect.innerHTML = ''; outputSelect.innerHTML = ''; data.devices.forEach(device => { // Input devices if (device.inputs > 0) { const opt = document.createElement('option'); opt.value = device.id; opt.textContent = `${device.name} (${device.inputs} ch)`; inputSelect.appendChild(opt); } // Output devices if (device.outputs > 0) { const opt = document.createElement('option'); opt.value = device.id; opt.textContent = `${device.name} (${device.outputs} ch)`; outputSelect.appendChild(opt); } }); // Load current settings const settingsRes = await fetch('/api/audio/settings'); const settings = await settingsRes.json(); if (settings.input_device !== null) inputSelect.value = settings.input_device; if (settings.output_device !== null) outputSelect.value = settings.output_device; // Channel settings const inputCh = document.getElementById('input-channel'); const callerCh = document.getElementById('caller-channel'); const devonCh = document.getElementById('devon-channel'); const liveCallerCh = document.getElementById('live-caller-channel'); const musicCh = document.getElementById('music-channel'); const sfxCh = document.getElementById('sfx-channel'); const adCh = document.getElementById('ad-channel'); const identCh = document.getElementById('ident-channel'); if (inputCh) inputCh.value = settings.input_channel || 1; if (callerCh) callerCh.value = settings.caller_channel || 1; if (devonCh) devonCh.value = settings.devon_channel || 17; if (liveCallerCh) liveCallerCh.value = settings.live_caller_channel || 9; if (musicCh) musicCh.value = settings.music_channel || 2; if (sfxCh) sfxCh.value = settings.sfx_channel || 3; if (adCh) adCh.value = settings.ad_channel || 11; if (identCh) identCh.value = settings.ident_channel || 15; // Phone filter setting const phoneFilterEl = document.getElementById('phone-filter'); if (phoneFilterEl) { phoneFilterEl.checked = settings.phone_filter ?? false; phoneFilter = phoneFilterEl.checked; } console.log('Audio devices loaded'); } catch (err) { console.error('loadAudioDevices error:', err); } } async function saveAudioDevices() { const inputDevice = document.getElementById('input-device')?.value; const outputDevice = document.getElementById('output-device')?.value; const inputChannel = document.getElementById('input-channel')?.value; const callerChannel = document.getElementById('caller-channel')?.value; const devonChannel = document.getElementById('devon-channel')?.value; const liveCallerChannel = document.getElementById('live-caller-channel')?.value; const musicChannel = document.getElementById('music-channel')?.value; const sfxChannel = document.getElementById('sfx-channel')?.value; const adChannel = document.getElementById('ad-channel')?.value; const identChannel = document.getElementById('ident-channel')?.value; const phoneFilterChecked = document.getElementById('phone-filter')?.checked ?? false; await fetch('/api/audio/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ input_device: inputDevice ? parseInt(inputDevice) : null, input_channel: inputChannel ? parseInt(inputChannel) : 1, output_device: outputDevice ? parseInt(outputDevice) : null, caller_channel: callerChannel ? parseInt(callerChannel) : 1, devon_channel: devonChannel ? parseInt(devonChannel) : 17, live_caller_channel: liveCallerChannel ? parseInt(liveCallerChannel) : 9, music_channel: musicChannel ? parseInt(musicChannel) : 2, sfx_channel: sfxChannel ? parseInt(sfxChannel) : 3, ad_channel: adChannel ? parseInt(adChannel) : 11, ident_channel: identChannel ? parseInt(identChannel) : 15, phone_filter: phoneFilterChecked }) }); // Update local state phoneFilter = phoneFilterChecked; log('Audio routing saved'); } // --- Callers --- async function loadCallers() { try { const res = await fetch('/api/callers'); const data = await res.json(); const grid = document.getElementById('callers'); if (!grid) return; grid.innerHTML = ''; data.callers.forEach((caller, idx) => { const btn = document.createElement('button'); btn.className = 'caller-btn'; if (caller.returning) btn.classList.add('returning'); btn.dataset.key = caller.key; let html = ''; html += caller.returning ? `\u2605 ${caller.name}` : `${caller.name}`; // Shortcut label: 1-9 for first 9, 0 for 10th if (idx < 10) { const shortcutKey = idx === 9 ? '0' : String(idx + 1); html += `${shortcutKey}`; } btn.innerHTML = html; btn.addEventListener('click', () => startCall(caller.key, caller.name)); grid.appendChild(btn); }); // Show session ID const sessionEl = document.getElementById('session-id'); if (sessionEl && data.session_id) { sessionEl.textContent = `(${data.session_id})`; } // Lineup readiness — an empty lineup means callers have no identity and // every call collapses into generic filler, so make it visible pre-show. const lineupEl = document.getElementById('lineup-status'); if (lineupEl) { if (data.lineup_ready) { lineupEl.textContent = `${data.lineup_count} ready`; lineupEl.className = 'lineup-status ready'; lineupEl.title = 'Caller identities are loaded'; } else { lineupEl.textContent = 'no lineup'; lineupEl.className = 'lineup-status empty'; lineupEl.title = 'No caller identities loaded — hit Reset Session before going live'; } } console.log('Loaded', data.callers.length, 'callers, session:', data.session_id, 'lineup:', data.lineup_count); } catch (err) { console.error('loadCallers error:', err); } } async function startCall(key, name) { if (isProcessing) return; let data; try { const res = await fetch(`/api/call/${key}`, { method: 'POST' }); if (!res.ok) { const err = await res.text().catch(() => ''); log(`Failed to start call with ${name}: ${err || res.status}`); return; } data = await res.json(); } catch (err) { log(`Failed to start call with ${name}: ${err.message}`); return; } currentCaller = { key, name }; document.querySelector('.callers-section')?.classList.add('call-active'); document.querySelector('.chat-section')?.classList.add('call-active'); // Highlight active caller button immediately document.querySelectorAll('.caller-btn').forEach(btn => { const isActive = btn.dataset.key === key; btn.classList.toggle('active', isActive); if (isActive) { btn.style.outline = '2px solid #5a8a3c'; const nameEl = btn.querySelector('.caller-name'); if (nameEl) { nameEl.style.background = '#e8791d'; nameEl.style.color = '#fff'; nameEl.style.padding = '2px 8px'; nameEl.style.borderRadius = '4px'; } } else { btn.style.outline = ''; const nameEl = btn.querySelector('.caller-name'); if (nameEl) { nameEl.style.background = ''; nameEl.style.color = ''; nameEl.style.padding = ''; nameEl.style.borderRadius = ''; } } }); // Check if real caller is active (three-way scenario) const realCallerActive = document.getElementById('real-caller-info') && !document.getElementById('real-caller-info').classList.contains('hidden'); if (realCallerActive) { document.getElementById('call-status').textContent = `Three-way: ${name} (AI) + Real Caller`; } else { document.getElementById('call-status').textContent = `On call: ${name}`; } document.getElementById('hangup-btn').disabled = false; const wrapupBtn = document.getElementById('wrapup-btn'); if (wrapupBtn) { wrapupBtn.disabled = false; wrapupBtn.classList.remove('active'); } // Show AI caller in active call indicator const aiInfo = document.getElementById('ai-caller-info'); const aiName = document.getElementById('ai-caller-name'); if (aiInfo) aiInfo.classList.remove('hidden'); if (aiName) aiName.textContent = name; // Show caller info panel with slim background data const infoPanel = document.getElementById('caller-info-panel'); if (infoPanel && data.caller_info) { const ci = data.caller_info; const identity = document.getElementById('caller-identity'); const situation = document.getElementById('caller-situation'); const signature = document.getElementById('caller-signature'); const secretWant = document.getElementById('caller-secret-want'); if (identity) identity.textContent = ci.identity || ''; if (situation) situation.textContent = ci.situation || ''; if (signature) signature.textContent = ci.signature ? `"${ci.signature}"` : ''; if (secretWant) secretWant.textContent = ci.secret_want ? `secretly wants: ${ci.secret_want}` : ''; infoPanel.classList.remove('hidden'); } const bgEl = document.getElementById('caller-background'); if (bgEl && data.background) bgEl.textContent = data.background; let matchCount = 0; document.querySelectorAll('.caller-btn').forEach(btn => { const isActive = btn.dataset.key === key; btn.classList.toggle('active', isActive); if (isActive) { btn.style.outline = '2px solid #5a8a3c'; matchCount++; } else { btn.style.outline = ''; } const nameEl = btn.querySelector('.caller-name'); if (nameEl) { nameEl.style.background = isActive ? '#e8791d' : ''; nameEl.style.color = isActive ? '#fff' : ''; nameEl.style.padding = isActive ? '2px 8px' : ''; nameEl.style.borderRadius = isActive ? '4px' : ''; } }); console.log(`[ActiveCaller] key=${key}, matched=${matchCount} buttons`); log(`Connected to ${name}` + (realCallerActive ? ' (three-way)' : '')); if (!realCallerActive) clearChat(); updateActiveCallIndicator(); } async function newSession() { // Hangup if on a call if (currentCaller) { await hangup(); } const btn = document.getElementById('new-session-btn'); const originalText = btn?.textContent; if (btn) { btn.disabled = true; btn.textContent = 'Generating...'; } try { await fetch('/api/session/reset', { method: 'POST' }); conversationSince = 0; // Hide caller background const bgDetails = document.getElementById('caller-background-details'); if (bgDetails) bgDetails.classList.add('hidden'); // Reload callers to get new session ID await loadCallers(); await loadShowTheme(); log('New session started - all callers have fresh backgrounds'); } finally { if (btn) { btn.disabled = false; btn.textContent = originalText || 'New Session'; } } } async function hangup() { if (!currentCaller) return; await fetch('/api/tts/stop', { method: 'POST' }); await fetch('/api/hangup', { method: 'POST' }); log(`Hung up on ${currentCaller.name}`); currentCaller = null; isProcessing = false; hideStatus(); document.querySelector('.callers-section')?.classList.remove('call-active'); document.querySelector('.chat-section')?.classList.remove('call-active'); document.getElementById('call-status').textContent = 'No active call'; document.getElementById('hangup-btn').disabled = true; const wrapBtn = document.getElementById('wrapup-btn'); if (wrapBtn) { wrapBtn.disabled = true; wrapBtn.classList.remove('active'); } document.querySelectorAll('.caller-btn').forEach(btn => { btn.classList.remove('active'); const nameEl = btn.querySelector('.caller-name'); if (nameEl) { nameEl.style.background = ''; nameEl.style.color = ''; nameEl.style.padding = ''; nameEl.style.borderRadius = ''; } }); // Hide caller info panel and background document.getElementById('caller-info-panel')?.classList.add('hidden'); const bgDetails2 = document.getElementById('caller-background-details'); if (bgDetails2) bgDetails2.classList.add('hidden'); // Hide AI caller indicator document.getElementById('ai-caller-info')?.classList.add('hidden'); updateActiveCallIndicator(); } async function wrapUp() { if (!currentCaller) return; try { const res = await fetch('/api/wrap-up', { method: 'POST' }); if (!res.ok) { const err = await res.json().catch(() => ({})); log(`Wrap-up failed: ${err.detail || res.status}`); return; } const wrapupBtn = document.getElementById('wrapup-btn'); if (wrapupBtn) wrapupBtn.classList.add('active'); log(`Wrapping up ${currentCaller.name}...`); } catch (err) { log(`Wrap-up error: ${err.message}`); } } function toggleMusic() { if (isMusicPlaying) { stopMusic(); } else { playMusic(); } } // --- Server-Side Recording --- async function startRecording() { if (isProcessing) return; try { const res = await fetch('/api/record/start', { method: 'POST' }); if (!res.ok) { const err = await res.json(); log('Record error: ' + (err.detail || 'Failed to start')); return; } isRecording = true; document.getElementById('talk-btn').classList.add('recording'); document.getElementById('talk-btn').textContent = 'Recording...'; } catch (err) { log('Record error: ' + err.message); } } async function stopRecording() { if (!isRecording) return; document.getElementById('talk-btn').classList.remove('recording'); document.getElementById('talk-btn').textContent = 'Hold to Talk'; isRecording = false; isProcessing = true; showStatus('Processing...'); try { // Stop recording and get transcription const data = await safeFetch('/api/record/stop', { method: 'POST' }); if (!data.text) { log('(No speech detected)'); isProcessing = false; hideStatus(); return; } addMessage('You', data.text); if (!currentCaller) { // No active call — route voice to Devon showStatus('Devon is thinking...'); await askDevon(data.text, { skipHostMessage: true }); } else { // Active call — talk to caller showStatus(`${currentCaller.name} is thinking...`); const chatData = await safeFetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: data.text }) }); // If routed to Devon, the SSE broadcast handles the message if (chatData.routed_to !== 'devon') { addMessage(chatData.caller, chatData.text); } // TTS (plays on server) - only if we have text and not routed to Devon if (chatData.text && chatData.text.trim() && chatData.routed_to !== 'devon') { showStatus(`${currentCaller.name} is speaking...`); await safeFetch('/api/tts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: chatData.text, voice_id: chatData.voice_id, phone_filter: phoneFilter }) }); } } } catch (err) { log('Error: ' + err.message); } isProcessing = false; hideStatus(); } async function sendTypedMessage() { const input = document.getElementById('type-input'); const text = input.value.trim(); if (!text || isProcessing) return; input.value = ''; document.getElementById('type-modal').classList.add('hidden'); isProcessing = true; addMessage('You', text); try { if (!currentCaller) { // No active call — route to Devon showStatus('Devon is thinking...'); await askDevon(text, { skipHostMessage: true }); } else { showStatus(`${currentCaller.name} is thinking...`); const chatData = await safeFetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text }) }); if (chatData.routed_to !== 'devon') { addMessage(chatData.caller, chatData.text); } // TTS (plays on server) - only if we have text and not routed to Devon if (chatData.text && chatData.text.trim() && chatData.routed_to !== 'devon') { showStatus(`${currentCaller.name} is speaking...`); await safeFetch('/api/tts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: chatData.text, voice_id: chatData.voice_id, phone_filter: phoneFilter }) }); } } } catch (err) { log('Error: ' + err.message); } isProcessing = false; hideStatus(); } // --- Music (Server-Side) --- let genreMap = {}; // { genre: [track, ...] } let genreQueues = {}; // { genre: [shuffled track indices...] } let activeGenre = null; let currentTrackName = ''; async function loadMusic() { try { const res = await fetch('/api/music'); const data = await res.json(); tracks = data.tracks || []; // Group tracks by genre genreMap = {}; tracks.forEach(track => { const genre = track.genre || 'Other'; if (!genreMap[genre]) genreMap[genre] = []; genreMap[genre].push(track); }); // Sort genre names, "Other" last const genreOrder = Object.keys(genreMap).sort((a, b) => { if (a === 'Other') return 1; if (b === 'Other') return -1; return a.localeCompare(b); }); // Build genre buttons const container = document.getElementById('genre-buttons'); if (!container) return; container.innerHTML = ''; genreOrder.forEach(genre => { const btn = document.createElement('button'); btn.className = 'genre-btn'; btn.textContent = genre; btn.dataset.genre = genre; btn.addEventListener('click', () => playGenre(genre)); container.appendChild(btn); }); console.log('Loaded', tracks.length, 'tracks in', genreOrder.length, 'genres'); } catch (err) { console.error('loadMusic error:', err); } } function getNextTrack(genre) { const genreTracks = genreMap[genre]; if (!genreTracks || genreTracks.length === 0) return null; // Refill and shuffle queue when empty if (!genreQueues[genre] || genreQueues[genre].length === 0) { const indices = genreTracks.map((_, i) => i); for (let i = indices.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [indices[i], indices[j]] = [indices[j], indices[i]]; } genreQueues[genre] = indices; } return genreTracks[genreQueues[genre].shift()]; } async function playGenre(genre) { const track = getNextTrack(genre); if (!track) return; try { const res = await fetch('/api/music/play', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ track: track.file, action: 'play' }) }); if (!res.ok) throw new Error(res.status); isMusicPlaying = true; activeGenre = genre; currentTrackName = track.name; updateMusicUI(); } catch (err) { log('Music play failed: ' + err.message); } } async function playMusic() { // M key toggle — if nothing playing, pick random genre if (!activeGenre) { const genres = Object.keys(genreMap); if (genres.length === 0) { await loadMusic(); const g = Object.keys(genreMap); if (g.length === 0) return; return playGenre(g[Math.floor(Math.random() * g.length)]); } return playGenre(genres[Math.floor(Math.random() * genres.length)]); } return playGenre(activeGenre); } async function stopMusic() { try { const res = await fetch('/api/music/stop', { method: 'POST' }); if (!res.ok) throw new Error(res.status); isMusicPlaying = false; activeGenre = null; currentTrackName = ''; updateMusicUI(); } catch (err) { log('Music stop failed: ' + err.message); } } function updateMusicUI() { // Highlight active genre button document.querySelectorAll('.genre-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.genre === activeGenre); }); // Show/hide now playing bar const nowPlaying = document.getElementById('now-playing'); const nowText = document.getElementById('now-playing-text'); if (nowPlaying && nowText) { if (isMusicPlaying && currentTrackName) { nowText.textContent = currentTrackName; nowPlaying.classList.remove('hidden'); } else { nowPlaying.classList.add('hidden'); } } } let _volumeDebounce = null; function setMusicVolume(e) { const volume = e.target.value / 100; clearTimeout(_volumeDebounce); _volumeDebounce = setTimeout(async () => { try { await fetch('/api/music/volume', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ track: '', action: 'volume', volume }) }); } catch (err) { log('Volume change failed'); } }, 150); } async function loadAds() { try { const res = await fetch('/api/ads'); const data = await res.json(); const ads = data.ads || []; const select = document.getElementById('ad-select'); if (!select) return; const previousValue = select.value; select.innerHTML = ''; ads.forEach(ad => { const option = document.createElement('option'); option.value = ad.file; option.textContent = ad.name; select.appendChild(option); }); if (previousValue && [...select.options].some(o => o.value === previousValue)) { select.value = previousValue; } console.log('Loaded', ads.length, 'ads'); } catch (err) { console.error('loadAds error:', err); } } async function playAd() { await loadAds(); const select = document.getElementById('ad-select'); const track = select?.value; if (!track) return; try { const res = await fetch('/api/ads/play', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ track, action: 'play' }) }); if (!res.ok) throw new Error(res.status); } catch (err) { log('Ad play failed: ' + err.message); } } async function stopAd() { try { await fetch('/api/ads/stop', { method: 'POST' }); } catch (err) { log('Ad stop failed: ' + err.message); } } async function loadIdents() { try { const res = await fetch('/api/idents'); const data = await res.json(); const idents = data.idents || []; const select = document.getElementById('ident-select'); if (!select) return; const previousValue = select.value; select.innerHTML = ''; idents.forEach(ident => { const option = document.createElement('option'); option.value = ident.file; option.textContent = ident.name; select.appendChild(option); }); if (previousValue && [...select.options].some(o => o.value === previousValue)) { select.value = previousValue; } console.log('Loaded', idents.length, 'idents'); } catch (err) { console.error('loadIdents error:', err); } } async function playIdent() { await loadIdents(); const select = document.getElementById('ident-select'); const track = select?.value; if (!track) return; try { const res = await fetch('/api/idents/play', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ track, action: 'play' }) }); if (!res.ok) throw new Error(res.status); } catch (err) { log('Ident play failed: ' + err.message); } } async function stopIdent() { try { await fetch('/api/idents/stop', { method: 'POST' }); } catch (err) { log('Ident stop failed: ' + err.message); } } // --- Sound Effects (Server-Side) --- async function loadSounds() { try { const res = await fetch('/api/sounds'); const data = await res.json(); sounds = data.sounds || []; const board = document.getElementById('soundboard'); if (!board) return; board.innerHTML = ''; const pinnedNames = ['cheer', 'applause', 'boo', 'correct']; const pinned = []; const rest = []; sounds.forEach(sound => { const lower = ((sound.name || '') + ' ' + (sound.file || '')).toLowerCase(); if (pinnedNames.some(p => lower.includes(p))) { pinned.push(sound); } else { rest.push(sound); } }); // Pinned buttons — always visible const pinnedRow = document.createElement('div'); pinnedRow.className = 'soundboard-pinned'; pinned.forEach(sound => { const btn = document.createElement('button'); btn.className = 'sound-btn pinned'; const lower = (sound.name || sound.file || '').toLowerCase(); if (lower.includes('cheer')) btn.classList.add('pin-cheer'); else if (lower.includes('applause')) btn.classList.add('pin-applause'); else if (lower.includes('boo')) btn.classList.add('pin-boo'); btn.textContent = sound.name; btn.addEventListener('click', () => playSFX(sound.file)); pinnedRow.appendChild(btn); }); board.appendChild(pinnedRow); // Collapsible section for remaining sounds if (rest.length > 0) { const toggle = document.createElement('button'); toggle.className = 'soundboard-toggle'; toggle.innerHTML = 'More Sounds ▼'; toggle.addEventListener('click', () => { soundboardExpanded = !soundboardExpanded; grid.classList.toggle('hidden', !soundboardExpanded); toggle.querySelector('.toggle-arrow').innerHTML = soundboardExpanded ? '▲' : '▼'; }); board.appendChild(toggle); const grid = document.createElement('div'); grid.className = 'soundboard-grid hidden'; rest.forEach(sound => { const btn = document.createElement('button'); btn.className = 'sound-btn'; btn.textContent = sound.name; btn.addEventListener('click', () => playSFX(sound.file)); grid.appendChild(btn); }); board.appendChild(grid); } console.log('Loaded', sounds.length, 'sounds', `(${pinned.length} pinned)`); } catch (err) { console.error('loadSounds error:', err); } } async function playSFX(soundFile) { try { const res = await fetch('/api/sfx/play', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sound: soundFile }) }); if (!res.ok) throw new Error(res.status); } catch (err) { log('SFX play failed: ' + err.message); } } // --- Show Theme --- async function loadShowTheme() { try { const res = await fetch('/api/show-theme'); const data = await res.json(); const input = document.getElementById('show-theme-input'); const setBtn = document.getElementById('set-theme-btn'); const clearBtn = document.getElementById('clear-theme-btn'); if (data.theme) { input.value = data.theme; input.classList.add('active'); setBtn.classList.add('hidden'); clearBtn.classList.remove('hidden'); } else { input.value = ''; input.classList.remove('active'); setBtn.classList.remove('hidden'); clearBtn.classList.add('hidden'); } } catch (e) { console.error('Failed to load show theme:', e); } } async function setShowTheme() { const input = document.getElementById('show-theme-input'); const setBtn = document.getElementById('set-theme-btn'); const theme = input.value.trim(); if (!theme) return; const originalText = setBtn?.textContent; if (setBtn) { setBtn.disabled = true; setBtn.textContent = 'Regenerating...'; } try { const res = await fetch('/api/show-theme', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ theme }) }); const data = await res.json(); if (data.theme) { input.classList.add('active'); setBtn?.classList.add('hidden'); document.getElementById('clear-theme-btn').classList.remove('hidden'); } // Backend regenerated backgrounds for unused callers — refresh the // button list so we show the new names, not the pre-theme cache. await loadCallers(); } catch (e) { console.error('Failed to set show theme:', e); } finally { if (setBtn) { setBtn.disabled = false; setBtn.textContent = originalText || 'Set'; } } } async function clearShowTheme() { try { await fetch('/api/show-theme', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ theme: '' }) }); const input = document.getElementById('show-theme-input'); input.value = ''; input.classList.remove('active'); document.getElementById('set-theme-btn').classList.remove('hidden'); document.getElementById('clear-theme-btn').classList.add('hidden'); await loadCallers(); } catch (e) { console.error('Failed to clear show theme:', e); } } // --- Settings --- async function loadSettings() { try { const res = await fetch('/api/settings'); const data = await res.json(); const providerEl = document.getElementById('provider'); if (providerEl) providerEl.value = data.provider || 'openrouter'; const modelSelect = document.getElementById('openrouter-model'); if (modelSelect) { modelSelect.innerHTML = ''; (data.available_openrouter_models || []).forEach(model => { const option = document.createElement('option'); option.value = model; option.textContent = model; if (model === data.openrouter_model) option.selected = true; modelSelect.appendChild(option); }); } const ollamaModel = document.getElementById('ollama-model'); const ollamaHost = document.getElementById('ollama-host'); if (ollamaHost) ollamaHost.value = data.ollama_host || 'http://localhost:11434'; // Populate Ollama models dropdown if (ollamaModel) { ollamaModel.innerHTML = ''; const ollamaModels = data.available_ollama_models || []; console.log('Ollama models from API:', ollamaModels.length, ollamaModels); if (ollamaModels.length === 0) { const option = document.createElement('option'); option.value = data.ollama_model || 'llama3.2'; option.textContent = data.ollama_model || 'llama3.2'; ollamaModel.appendChild(option); } else { ollamaModels.forEach(model => { const option = document.createElement('option'); option.value = model; option.textContent = model; if (model === data.ollama_model) option.selected = true; ollamaModel.appendChild(option); }); } console.log('Ollama dropdown options:', ollamaModel.options.length); } else { console.log('Ollama model element not found!'); } // TTS provider const ttsProvider = document.getElementById('tts-provider'); if (ttsProvider) ttsProvider.value = data.tts_provider || 'elevenlabs'; // Category model routing const models = data.available_openrouter_models || []; window._openrouterModels = models; const categoryModels = data.category_models || {}; const categories = ['caller_dialog', 'devon_monitor', 'devon_ask', 'background_gen', 'call_summary', 'news_summary']; for (const cat of categories) { const sel = document.getElementById(`model-${cat}`); if (!sel) continue; sel.innerHTML = ''; for (const m of models) { const opt = document.createElement('option'); opt.value = m; opt.textContent = m.split('/').pop(); if (m === (categoryModels[cat] || data.openrouter_model)) opt.selected = true; sel.appendChild(opt); } } updateProviderUI(); console.log('Settings loaded:', data.provider, 'TTS:', data.tts_provider); } catch (err) { console.error('loadSettings error:', err); } } function updateProviderUI() { // Kept for compatibility — provider toggle removed from UI } async function saveSettings() { try { // Save audio devices await saveAudioDevices(); // Collect category model routing const categoryModels = {}; const categories = ['caller_dialog', 'devon_monitor', 'devon_ask', 'background_gen', 'call_summary', 'news_summary']; for (const cat of categories) { const sel = document.getElementById(`model-${cat}`); if (sel) categoryModels[cat] = sel.value; } // Save LLM, TTS, and model routing settings const res = await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ provider: 'openrouter', openrouter_model: categoryModels.caller_dialog || document.getElementById('model-caller_dialog')?.value, tts_provider: document.getElementById('tts-provider')?.value, category_models: categoryModels }) }); if (!res.ok) throw new Error(res.status); document.getElementById('settings-modal')?.classList.add('hidden'); log('Settings saved'); } catch (err) { log('Settings save failed: ' + err.message); } } // --- UI Helpers --- function addMessage(sender, text) { const chat = document.getElementById('chat'); if (!chat) { console.log(`[${sender}]: ${text}`); return; } const div = document.createElement('div'); let className = 'message'; if (sender === 'You') { className += ' host'; } else if (sender === 'System') { className += ' system'; // System messages are compact — no avatar, small text div.className = className; div.innerHTML = `