Add recording diagnostics and refresh music list on play
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -202,7 +202,17 @@ class AudioService:
|
|||||||
time.sleep(0.05)
|
time.sleep(0.05)
|
||||||
|
|
||||||
if not self._recorded_audio:
|
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""
|
return b""
|
||||||
|
|
||||||
# Combine all chunks
|
# Combine all chunks
|
||||||
@@ -228,17 +238,28 @@ class AudioService:
|
|||||||
device_sr = int(device_info['default_samplerate'])
|
device_sr = int(device_info['default_samplerate'])
|
||||||
record_channel = min(self.input_channel, max_channels) - 1
|
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
|
# Store device sample rate for later resampling
|
||||||
self._record_device_sr = device_sr
|
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):
|
def callback(indata, frames, time_info, status):
|
||||||
if status:
|
if status:
|
||||||
print(f"Record status: {status}")
|
print(f"Record status: {status}")
|
||||||
|
callback_count[0] += 1
|
||||||
|
if not stream_ready.is_set():
|
||||||
|
stream_ready.set()
|
||||||
if self._recording:
|
if self._recording:
|
||||||
self._recorded_audio.append(indata[:, record_channel].copy())
|
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(
|
with sd.InputStream(
|
||||||
device=self.input_device,
|
device=self.input_device,
|
||||||
channels=max_channels,
|
channels=max_channels,
|
||||||
@@ -247,11 +268,19 @@ class AudioService:
|
|||||||
callback=callback,
|
callback=callback,
|
||||||
blocksize=1024
|
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:
|
while self._recording:
|
||||||
time.sleep(0.05)
|
time.sleep(0.05)
|
||||||
|
|
||||||
|
print(f"Recording: stream closed, {callback_count[0]} callbacks fired, {len(self._recorded_audio)} chunks captured")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Recording error: {e}")
|
print(f"Recording error: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
self._recording = False
|
self._recording = False
|
||||||
|
|
||||||
# --- Caller TTS Playback ---
|
# --- Caller TTS Playback ---
|
||||||
@@ -463,10 +492,10 @@ class AudioService:
|
|||||||
record_channel = min(self.input_channel, max_channels) - 1
|
record_channel = min(self.input_channel, max_channels) - 1
|
||||||
step = max(1, int(device_sr / 16000))
|
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 = []
|
||||||
host_accum_samples = [0]
|
host_accum_samples = [0]
|
||||||
send_threshold = 960 # 60ms at 16kHz
|
send_threshold = 1600 # 100ms at 16kHz
|
||||||
|
|
||||||
def callback(indata, frames, time_info, status):
|
def callback(indata, frames, time_info, status):
|
||||||
# Capture for push-to-talk recording if active
|
# Capture for push-to-talk recording if active
|
||||||
|
|||||||
@@ -16,6 +16,21 @@ let tracks = [];
|
|||||||
let sounds = [];
|
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 ---
|
// --- Init ---
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
console.log('AI Radio Show initializing...');
|
console.log('AI Radio Show initializing...');
|
||||||
@@ -108,6 +123,15 @@ function initEventListeners() {
|
|||||||
log('Real caller disconnected');
|
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
|
// AI respond mode toggle
|
||||||
document.getElementById('mode-manual')?.addEventListener('click', () => {
|
document.getElementById('mode-manual')?.addEventListener('click', () => {
|
||||||
document.getElementById('mode-manual')?.classList.add('active');
|
document.getElementById('mode-manual')?.classList.add('active');
|
||||||
@@ -123,7 +147,6 @@ function initEventListeners() {
|
|||||||
document.getElementById('mode-auto')?.addEventListener('click', () => {
|
document.getElementById('mode-auto')?.addEventListener('click', () => {
|
||||||
document.getElementById('mode-auto')?.classList.add('active');
|
document.getElementById('mode-auto')?.classList.add('active');
|
||||||
document.getElementById('mode-manual')?.classList.remove('active');
|
document.getElementById('mode-manual')?.classList.remove('active');
|
||||||
document.getElementById('ai-respond-btn')?.classList.add('hidden');
|
|
||||||
fetch('/api/session/ai-mode', {
|
fetch('/api/session/ai-mode', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -362,6 +385,7 @@ async function newSession() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await fetch('/api/session/reset', { method: 'POST' });
|
await fetch('/api/session/reset', { method: 'POST' });
|
||||||
|
conversationSince = 0;
|
||||||
|
|
||||||
// Hide caller background
|
// Hide caller background
|
||||||
const bgEl = document.getElementById('caller-background');
|
const bgEl = document.getElementById('caller-background');
|
||||||
@@ -434,8 +458,7 @@ async function stopRecording() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Stop recording and get transcription
|
// Stop recording and get transcription
|
||||||
const res = await fetch('/api/record/stop', { method: 'POST' });
|
const data = await safeFetch('/api/record/stop', { method: 'POST' });
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
if (!data.text) {
|
if (!data.text) {
|
||||||
log('(No speech detected)');
|
log('(No speech detected)');
|
||||||
@@ -449,12 +472,11 @@ async function stopRecording() {
|
|||||||
// Chat
|
// Chat
|
||||||
showStatus(`${currentCaller.name} is thinking...`);
|
showStatus(`${currentCaller.name} is thinking...`);
|
||||||
|
|
||||||
const chatRes = await fetch('/api/chat', {
|
const chatData = await safeFetch('/api/chat', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ text: data.text })
|
body: JSON.stringify({ text: data.text })
|
||||||
});
|
});
|
||||||
const chatData = await chatRes.json();
|
|
||||||
|
|
||||||
addMessage(chatData.caller, chatData.text);
|
addMessage(chatData.caller, chatData.text);
|
||||||
|
|
||||||
@@ -462,7 +484,7 @@ async function stopRecording() {
|
|||||||
if (chatData.text && chatData.text.trim()) {
|
if (chatData.text && chatData.text.trim()) {
|
||||||
showStatus(`${currentCaller.name} is speaking...`);
|
showStatus(`${currentCaller.name} is speaking...`);
|
||||||
|
|
||||||
await fetch('/api/tts', {
|
await safeFetch('/api/tts', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -496,12 +518,11 @@ async function sendTypedMessage() {
|
|||||||
try {
|
try {
|
||||||
showStatus(`${currentCaller.name} is thinking...`);
|
showStatus(`${currentCaller.name} is thinking...`);
|
||||||
|
|
||||||
const chatRes = await fetch('/api/chat', {
|
const chatData = await safeFetch('/api/chat', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ text })
|
body: JSON.stringify({ text })
|
||||||
});
|
});
|
||||||
const chatData = await chatRes.json();
|
|
||||||
|
|
||||||
addMessage(chatData.caller, chatData.text);
|
addMessage(chatData.caller, chatData.text);
|
||||||
|
|
||||||
@@ -509,7 +530,7 @@ async function sendTypedMessage() {
|
|||||||
if (chatData.text && chatData.text.trim()) {
|
if (chatData.text && chatData.text.trim()) {
|
||||||
showStatus(`${currentCaller.name} is speaking...`);
|
showStatus(`${currentCaller.name} is speaking...`);
|
||||||
|
|
||||||
await fetch('/api/tts', {
|
await safeFetch('/api/tts', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -538,6 +559,8 @@ async function loadMusic() {
|
|||||||
|
|
||||||
const select = document.getElementById('track-select');
|
const select = document.getElementById('track-select');
|
||||||
if (!select) return;
|
if (!select) return;
|
||||||
|
|
||||||
|
const previousValue = select.value;
|
||||||
select.innerHTML = '';
|
select.innerHTML = '';
|
||||||
|
|
||||||
tracks.forEach((track, i) => {
|
tracks.forEach((track, i) => {
|
||||||
@@ -546,6 +569,12 @@ async function loadMusic() {
|
|||||||
option.textContent = track.name;
|
option.textContent = track.name;
|
||||||
select.appendChild(option);
|
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');
|
console.log('Loaded', tracks.length, 'tracks');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('loadMusic error:', err);
|
console.error('loadMusic error:', err);
|
||||||
@@ -554,6 +583,7 @@ async function loadMusic() {
|
|||||||
|
|
||||||
|
|
||||||
async function playMusic() {
|
async function playMusic() {
|
||||||
|
await loadMusic();
|
||||||
const select = document.getElementById('track-select');
|
const select = document.getElementById('track-select');
|
||||||
const track = select?.value;
|
const track = select?.value;
|
||||||
if (!track) return;
|
if (!track) return;
|
||||||
@@ -894,6 +924,19 @@ async function takeCall(callerId) {
|
|||||||
if (data.status === 'on_air') {
|
if (data.status === 'on_air') {
|
||||||
showRealCaller(data.caller);
|
showRealCaller(data.caller);
|
||||||
log(`${data.caller.phone} is on air — Channel ${data.caller.channel}`);
|
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) {
|
} catch (err) {
|
||||||
log('Failed to take call: ' + err.message);
|
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() {
|
async function stopServer() {
|
||||||
if (!confirm('Stop the server? You will need to restart it manually.')) return;
|
if (!confirm('Stop the server? You will need to restart it manually.')) return;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user