From b0643d6082d2ca888472e6cd590d0b33550f7f2b Mon Sep 17 00:00:00 2001 From: tcpsyn Date: Fri, 6 Feb 2026 01:00:41 -0700 Subject: [PATCH] Add recording diagnostics and refresh music list on play Co-Authored-By: Claude Opus 4.6 --- backend/services/audio.py | 37 ++++++++++-- frontend/js/app.js | 121 +++++++++++++++++++++++++++++++++++--- 2 files changed, 145 insertions(+), 13 deletions(-) diff --git a/backend/services/audio.py b/backend/services/audio.py index 4d04186..55cf705 100644 --- a/backend/services/audio.py +++ b/backend/services/audio.py @@ -202,7 +202,17 @@ class AudioService: time.sleep(0.05) if not self._recorded_audio: - print(f"Recording stopped: NO audio chunks captured (piggyback={self._host_stream is not None})") + piggyback = self._host_stream is not None + # Check what other streams might be active + active_streams = [] + if self._music_stream: + active_streams.append("music") + if self._live_caller_stream: + active_streams.append("live_caller") + if self._host_stream: + active_streams.append("host") + streams_info = f", active_streams=[{','.join(active_streams)}]" if active_streams else "" + print(f"Recording stopped: NO audio chunks captured (piggyback={piggyback}, device={self.input_device}, ch={self.input_channel}{streams_info})") return b"" # Combine all chunks @@ -228,17 +238,28 @@ class AudioService: device_sr = int(device_info['default_samplerate']) record_channel = min(self.input_channel, max_channels) - 1 + if max_channels == 0: + print(f"Recording error: device {self.input_device} has no input channels") + self._recording = False + return + # Store device sample rate for later resampling self._record_device_sr = device_sr - print(f"Recording from device {self.input_device} ch {self.input_channel} @ {device_sr}Hz") + stream_ready = threading.Event() + callback_count = [0] def callback(indata, frames, time_info, status): if status: print(f"Record status: {status}") + callback_count[0] += 1 + if not stream_ready.is_set(): + stream_ready.set() if self._recording: self._recorded_audio.append(indata[:, record_channel].copy()) + print(f"Recording: opening stream on device {self.input_device} ch {self.input_channel} @ {device_sr}Hz ({max_channels} ch)") + with sd.InputStream( device=self.input_device, channels=max_channels, @@ -247,11 +268,19 @@ class AudioService: callback=callback, blocksize=1024 ): + # Wait for stream to actually start capturing + if not stream_ready.wait(timeout=1.0): + print(f"Recording WARNING: stream opened but callback not firing after 1s") + while self._recording: time.sleep(0.05) + print(f"Recording: stream closed, {callback_count[0]} callbacks fired, {len(self._recorded_audio)} chunks captured") + except Exception as e: print(f"Recording error: {e}") + import traceback + traceback.print_exc() self._recording = False # --- Caller TTS Playback --- @@ -463,10 +492,10 @@ class AudioService: record_channel = min(self.input_channel, max_channels) - 1 step = max(1, int(device_sr / 16000)) - # Buffer host mic to send ~60ms chunks instead of tiny 21ms ones + # Buffer host mic to send ~100ms chunks (reduces WebSocket frame rate) host_accum = [] host_accum_samples = [0] - send_threshold = 960 # 60ms at 16kHz + send_threshold = 1600 # 100ms at 16kHz def callback(indata, frames, time_info, status): # Capture for push-to-talk recording if active diff --git a/frontend/js/app.js b/frontend/js/app.js index 395e098..e2a56a2 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -16,6 +16,21 @@ let tracks = []; let sounds = []; +// --- Safe JSON parsing --- +async function safeFetch(url, options = {}) { + const res = await fetch(url, options); + 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); +} + + // --- Init --- document.addEventListener('DOMContentLoaded', async () => { console.log('AI Radio Show initializing...'); @@ -108,6 +123,15 @@ function initEventListeners() { log('Real caller disconnected'); }); + // 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', () => { document.getElementById('mode-manual')?.classList.add('active'); @@ -123,7 +147,6 @@ function initEventListeners() { document.getElementById('mode-auto')?.addEventListener('click', () => { document.getElementById('mode-auto')?.classList.add('active'); document.getElementById('mode-manual')?.classList.remove('active'); - document.getElementById('ai-respond-btn')?.classList.add('hidden'); fetch('/api/session/ai-mode', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -362,6 +385,7 @@ async function newSession() { } await fetch('/api/session/reset', { method: 'POST' }); + conversationSince = 0; // Hide caller background const bgEl = document.getElementById('caller-background'); @@ -434,8 +458,7 @@ async function stopRecording() { try { // Stop recording and get transcription - const res = await fetch('/api/record/stop', { method: 'POST' }); - const data = await res.json(); + const data = await safeFetch('/api/record/stop', { method: 'POST' }); if (!data.text) { log('(No speech detected)'); @@ -449,12 +472,11 @@ async function stopRecording() { // Chat showStatus(`${currentCaller.name} is thinking...`); - const chatRes = await fetch('/api/chat', { + const chatData = await safeFetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: data.text }) }); - const chatData = await chatRes.json(); addMessage(chatData.caller, chatData.text); @@ -462,7 +484,7 @@ async function stopRecording() { if (chatData.text && chatData.text.trim()) { showStatus(`${currentCaller.name} is speaking...`); - await fetch('/api/tts', { + await safeFetch('/api/tts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -496,12 +518,11 @@ async function sendTypedMessage() { try { showStatus(`${currentCaller.name} is thinking...`); - const chatRes = await fetch('/api/chat', { + const chatData = await safeFetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text }) }); - const chatData = await chatRes.json(); addMessage(chatData.caller, chatData.text); @@ -509,7 +530,7 @@ async function sendTypedMessage() { if (chatData.text && chatData.text.trim()) { showStatus(`${currentCaller.name} is speaking...`); - await fetch('/api/tts', { + await safeFetch('/api/tts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -538,6 +559,8 @@ async function loadMusic() { const select = document.getElementById('track-select'); if (!select) return; + + const previousValue = select.value; select.innerHTML = ''; tracks.forEach((track, i) => { @@ -546,6 +569,12 @@ async function loadMusic() { option.textContent = track.name; select.appendChild(option); }); + + // Restore previous selection if it still exists + if (previousValue && [...select.options].some(o => o.value === previousValue)) { + select.value = previousValue; + } + console.log('Loaded', tracks.length, 'tracks'); } catch (err) { console.error('loadMusic error:', err); @@ -554,6 +583,7 @@ async function loadMusic() { async function playMusic() { + await loadMusic(); const select = document.getElementById('track-select'); const track = select?.value; if (!track) return; @@ -894,6 +924,19 @@ async function takeCall(callerId) { if (data.status === 'on_air') { showRealCaller(data.caller); log(`${data.caller.phone} is on air — Channel ${data.caller.channel}`); + + // Auto-select an AI caller if none is active + if (!currentCaller) { + const callerBtns = document.querySelectorAll('.caller-btn'); + if (callerBtns.length > 0) { + const randomIdx = Math.floor(Math.random() * callerBtns.length); + const btn = callerBtns[randomIdx]; + const key = btn.dataset.key; + const name = btn.textContent; + log(`Auto-selecting ${name} as AI caller`); + await startCall(key, name); + } + } } } catch (err) { log('Failed to take call: ' + err.message); @@ -962,6 +1005,66 @@ function hideRealCaller() { } +// --- AI Respond (manual trigger) --- +async function triggerAiRespond() { + if (!currentCaller) { + log('No AI caller active — click a caller first'); + return; + } + + const btn = document.getElementById('ai-respond-btn'); + if (btn) { btn.disabled = true; btn.textContent = 'Thinking...'; } + showStatus(`${currentCaller.name} is thinking...`); + + try { + const data = await safeFetch('/api/ai-respond', { method: 'POST' }); + if (data.text) { + addMessage(data.caller, data.text); + showStatus(`${data.caller} is speaking...`); + const duration = data.text.length * 60; + setTimeout(hideStatus, Math.min(duration, 15000)); + } + } catch (err) { + log('AI respond error: ' + err.message); + } + + if (btn) { btn.disabled = false; btn.textContent = 'Let them respond'; } +} + + +// --- Conversation Update Polling --- +let conversationSince = 0; + +function startConversationPolling() { + setInterval(fetchConversationUpdates, 1000); +} + +async function fetchConversationUpdates() { + try { + const res = await fetch(`/api/conversation/updates?since=${conversationSince}`); + const data = await res.json(); + if (data.messages && data.messages.length > 0) { + for (const msg of data.messages) { + conversationSince = msg.id + 1; + if (msg.type === 'caller_disconnected') { + hideRealCaller(); + log(`${msg.phone} disconnected (${msg.reason})`); + } else if (msg.type === 'chat') { + addMessage(msg.sender, msg.text); + } else if (msg.type === 'ai_status') { + showStatus(msg.text); + } else if (msg.type === 'ai_done') { + hideStatus(); + } else if (msg.type === 'caller_queued') { + // Queue poll will pick this up, just ensure it refreshes + fetchQueue(); + } + } + } + } catch (err) {} +} + + async function stopServer() { if (!confirm('Stop the server? You will need to restart it manually.')) return;