Compare commits

..

10 Commits

Author SHA1 Message Date
41ddc8ee35 Remove Twilio dependencies and cleanup references
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 15:54:35 -07:00
a72c1eb795 Update tests for CallerService and browser caller format
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 15:53:41 -07:00
82ad234480 Add browser call-in page and update host dashboard for browser callers
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 15:52:54 -07:00
863a81f87b Add continuous host mic streaming to real callers
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 15:51:17 -07:00
bf140a77b7 Add browser caller WebSocket handler with PCM audio streaming
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 15:49:49 -07:00
06f334359e Remove Twilio endpoints and dependencies
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 15:48:12 -07:00
3961cfc9d4 Rename TwilioService to CallerService, remove Twilio-specific audio encoding
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 15:45:08 -07:00
db134262fb Add frontend: call queue, active call indicator, three-party chat, three-way calls
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 13:46:19 -07:00
8dc1d62487 Add Twilio and Cloudflare tunnel setup docs 2026-02-05 13:44:24 -07:00
141f81232e Add AI follow-up system with call summarization and show history
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 13:42:35 -07:00
14 changed files with 1331 additions and 407 deletions

View File

@@ -15,12 +15,6 @@ class Settings(BaseSettings):
openrouter_api_key: str = os.getenv("OPENROUTER_API_KEY", "")
inworld_api_key: str = os.getenv("INWORLD_API_KEY", "")
# Twilio Settings
twilio_account_sid: str = os.getenv("TWILIO_ACCOUNT_SID", "")
twilio_auth_token: str = os.getenv("TWILIO_AUTH_TOKEN", "")
twilio_phone_number: str = os.getenv("TWILIO_PHONE_NUMBER", "")
twilio_webhook_base_url: str = os.getenv("TWILIO_WEBHOOK_BASE_URL", "")
# LLM Settings
llm_provider: str = "openrouter" # "openrouter" or "ollama"
openrouter_model: str = "anthropic/claude-3-haiku"

View File

@@ -4,20 +4,17 @@ import uuid
import asyncio
from dataclasses import dataclass, field
from pathlib import Path
from fastapi import FastAPI, HTTPException, UploadFile, File, Form, WebSocket, WebSocketDisconnect
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, Response
from twilio.twiml.voice_response import VoiceResponse
from fastapi.responses import FileResponse
import json
import base64
import audioop
import time
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from typing import Optional
from .config import settings
from .services.twilio_service import TwilioService
from .services.caller_service import CallerService
from .services.transcription import transcribe_audio
from .services.llm import llm_service
from .services.tts import generate_speech
@@ -247,7 +244,7 @@ def generate_caller_background(base: dict) -> str:
return f"""{age}, {job} {location}. {problem.capitalize()}. {interest1.capitalize()}, {interest2}. {quirk1.capitalize()}, {quirk2}."""
def get_caller_prompt(caller: dict, conversation_summary: str = "") -> str:
def get_caller_prompt(caller: dict, conversation_summary: str = "", show_history: str = "") -> str:
"""Generate a natural system prompt for a caller"""
context = ""
if conversation_summary:
@@ -257,10 +254,14 @@ CONVERSATION SO FAR:
Continue naturally. Don't repeat yourself.
"""
history = ""
if show_history:
history = f"\n{show_history}\n"
return f"""You're {caller['name']}, calling a late-night radio show. You trust this host.
{caller['vibe']}
{context}
{history}{context}
HOW TO TALK:
- Sound like a real person chatting, not writing.
- Keep responses to 2-3 sentences. Enough to make your point, short enough for back-and-forth.
@@ -401,7 +402,7 @@ class Session:
session = Session()
twilio_service = TwilioService()
caller_service = CallerService()
# --- Static Files ---
@@ -415,6 +416,11 @@ async def index():
return FileResponse(frontend_dir / "index.html")
@app.get("/call-in")
async def call_in_page():
return FileResponse(frontend_dir / "call-in.html")
# --- Request Models ---
class ChatRequest(BaseModel):
@@ -611,9 +617,10 @@ async def chat(request: ChatRequest):
session.add_message("user", request.text)
# Include conversation summary for context
# Include conversation summary and show history for context
conversation_summary = session.get_conversation_summary()
system_prompt = get_caller_prompt(session.caller, conversation_summary)
show_history = session.get_show_history()
system_prompt = get_caller_prompt(session.caller, conversation_summary, show_history)
response = await llm_service.generate(
messages=session.conversation[-10:], # Reduced history for speed
@@ -665,9 +672,9 @@ async def text_to_speech(request: TTSRequest):
# Also send to active real callers so they hear the AI
if session.active_real_caller:
call_sid = session.active_real_caller["call_sid"]
caller_id = session.active_real_caller["caller_id"]
asyncio.create_task(
twilio_service.send_audio_to_caller(call_sid, audio_bytes, 24000)
caller_service.send_audio_to_caller(caller_id, audio_bytes, 24000)
)
return {"status": "playing", "duration": len(audio_bytes) / 2 / 24000}
@@ -775,67 +782,143 @@ async def update_settings(data: dict):
return llm_service.get_settings()
# --- Twilio Webhook & Queue Endpoints ---
# --- Browser Caller WebSocket ---
@app.post("/api/twilio/voice")
async def twilio_voice_webhook(
CallSid: str = Form(...),
From: str = Form(...),
):
"""Handle incoming Twilio call — greet and enqueue"""
twilio_service.add_to_queue(CallSid, From)
@app.websocket("/api/caller/stream")
async def caller_audio_stream(websocket: WebSocket):
"""Handle browser caller WebSocket — bidirectional audio"""
await websocket.accept()
response = VoiceResponse()
response.say("You're calling Luke at the Roost. Hold tight, we'll get to you.", voice="alice")
response.enqueue(
"radio_show",
wait_url="/api/twilio/hold-music",
wait_url_method="POST",
caller_id = str(uuid.uuid4())[:8]
caller_name = "Anonymous"
audio_buffer = bytearray()
CHUNK_DURATION_S = 3
SAMPLE_RATE = 16000
chunk_samples = CHUNK_DURATION_S * SAMPLE_RATE
try:
# Wait for join message
join_data = await websocket.receive_text()
join_msg = json.loads(join_data)
if join_msg.get("type") == "join":
caller_name = join_msg.get("name", "Anonymous").strip() or "Anonymous"
# Add to queue
caller_service.add_to_queue(caller_id, caller_name)
caller_service.register_websocket(caller_id, websocket)
# Notify caller they're queued
queue = caller_service.get_queue()
position = next((i+1 for i, c in enumerate(queue) if c["caller_id"] == caller_id), 0)
await websocket.send_text(json.dumps({
"status": "queued",
"caller_id": caller_id,
"position": position,
}))
# Main loop — handle both text and binary messages
while True:
message = await websocket.receive()
if message.get("type") == "websocket.disconnect":
break
if "bytes" in message and message["bytes"]:
# Binary audio data — only process if caller is on air
call_info = caller_service.active_calls.get(caller_id)
if not call_info:
continue # Still in queue, ignore audio
pcm_data = message["bytes"]
audio_buffer.extend(pcm_data)
# Route to Loopback channel
channel = call_info["channel"]
audio_service.route_real_caller_audio(pcm_data, channel, SAMPLE_RATE)
# Transcribe when we have enough audio
if len(audio_buffer) >= chunk_samples * 2:
pcm_chunk = bytes(audio_buffer[:chunk_samples * 2])
audio_buffer = audio_buffer[chunk_samples * 2:]
asyncio.create_task(
_handle_real_caller_transcription(caller_id, pcm_chunk, SAMPLE_RATE)
)
elif "text" in message and message["text"]:
# Control messages (future use)
pass
except WebSocketDisconnect:
print(f"[Caller WS] Disconnected: {caller_id} ({caller_name})")
except Exception as e:
print(f"[Caller WS] Error: {e}")
finally:
caller_service.unregister_websocket(caller_id)
# If still in queue, remove
caller_service.remove_from_queue(caller_id)
# If on air, clean up
if caller_id in caller_service.active_calls:
caller_service.hangup(caller_id)
if session.active_real_caller and session.active_real_caller.get("caller_id") == caller_id:
session.active_real_caller = None
# Transcribe remaining audio
if audio_buffer:
asyncio.create_task(
_handle_real_caller_transcription(caller_id, bytes(audio_buffer), SAMPLE_RATE)
)
return Response(content=str(response), media_type="application/xml")
@app.post("/api/twilio/hold-music")
async def twilio_hold_music():
"""Serve hold music TwiML for queued callers"""
response = VoiceResponse()
response.say("Please hold, you'll be on air shortly.", voice="alice")
response.pause(length=30)
return Response(content=str(response), media_type="application/xml")
# --- Host Audio Broadcast ---
async def _broadcast_host_audio(pcm_bytes: bytes):
"""Send host mic audio to all active real callers"""
for caller_id in list(caller_service.active_calls.keys()):
try:
await caller_service.send_audio_to_caller(caller_id, pcm_bytes, 16000)
except Exception:
pass
def _host_audio_sync_callback(pcm_bytes: bytes):
"""Sync wrapper to schedule async broadcast from audio thread"""
try:
loop = asyncio.get_event_loop()
if loop.is_running():
loop.call_soon_threadsafe(
asyncio.ensure_future, _broadcast_host_audio(pcm_bytes)
)
except Exception:
pass
# --- Queue Endpoints ---
@app.get("/api/queue")
async def get_call_queue():
"""Get list of callers waiting in queue"""
return {"queue": twilio_service.get_queue()}
return {"queue": caller_service.get_queue()}
@app.post("/api/queue/take/{call_sid}")
async def take_call_from_queue(call_sid: str):
@app.post("/api/queue/take/{caller_id}")
async def take_call_from_queue(caller_id: str):
"""Take a caller off hold and put them on air"""
try:
call_info = twilio_service.take_call(call_sid)
call_info = caller_service.take_call(caller_id)
except ValueError as e:
raise HTTPException(404, str(e))
session.active_real_caller = {
"call_sid": call_info["call_sid"],
"phone": call_info["phone"],
"caller_id": call_info["caller_id"],
"channel": call_info["channel"],
"name": call_info["name"],
}
# Connect Twilio media stream by updating the call
from twilio.rest import Client as TwilioClient
if settings.twilio_account_sid and settings.twilio_auth_token:
client = TwilioClient(settings.twilio_account_sid, settings.twilio_auth_token)
twiml = VoiceResponse()
connect = twiml.connect()
connect.stream(
url=f"wss://{settings.twilio_webhook_base_url.replace('https://', '')}/api/twilio/stream",
name=call_sid,
)
client.calls(call_sid).update(twiml=str(twiml))
# Notify caller they're on air via WebSocket
await caller_service.notify_caller(caller_id, {"status": "on_air", "channel": call_info["channel"]})
# Start host mic streaming if this is the first real caller
if len(caller_service.active_calls) == 1:
audio_service.start_host_stream(_host_audio_sync_callback)
return {
"status": "on_air",
@@ -843,97 +926,17 @@ async def take_call_from_queue(call_sid: str):
}
@app.post("/api/queue/drop/{call_sid}")
async def drop_from_queue(call_sid: str):
@app.post("/api/queue/drop/{caller_id}")
async def drop_from_queue(caller_id: str):
"""Drop a caller from the queue"""
twilio_service.remove_from_queue(call_sid)
# Hang up the Twilio call
from twilio.rest import Client as TwilioClient
if settings.twilio_account_sid and settings.twilio_auth_token:
try:
client = TwilioClient(settings.twilio_account_sid, settings.twilio_auth_token)
client.calls(call_sid).update(status="completed")
except Exception as e:
print(f"[Twilio] Failed to end call {call_sid}: {e}")
caller_service.remove_from_queue(caller_id)
await caller_service.disconnect_caller(caller_id)
return {"status": "dropped"}
# --- Twilio WebSocket Media Stream ---
@app.websocket("/api/twilio/stream")
async def twilio_media_stream(websocket: WebSocket):
"""Handle Twilio Media Streams WebSocket — bidirectional audio"""
await websocket.accept()
print("[Twilio WS] Media stream connected")
call_sid = None
stream_sid = None
audio_buffer = bytearray()
CHUNK_DURATION_S = 3 # Transcribe every 3 seconds of audio
MULAW_SAMPLE_RATE = 8000
chunk_samples = CHUNK_DURATION_S * MULAW_SAMPLE_RATE
try:
while True:
data = await websocket.receive_text()
msg = json.loads(data)
event = msg.get("event")
if event == "start":
stream_sid = msg["start"]["streamSid"]
call_sid = msg["start"]["callSid"]
twilio_service.register_websocket(call_sid, websocket)
if call_sid in twilio_service.active_calls:
twilio_service.active_calls[call_sid]["stream_sid"] = stream_sid
print(f"[Twilio WS] Stream started: {stream_sid} for call {call_sid}")
elif event == "media":
# Decode mulaw audio from base64
payload = base64.b64decode(msg["media"]["payload"])
# Convert mulaw to 16-bit PCM
pcm_data = audioop.ulaw2lin(payload, 2)
audio_buffer.extend(pcm_data)
# Get channel for this caller
call_info = twilio_service.active_calls.get(call_sid)
if call_info:
channel = call_info["channel"]
# Route PCM to the caller's dedicated Loopback channel
audio_service.route_real_caller_audio(pcm_data, channel, MULAW_SAMPLE_RATE)
# When we have enough audio, transcribe
if len(audio_buffer) >= chunk_samples * 2: # 2 bytes per sample
pcm_chunk = bytes(audio_buffer[:chunk_samples * 2])
audio_buffer = audio_buffer[chunk_samples * 2:]
# Transcribe in background
asyncio.create_task(
_handle_real_caller_transcription(call_sid, pcm_chunk, MULAW_SAMPLE_RATE)
)
elif event == "stop":
print(f"[Twilio WS] Stream stopped: {stream_sid}")
break
except WebSocketDisconnect:
print(f"[Twilio WS] Disconnected: {call_sid}")
except Exception as e:
print(f"[Twilio WS] Error: {e}")
finally:
if call_sid:
twilio_service.unregister_websocket(call_sid)
# Transcribe any remaining audio
if audio_buffer and call_sid:
asyncio.create_task(
_handle_real_caller_transcription(call_sid, bytes(audio_buffer), MULAW_SAMPLE_RATE)
)
async def _handle_real_caller_transcription(call_sid: str, pcm_data: bytes, sample_rate: int):
async def _handle_real_caller_transcription(caller_id: str, pcm_data: bytes, sample_rate: int):
"""Transcribe a chunk of real caller audio and add to conversation"""
call_info = twilio_service.active_calls.get(call_sid)
call_info = caller_service.active_calls.get(caller_id)
if not call_info:
return
@@ -979,7 +982,8 @@ async def _check_ai_auto_respond(real_caller_text: str, real_caller_name: str):
# Generate full response
conversation_summary = session.get_conversation_summary()
system_prompt = get_caller_prompt(session.caller, conversation_summary)
show_history = session.get_show_history()
system_prompt = get_caller_prompt(session.caller, conversation_summary, show_history)
response = await llm_service.generate(
messages=session.conversation[-10:],
@@ -1003,6 +1007,120 @@ async def _check_ai_auto_respond(real_caller_text: str, real_caller_name: str):
thread.start()
# --- Follow-Up & Session Control Endpoints ---
@app.post("/api/hangup/real")
async def hangup_real_caller():
"""Hang up on real caller — summarize call and store in history"""
if not session.active_real_caller:
raise HTTPException(400, "No active real caller")
caller_id = session.active_real_caller["caller_id"]
caller_name = session.active_real_caller["name"]
# Summarize the conversation
summary = ""
if session.conversation:
transcript_text = "\n".join(
f"{msg['role']}: {msg['content']}" for msg in session.conversation
)
summary = await llm_service.generate(
messages=[{"role": "user", "content": f"Summarize this radio show call in 1-2 sentences:\n{transcript_text}"}],
system_prompt="You summarize radio show conversations concisely. Focus on what the caller talked about and any emotional moments.",
)
# Store in call history
session.call_history.append(CallRecord(
caller_type="real",
caller_name=caller_name,
summary=summary,
transcript=list(session.conversation),
))
# Disconnect the caller
caller_service.hangup(caller_id)
await caller_service.disconnect_caller(caller_id)
# Stop host streaming if no more active callers
if len(caller_service.active_calls) == 0:
audio_service.stop_host_stream()
session.active_real_caller = None
# Play hangup sound
hangup_sound = settings.sounds_dir / "hangup.wav"
if hangup_sound.exists():
audio_service.play_sfx(str(hangup_sound))
# Auto follow-up?
auto_followup_triggered = False
if session.auto_followup:
auto_followup_triggered = True
asyncio.create_task(_auto_followup(summary))
return {
"status": "disconnected",
"caller": caller_name,
"summary": summary,
"auto_followup": auto_followup_triggered,
}
async def _auto_followup(last_call_summary: str):
"""Automatically pick an AI caller and connect them as follow-up"""
await asyncio.sleep(7) # Brief pause before follow-up
# Ask LLM to pick best AI caller for follow-up
caller_list = ", ".join(
f'{k}: {v["name"]} ({v["gender"]}, {v["age_range"][0]}-{v["age_range"][1]})'
for k, v in CALLER_BASES.items()
)
pick = await llm_service.generate(
messages=[{"role": "user", "content": f'A caller just talked about: "{last_call_summary}". Which AI caller should follow up? Available: {caller_list}. Reply with just the key number.'}],
system_prompt="Pick the most interesting AI caller to follow up on this topic. Just reply with the number key.",
)
# Extract key from response
match = re.search(r'\d+', pick)
if match:
caller_key = match.group()
if caller_key in CALLER_BASES:
session.start_call(caller_key)
print(f"[Auto Follow-Up] {CALLER_BASES[caller_key]['name']} is calling in about: {last_call_summary[:50]}...")
@app.post("/api/followup/generate")
async def generate_followup():
"""Generate an AI follow-up caller based on recent show history"""
if not session.call_history:
raise HTTPException(400, "No call history to follow up on")
last_record = session.call_history[-1]
await _auto_followup(last_record.summary)
return {
"status": "followup_triggered",
"based_on": last_record.caller_name,
}
@app.post("/api/session/ai-mode")
async def set_ai_mode(data: dict):
"""Set AI respond mode (manual or auto)"""
mode = data.get("mode", "manual")
session.ai_respond_mode = mode
print(f"[Session] AI respond mode: {mode}")
return {"mode": mode}
@app.post("/api/session/auto-followup")
async def set_auto_followup(data: dict):
"""Toggle auto follow-up"""
session.auto_followup = data.get("enabled", False)
print(f"[Session] Auto follow-up: {session.auto_followup}")
return {"enabled": session.auto_followup}
# --- Server Control Endpoints ---
import subprocess

View File

@@ -48,6 +48,10 @@ class AudioService:
self._caller_stop_event = threading.Event()
self._caller_thread: Optional[threading.Thread] = None
# Host mic streaming state
self._host_stream: Optional[sd.InputStream] = None
self._host_send_callback: Optional[Callable] = None
# Sample rates
self.input_sample_rate = 16000 # For Whisper
self.output_sample_rate = 24000 # For TTS
@@ -325,7 +329,7 @@ class AudioService:
device_sr = int(device_info['default_samplerate'])
channel_idx = min(channel, num_channels) - 1
# Resample from Twilio's 8kHz to device sample rate
# Resample to device sample rate if needed
if sample_rate != device_sr:
audio = librosa.resample(audio, orig_sr=sample_rate, target_sr=device_sr)
@@ -345,6 +349,55 @@ class AudioService:
except Exception as e:
print(f"Real caller audio routing error: {e}")
# --- Host Mic Streaming ---
def start_host_stream(self, send_callback: Callable):
"""Start continuous host mic capture for streaming to real callers"""
if self.input_device is None:
print("[Audio] No input device configured for host streaming")
return
self._host_send_callback = send_callback
device_info = sd.query_devices(self.input_device)
max_channels = device_info['max_input_channels']
device_sr = int(device_info['default_samplerate'])
record_channel = min(self.input_channel, max_channels) - 1
import librosa
def callback(indata, frames, time_info, status):
if not self._host_send_callback:
return
# Extract the configured input channel
mono = indata[:, record_channel].copy()
# Resample to 16kHz if needed
if device_sr != 16000:
mono = librosa.resample(mono, orig_sr=device_sr, target_sr=16000)
# Convert float32 to int16 PCM
pcm = (mono * 32767).astype(np.int16).tobytes()
self._host_send_callback(pcm)
self._host_stream = sd.InputStream(
device=self.input_device,
channels=max_channels,
samplerate=device_sr,
dtype=np.float32,
blocksize=4096,
callback=callback,
)
self._host_stream.start()
print(f"[Audio] Host mic streaming started (device {self.input_device} ch {self.input_channel} @ {device_sr}Hz)")
def stop_host_stream(self):
"""Stop host mic streaming"""
if self._host_stream:
self._host_stream.stop()
self._host_stream.close()
self._host_stream = None
self._host_send_callback = None
print("[Audio] Host mic streaming stopped")
# --- Music Playback ---
def load_music(self, file_path: str) -> bool:

View File

@@ -0,0 +1,147 @@
"""Browser caller queue and audio stream service"""
import asyncio
import time
import threading
from typing import Optional
class CallerService:
"""Manages browser caller queue, channel allocation, and WebSocket streams"""
FIRST_REAL_CHANNEL = 3
def __init__(self):
self._queue: list[dict] = []
self.active_calls: dict[str, dict] = {}
self._allocated_channels: set[int] = set()
self._caller_counter: int = 0
self._lock = threading.Lock()
self._websockets: dict[str, any] = {} # caller_id -> WebSocket
def add_to_queue(self, caller_id: str, name: str):
with self._lock:
self._queue.append({
"caller_id": caller_id,
"name": name,
"queued_at": time.time(),
})
print(f"[Caller] {name} added to queue (ID: {caller_id})")
def remove_from_queue(self, caller_id: str):
with self._lock:
self._queue = [c for c in self._queue if c["caller_id"] != caller_id]
print(f"[Caller] {caller_id} removed from queue")
def get_queue(self) -> list[dict]:
now = time.time()
with self._lock:
return [
{
"caller_id": c["caller_id"],
"name": c["name"],
"wait_time": int(now - c["queued_at"]),
}
for c in self._queue
]
def allocate_channel(self) -> int:
with self._lock:
ch = self.FIRST_REAL_CHANNEL
while ch in self._allocated_channels:
ch += 1
self._allocated_channels.add(ch)
return ch
def release_channel(self, channel: int):
with self._lock:
self._allocated_channels.discard(channel)
def take_call(self, caller_id: str) -> dict:
caller = None
with self._lock:
for c in self._queue:
if c["caller_id"] == caller_id:
caller = c
break
if caller:
self._queue = [c for c in self._queue if c["caller_id"] != caller_id]
if not caller:
raise ValueError(f"Caller {caller_id} not in queue")
channel = self.allocate_channel()
self._caller_counter += 1
name = caller["name"]
call_info = {
"caller_id": caller_id,
"name": name,
"channel": channel,
"started_at": time.time(),
}
self.active_calls[caller_id] = call_info
print(f"[Caller] {name} taken on air — channel {channel}")
return call_info
def hangup(self, caller_id: str):
call_info = self.active_calls.pop(caller_id, None)
if call_info:
self.release_channel(call_info["channel"])
print(f"[Caller] {call_info['name']} hung up — channel {call_info['channel']} released")
self._websockets.pop(caller_id, None)
def reset(self):
with self._lock:
for call_info in self.active_calls.values():
self._allocated_channels.discard(call_info["channel"])
self._queue.clear()
self.active_calls.clear()
self._allocated_channels.clear()
self._caller_counter = 0
self._websockets.clear()
print("[Caller] Service reset")
def register_websocket(self, caller_id: str, websocket):
"""Register a WebSocket for a caller"""
self._websockets[caller_id] = websocket
def unregister_websocket(self, caller_id: str):
"""Unregister a WebSocket"""
self._websockets.pop(caller_id, None)
async def send_audio_to_caller(self, caller_id: str, pcm_data: bytes, sample_rate: int):
"""Send audio to real caller via WebSocket binary frame"""
ws = self._websockets.get(caller_id)
if not ws:
return
try:
if sample_rate != 16000:
import numpy as np
import librosa
audio = np.frombuffer(pcm_data, dtype=np.int16).astype(np.float32) / 32768.0
audio = librosa.resample(audio, orig_sr=sample_rate, target_sr=16000)
pcm_data = (audio * 32767).astype(np.int16).tobytes()
await ws.send_bytes(pcm_data)
except Exception as e:
print(f"[Caller] Failed to send audio: {e}")
async def notify_caller(self, caller_id: str, message: dict):
"""Send JSON control message to caller"""
ws = self._websockets.get(caller_id)
if ws:
import json
await ws.send_text(json.dumps(message))
async def disconnect_caller(self, caller_id: str):
"""Disconnect a caller's WebSocket"""
ws = self._websockets.get(caller_id)
if ws:
try:
import json
await ws.send_text(json.dumps({"status": "disconnected"}))
await ws.close()
except Exception:
pass
self._websockets.pop(caller_id, None)

View File

@@ -1,148 +0,0 @@
"""Twilio call queue and media stream service"""
import asyncio
import base64
import audioop
import time
import threading
from typing import Optional
class TwilioService:
"""Manages Twilio call queue, channel allocation, and media streams"""
FIRST_REAL_CHANNEL = 3
def __init__(self):
self._queue: list[dict] = []
self.active_calls: dict[str, dict] = {}
self._allocated_channels: set[int] = set()
self._caller_counter: int = 0
self._lock = threading.Lock()
self._websockets: dict[str, any] = {} # call_sid -> WebSocket
def add_to_queue(self, call_sid: str, phone: str):
with self._lock:
self._queue.append({
"call_sid": call_sid,
"phone": phone,
"queued_at": time.time(),
})
print(f"[Twilio] Caller {phone} added to queue (SID: {call_sid})")
def remove_from_queue(self, call_sid: str):
with self._lock:
self._queue = [c for c in self._queue if c["call_sid"] != call_sid]
print(f"[Twilio] Caller {call_sid} removed from queue")
def get_queue(self) -> list[dict]:
now = time.time()
with self._lock:
return [
{
"call_sid": c["call_sid"],
"phone": c["phone"],
"wait_time": int(now - c["queued_at"]),
}
for c in self._queue
]
def allocate_channel(self) -> int:
with self._lock:
ch = self.FIRST_REAL_CHANNEL
while ch in self._allocated_channels:
ch += 1
self._allocated_channels.add(ch)
return ch
def release_channel(self, channel: int):
with self._lock:
self._allocated_channels.discard(channel)
def take_call(self, call_sid: str) -> dict:
caller = None
with self._lock:
for c in self._queue:
if c["call_sid"] == call_sid:
caller = c
break
if caller:
self._queue = [c for c in self._queue if c["call_sid"] != call_sid]
if not caller:
raise ValueError(f"Call {call_sid} not in queue")
channel = self.allocate_channel()
self._caller_counter += 1
name = f"Caller #{self._caller_counter}"
call_info = {
"call_sid": call_sid,
"phone": caller["phone"],
"channel": channel,
"name": name,
"started_at": time.time(),
}
self.active_calls[call_sid] = call_info
print(f"[Twilio] {name} ({caller['phone']}) taken on air — channel {channel}")
return call_info
def hangup(self, call_sid: str):
call_info = self.active_calls.pop(call_sid, None)
if call_info:
self.release_channel(call_info["channel"])
print(f"[Twilio] {call_info['name']} hung up — channel {call_info['channel']} released")
self._websockets.pop(call_sid, None)
def reset(self):
with self._lock:
for call_info in self.active_calls.values():
self._allocated_channels.discard(call_info["channel"])
self._queue.clear()
self.active_calls.clear()
self._allocated_channels.clear()
self._caller_counter = 0
self._websockets.clear()
print("[Twilio] Service reset")
def register_websocket(self, call_sid: str, websocket):
"""Register a WebSocket for a call"""
self._websockets[call_sid] = websocket
def unregister_websocket(self, call_sid: str):
"""Unregister a WebSocket"""
self._websockets.pop(call_sid, None)
async def send_audio_to_caller(self, call_sid: str, pcm_data: bytes, sample_rate: int):
"""Send audio back to real caller via Twilio WebSocket"""
ws = self._websockets.get(call_sid)
if not ws:
return
call_info = self.active_calls.get(call_sid)
if not call_info or "stream_sid" not in call_info:
return
try:
# Resample to 8kHz if needed
if sample_rate != 8000:
import numpy as np
import librosa
audio = np.frombuffer(pcm_data, dtype=np.int16).astype(np.float32) / 32768.0
audio = librosa.resample(audio, orig_sr=sample_rate, target_sr=8000)
pcm_data = (audio * 32767).astype(np.int16).tobytes()
# Convert PCM to mulaw
mulaw_data = audioop.lin2ulaw(pcm_data, 2)
# Send as Twilio media message
import json
await ws.send_text(json.dumps({
"event": "media",
"streamSid": call_info["stream_sid"],
"media": {
"payload": base64.b64encode(mulaw_data).decode("ascii"),
},
}))
except Exception as e:
print(f"[Twilio] Failed to send audio to caller: {e}")

155
frontend/call-in.html Normal file
View File

@@ -0,0 +1,155 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Call In - Luke at the Roost</title>
<style>
:root {
--bg: #1a1a2e;
--bg-light: #252547;
--accent: #e94560;
--text: #fff;
--text-muted: #888;
--radius: 8px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.container {
max-width: 400px;
width: 100%;
padding: 30px;
text-align: center;
}
h1 {
font-size: 1.5em;
margin-bottom: 8px;
}
.subtitle {
color: var(--text-muted);
margin-bottom: 30px;
font-size: 0.9em;
}
.form-group {
margin-bottom: 20px;
}
.form-group input {
width: 100%;
padding: 12px 16px;
border: 1px solid #444;
border-radius: var(--radius);
background: var(--bg-light);
color: var(--text);
font-size: 1em;
outline: none;
}
.form-group input:focus {
border-color: var(--accent);
}
.call-btn {
width: 100%;
padding: 14px;
border: none;
border-radius: var(--radius);
background: var(--accent);
color: #fff;
font-size: 1.1em;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
.call-btn:hover { opacity: 0.9; }
.call-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.hangup-btn {
width: 100%;
padding: 14px;
border: none;
border-radius: var(--radius);
background: #c0392b;
color: #fff;
font-size: 1.1em;
font-weight: 600;
cursor: pointer;
margin-top: 12px;
display: none;
}
.hangup-btn:hover { opacity: 0.9; }
.status {
margin-top: 20px;
padding: 16px;
background: var(--bg-light);
border-radius: var(--radius);
display: none;
}
.status.visible { display: block; }
.status-label {
font-size: 0.85em;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 6px;
}
.status-text {
font-size: 1.1em;
font-weight: 500;
}
.on-air .status-text {
color: var(--accent);
font-weight: 700;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.mic-meter {
margin-top: 16px;
height: 6px;
background: #333;
border-radius: 3px;
overflow: hidden;
display: none;
}
.mic-meter.visible { display: block; }
.mic-meter-fill {
height: 100%;
background: #2ecc71;
width: 0%;
transition: width 0.1s;
border-radius: 3px;
}
</style>
</head>
<body>
<div class="container">
<h1>Luke at the Roost</h1>
<p class="subtitle">Call in to the show</p>
<div class="form-group">
<input type="text" id="caller-name" placeholder="Your name" maxlength="30" autocomplete="off">
</div>
<button id="call-btn" class="call-btn">Call In</button>
<button id="hangup-btn" class="hangup-btn">Hang Up</button>
<div id="status" class="status">
<div class="status-label">Status</div>
<div id="status-text" class="status-text">Connecting...</div>
</div>
<div id="mic-meter" class="mic-meter">
<div id="mic-meter-fill" class="mic-meter-fill"></div>
</div>
</div>
<script src="/js/call-in.js"></script>
</body>
</html>

View File

@@ -541,3 +541,38 @@ section h2 {
.server-log .log-line.chat {
color: #f8f;
}
/* Call Queue */
.queue-section { margin: 1rem 0; }
.call-queue { border: 1px solid #333; border-radius: 4px; padding: 0.5rem; max-height: 150px; overflow-y: auto; }
.queue-empty { color: #666; text-align: center; padding: 0.5rem; }
.queue-item { display: flex; align-items: center; gap: 0.75rem; padding: 0.4rem 0.5rem; border-bottom: 1px solid #222; }
.queue-item:last-child { border-bottom: none; }
.queue-phone { font-family: monospace; color: #4fc3f7; }
.queue-wait { color: #999; font-size: 0.85rem; flex: 1; }
.queue-take-btn { background: #2e7d32; color: white; border: none; padding: 0.25rem 0.75rem; border-radius: 3px; cursor: pointer; }
.queue-take-btn:hover { background: #388e3c; }
.queue-drop-btn { background: #c62828; color: white; border: none; padding: 0.25rem 0.5rem; border-radius: 3px; cursor: pointer; }
.queue-drop-btn:hover { background: #d32f2f; }
/* Active Call Indicator */
.active-call { border: 1px solid #444; border-radius: 4px; padding: 0.75rem; margin: 0.5rem 0; background: #1a1a2e; }
.caller-info { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; }
.caller-info:last-of-type { margin-bottom: 0; }
.caller-type { font-size: 0.7rem; font-weight: bold; padding: 0.15rem 0.4rem; border-radius: 3px; text-transform: uppercase; }
.caller-type.real { background: #c62828; color: white; }
.caller-type.ai { background: #1565c0; color: white; }
.channel-badge { font-size: 0.75rem; color: #999; background: #222; padding: 0.1rem 0.4rem; border-radius: 3px; }
.call-duration { font-family: monospace; color: #4fc3f7; }
.ai-controls { display: flex; align-items: center; gap: 0.5rem; margin-left: auto; }
.mode-toggle { display: flex; border: 1px solid #444; border-radius: 3px; overflow: hidden; }
.mode-btn { background: #222; color: #999; border: none; padding: 0.2rem 0.5rem; font-size: 0.75rem; cursor: pointer; }
.mode-btn.active { background: #1565c0; color: white; }
.respond-btn { background: #2e7d32; color: white; border: none; padding: 0.25rem 0.75rem; border-radius: 3px; font-size: 0.8rem; cursor: pointer; }
.hangup-btn.small { font-size: 0.75rem; padding: 0.2rem 0.5rem; }
.auto-followup-label { display: flex; align-items: center; gap: 0.4rem; font-size: 0.8rem; color: #999; margin-top: 0.5rem; }
/* Three-Party Chat */
.message.real-caller { border-left: 3px solid #c62828; padding-left: 0.5rem; }
.message.ai-caller { border-left: 3px solid #1565c0; padding-left: 0.5rem; }
.message.host { border-left: 3px solid #2e7d32; padding-left: 0.5rem; }

View File

@@ -21,11 +21,44 @@
<section class="callers-section">
<h2>Callers <span id="session-id" class="session-id"></span></h2>
<div id="callers" class="caller-grid"></div>
<!-- Active Call Indicator -->
<div id="active-call" class="active-call hidden">
<div id="real-caller-info" class="caller-info hidden">
<span class="caller-type real">LIVE</span>
<span id="real-caller-name"></span>
<span id="real-caller-channel" class="channel-badge"></span>
<span id="real-caller-duration" class="call-duration"></span>
<button id="hangup-real-btn" class="hangup-btn small">Hang Up</button>
</div>
<div id="ai-caller-info" class="caller-info hidden">
<span class="caller-type ai">AI</span>
<span id="ai-caller-name"></span>
<div class="ai-controls">
<div class="mode-toggle">
<button id="mode-manual" class="mode-btn active">Manual</button>
<button id="mode-auto" class="mode-btn">Auto</button>
</div>
<button id="ai-respond-btn" class="respond-btn">Let them respond</button>
</div>
<button id="hangup-ai-btn" class="hangup-btn small">Hang Up</button>
</div>
<label class="auto-followup-label">
<input type="checkbox" id="auto-followup"> Auto Follow-Up
</label>
</div>
<div id="call-status" class="call-status">No active call</div>
<div id="caller-background" class="caller-background hidden"></div>
<button id="hangup-btn" class="hangup-btn" disabled>Hang Up</button>
</section>
<!-- Call Queue -->
<section class="queue-section">
<h2>Incoming Calls <a href="/call-in" target="_blank" style="font-size:0.6em;font-weight:normal;color:var(--accent);">Call-in page</a></h2>
<div id="call-queue" class="call-queue">
<div class="queue-empty">No callers waiting</div>
</div>
</section>
<!-- Chat -->
<section class="chat-section">
<div id="chat" class="chat-log"></div>
@@ -173,6 +206,6 @@
</div>
</div>
<script src="/js/app.js?v=8"></script>
<script src="/js/app.js?v=9"></script>
</body>
</html>

View File

@@ -52,6 +52,9 @@ function initEventListeners() {
// Start log polling
startLogPolling();
// Start queue polling
startQueuePolling();
// Talk button - now triggers server-side recording
const talkBtn = document.getElementById('talk-btn');
if (talkBtn) {
@@ -97,6 +100,45 @@ function initEventListeners() {
phoneFilter = e.target.checked;
});
document.getElementById('refresh-ollama')?.addEventListener('click', refreshOllamaModels);
// Real caller hangup
document.getElementById('hangup-real-btn')?.addEventListener('click', async () => {
await fetch('/api/hangup/real', { method: 'POST' });
hideRealCaller();
log('Real caller disconnected');
});
// AI respond mode toggle
document.getElementById('mode-manual')?.addEventListener('click', () => {
document.getElementById('mode-manual')?.classList.add('active');
document.getElementById('mode-auto')?.classList.remove('active');
document.getElementById('ai-respond-btn')?.classList.remove('hidden');
fetch('/api/session/ai-mode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: 'manual' }),
});
});
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' },
body: JSON.stringify({ mode: 'auto' }),
});
});
// Auto follow-up toggle
document.getElementById('auto-followup')?.addEventListener('change', (e) => {
fetch('/api/session/auto-followup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: e.target.checked }),
});
});
}
@@ -273,9 +315,24 @@ async function startCall(key, name) {
currentCaller = { key, name };
// 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;
// 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 background
const bgEl = document.getElementById('caller-background');
if (bgEl && data.background) {
@@ -287,8 +344,10 @@ async function startCall(key, name) {
btn.classList.toggle('active', btn.dataset.key === key);
});
log(`Connected to ${name}`);
clearChat();
log(`Connected to ${name}` + (realCallerActive ? ' (three-way)' : ''));
if (!realCallerActive) clearChat();
updateActiveCallIndicator();
}
@@ -314,7 +373,6 @@ async function newSession() {
async function hangup() {
if (!currentCaller) return;
// Stop any playing TTS
await fetch('/api/tts/stop', { method: 'POST' });
await fetch('/api/hangup', { method: 'POST' });
@@ -331,6 +389,10 @@ async function hangup() {
// Hide caller background
const bgEl = document.getElementById('caller-background');
if (bgEl) bgEl.classList.add('hidden');
// Hide AI caller indicator
document.getElementById('ai-caller-info')?.classList.add('hidden');
updateActiveCallIndicator();
}
@@ -647,7 +709,19 @@ function addMessage(sender, text) {
return;
}
const div = document.createElement('div');
div.className = `message ${sender === 'You' ? 'host' : 'caller'}`;
let className = 'message';
if (sender === 'You') {
className += ' host';
} else if (sender === 'System') {
className += ' system';
} else if (sender.includes('(caller)') || sender.includes('Caller #')) {
className += ' real-caller';
} else {
className += ' ai-caller';
}
div.className = className;
div.innerHTML = `<strong>${sender}:</strong> ${text}`;
chat.appendChild(div);
chat.scrollTop = chat.scrollHeight;
@@ -769,6 +843,121 @@ async function restartServer() {
}
// --- Call Queue ---
let queuePollInterval = null;
function startQueuePolling() {
queuePollInterval = setInterval(fetchQueue, 3000);
fetchQueue();
}
async function fetchQueue() {
try {
const res = await fetch('/api/queue');
const data = await res.json();
renderQueue(data.queue);
} catch (err) {}
}
function renderQueue(queue) {
const el = document.getElementById('call-queue');
if (!el) return;
if (queue.length === 0) {
el.innerHTML = '<div class="queue-empty">No callers waiting</div>';
return;
}
el.innerHTML = queue.map(caller => {
const mins = Math.floor(caller.wait_time / 60);
const secs = caller.wait_time % 60;
const waitStr = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
return `
<div class="queue-item">
<span class="queue-name">${caller.name}</span>
<span class="queue-wait">waiting ${waitStr}</span>
<button class="queue-take-btn" onclick="takeCall('${caller.caller_id}')">Take Call</button>
<button class="queue-drop-btn" onclick="dropCall('${caller.caller_id}')">Drop</button>
</div>
`;
}).join('');
}
async function takeCall(callerId) {
try {
const res = await fetch(`/api/queue/take/${callerId}`, { method: 'POST' });
const data = await res.json();
if (data.status === 'on_air') {
showRealCaller(data.caller);
log(`${data.caller.name} is on air — Channel ${data.caller.channel}`);
}
} catch (err) {
log('Failed to take call: ' + err.message);
}
}
async function dropCall(callerId) {
try {
await fetch(`/api/queue/drop/${callerId}`, { method: 'POST' });
fetchQueue();
} catch (err) {
log('Failed to drop call: ' + err.message);
}
}
// --- Active Call Indicator ---
let realCallerTimer = null;
let realCallerStartTime = null;
function updateActiveCallIndicator() {
const container = document.getElementById('active-call');
const realInfo = document.getElementById('real-caller-info');
const aiInfo = document.getElementById('ai-caller-info');
const statusEl = document.getElementById('call-status');
const hasReal = realInfo && !realInfo.classList.contains('hidden');
const hasAi = aiInfo && !aiInfo.classList.contains('hidden');
if (hasReal || hasAi) {
container?.classList.remove('hidden');
statusEl?.classList.add('hidden');
} else {
container?.classList.add('hidden');
statusEl?.classList.remove('hidden');
if (statusEl) statusEl.textContent = 'No active call';
}
}
function showRealCaller(callerInfo) {
const nameEl = document.getElementById('real-caller-name');
const chEl = document.getElementById('real-caller-channel');
if (nameEl) nameEl.textContent = callerInfo.name;
if (chEl) chEl.textContent = `Ch ${callerInfo.channel}`;
document.getElementById('real-caller-info')?.classList.remove('hidden');
realCallerStartTime = Date.now();
if (realCallerTimer) clearInterval(realCallerTimer);
realCallerTimer = setInterval(() => {
const elapsed = Math.floor((Date.now() - realCallerStartTime) / 1000);
const mins = Math.floor(elapsed / 60);
const secs = elapsed % 60;
const durEl = document.getElementById('real-caller-duration');
if (durEl) durEl.textContent = `${mins}:${secs.toString().padStart(2, '0')}`;
}, 1000);
updateActiveCallIndicator();
}
function hideRealCaller() {
document.getElementById('real-caller-info')?.classList.add('hidden');
if (realCallerTimer) clearInterval(realCallerTimer);
realCallerTimer = null;
updateActiveCallIndicator();
}
async function stopServer() {
if (!confirm('Stop the server? You will need to restart it manually.')) return;

232
frontend/js/call-in.js Normal file
View File

@@ -0,0 +1,232 @@
/**
* Call-In Page — Browser WebSocket audio streaming
* Captures mic via AudioWorklet, sends Int16 PCM 16kHz mono over WebSocket.
* Receives Int16 PCM 16kHz mono back for playback.
*/
let ws = null;
let audioCtx = null;
let micStream = null;
let workletNode = null;
let nextPlayTime = 0;
let callerId = null;
const callBtn = document.getElementById('call-btn');
const hangupBtn = document.getElementById('hangup-btn');
const statusEl = document.getElementById('status');
const statusText = document.getElementById('status-text');
const nameInput = document.getElementById('caller-name');
const micMeter = document.getElementById('mic-meter');
const micMeterFill = document.getElementById('mic-meter-fill');
callBtn.addEventListener('click', startCall);
hangupBtn.addEventListener('click', hangUp);
nameInput.addEventListener('keydown', e => {
if (e.key === 'Enter') startCall();
});
async function startCall() {
const name = nameInput.value.trim() || 'Anonymous';
callBtn.disabled = true;
setStatus('Connecting...', false);
try {
// Get mic access
micStream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true, sampleRate: 16000 }
});
// Set up AudioContext
audioCtx = new AudioContext({ sampleRate: 48000 });
// Register worklet processor inline via blob
const processorCode = `
class CallerProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.buffer = [];
this.targetSamples = 4096; // ~256ms at 16kHz
}
process(inputs) {
const input = inputs[0][0];
if (!input) return true;
// Downsample from sampleRate to 16000
const ratio = sampleRate / 16000;
for (let i = 0; i < input.length; i += ratio) {
const idx = Math.floor(i);
if (idx < input.length) {
this.buffer.push(input[idx]);
}
}
if (this.buffer.length >= this.targetSamples) {
const chunk = this.buffer.splice(0, this.targetSamples);
const int16 = new Int16Array(chunk.length);
for (let i = 0; i < chunk.length; i++) {
const s = Math.max(-1, Math.min(1, chunk[i]));
int16[i] = s < 0 ? s * 32768 : s * 32767;
}
this.port.postMessage(int16.buffer, [int16.buffer]);
}
return true;
}
}
registerProcessor('caller-processor', CallerProcessor);
`;
const blob = new Blob([processorCode], { type: 'application/javascript' });
const blobUrl = URL.createObjectURL(blob);
await audioCtx.audioWorklet.addModule(blobUrl);
URL.revokeObjectURL(blobUrl);
// Connect mic to worklet
const source = audioCtx.createMediaStreamSource(micStream);
workletNode = new AudioWorkletNode(audioCtx, 'caller-processor');
// Connect WebSocket
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${proto}//${location.host}/api/caller/stream`);
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
ws.send(JSON.stringify({ type: 'join', name }));
};
ws.onmessage = (event) => {
if (typeof event.data === 'string') {
handleControlMessage(JSON.parse(event.data));
} else {
handleAudioData(event.data);
}
};
ws.onclose = () => {
setStatus('Disconnected', false);
cleanup();
};
ws.onerror = () => {
setStatus('Connection error', false);
cleanup();
};
// Forward mic audio to WebSocket
workletNode.port.onmessage = (e) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(e.data);
}
};
source.connect(workletNode);
// Don't connect worklet to destination — we don't want to hear our own mic
// Show mic meter
const analyser = audioCtx.createAnalyser();
analyser.fftSize = 256;
source.connect(analyser);
startMicMeter(analyser);
// UI
nameInput.disabled = true;
hangupBtn.style.display = 'block';
} catch (err) {
console.error('Call error:', err);
setStatus('Failed: ' + err.message, false);
callBtn.disabled = false;
cleanup();
}
}
function handleControlMessage(msg) {
if (msg.status === 'queued') {
callerId = msg.caller_id;
setStatus(`Waiting in queue (position ${msg.position})...`, false);
} else if (msg.status === 'on_air') {
setStatus('ON AIR', true);
nextPlayTime = audioCtx.currentTime;
} else if (msg.status === 'disconnected') {
setStatus('Disconnected', false);
cleanup();
}
}
function handleAudioData(buffer) {
if (!audioCtx) return;
const int16 = new Int16Array(buffer);
const float32 = new Float32Array(int16.length);
for (let i = 0; i < int16.length; i++) {
float32[i] = int16[i] / 32768;
}
const audioBuf = audioCtx.createBuffer(1, float32.length, 16000);
audioBuf.copyToChannel(float32, 0);
const source = audioCtx.createBufferSource();
source.buffer = audioBuf;
source.connect(audioCtx.destination);
const now = audioCtx.currentTime;
if (nextPlayTime < now) {
nextPlayTime = now;
}
source.start(nextPlayTime);
nextPlayTime += audioBuf.duration;
}
function hangUp() {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.close();
}
setStatus('Disconnected', false);
cleanup();
}
function cleanup() {
if (workletNode) {
workletNode.disconnect();
workletNode = null;
}
if (micStream) {
micStream.getTracks().forEach(t => t.stop());
micStream = null;
}
if (audioCtx) {
audioCtx.close().catch(() => {});
audioCtx = null;
}
ws = null;
callerId = null;
callBtn.disabled = false;
nameInput.disabled = false;
hangupBtn.style.display = 'none';
micMeter.classList.remove('visible');
}
function setStatus(text, isOnAir) {
statusEl.classList.add('visible');
statusText.textContent = text;
if (isOnAir) {
statusEl.classList.add('on-air');
} else {
statusEl.classList.remove('on-air');
}
}
function startMicMeter(analyser) {
micMeter.classList.add('visible');
const data = new Uint8Array(analyser.frequencyBinCount);
function update() {
if (!analyser || !audioCtx) return;
analyser.getByteFrequencyData(data);
let sum = 0;
for (let i = 0; i < data.length; i++) sum += data[i];
const avg = sum / data.length;
const pct = Math.min(100, (avg / 128) * 100);
micMeterFill.style.width = pct + '%';
requestAnimationFrame(update);
}
update();
}

View File

@@ -0,0 +1,180 @@
import sys
sys.path.insert(0, "/Users/lukemacneil/ai-podcast")
from backend.services.caller_service import CallerService
def test_queue_starts_empty():
svc = CallerService()
assert svc.get_queue() == []
def test_add_caller_to_queue():
svc = CallerService()
svc.add_to_queue("abc123", "Dave")
q = svc.get_queue()
assert len(q) == 1
assert q[0]["caller_id"] == "abc123"
assert q[0]["name"] == "Dave"
assert "wait_time" in q[0]
def test_remove_caller_from_queue():
svc = CallerService()
svc.add_to_queue("abc123", "Dave")
svc.remove_from_queue("abc123")
assert svc.get_queue() == []
def test_allocate_channel():
svc = CallerService()
ch1 = svc.allocate_channel()
ch2 = svc.allocate_channel()
assert ch1 == 3
assert ch2 == 4
svc.release_channel(ch1)
ch3 = svc.allocate_channel()
assert ch3 == 3
def test_take_call():
svc = CallerService()
svc.add_to_queue("abc123", "Dave")
result = svc.take_call("abc123")
assert result["caller_id"] == "abc123"
assert result["channel"] >= 3
assert svc.get_queue() == []
assert svc.active_calls["abc123"]["channel"] == result["channel"]
def test_hangup_real_caller():
svc = CallerService()
svc.add_to_queue("abc123", "Dave")
svc.take_call("abc123")
ch = svc.active_calls["abc123"]["channel"]
svc.hangup("abc123")
assert "abc123" not in svc.active_calls
assert ch not in svc._allocated_channels
def test_caller_counter_increments():
svc = CallerService()
svc.add_to_queue("id1", "Dave")
svc.add_to_queue("id2", "Sarah")
r1 = svc.take_call("id1")
r2 = svc.take_call("id2")
assert r1["name"] == "Dave"
assert r2["name"] == "Sarah"
def test_register_and_unregister_websocket():
svc = CallerService()
fake_ws = object()
svc.register_websocket("abc123", fake_ws)
assert svc._websockets["abc123"] is fake_ws
svc.unregister_websocket("abc123")
assert "abc123" not in svc._websockets
def test_hangup_clears_websocket():
svc = CallerService()
svc.add_to_queue("abc123", "Dave")
svc.take_call("abc123")
svc.register_websocket("abc123", object())
svc.hangup("abc123")
assert "abc123" not in svc._websockets
def test_reset_clears_websockets():
svc = CallerService()
svc.register_websocket("id1", object())
svc.register_websocket("id2", object())
svc.reset()
assert svc._websockets == {}
def test_send_audio_no_websocket():
"""send_audio_to_caller returns silently when no WS registered"""
import asyncio
svc = CallerService()
asyncio.get_event_loop().run_until_complete(
svc.send_audio_to_caller("NONE", b"\x00" * 100, 16000)
)
def test_notify_caller():
"""notify_caller sends JSON text to WebSocket"""
import asyncio
class FakeWS:
def __init__(self):
self.sent = []
async def send_text(self, data):
self.sent.append(data)
svc = CallerService()
ws = FakeWS()
svc.register_websocket("abc123", ws)
asyncio.get_event_loop().run_until_complete(
svc.notify_caller("abc123", {"status": "on_air", "channel": 3})
)
assert len(ws.sent) == 1
import json
msg = json.loads(ws.sent[0])
assert msg["status"] == "on_air"
assert msg["channel"] == 3
def test_disconnect_caller():
"""disconnect_caller sends disconnected message and removes WS"""
import asyncio
class FakeWS:
def __init__(self):
self.sent = []
self.closed = False
async def send_text(self, data):
self.sent.append(data)
async def close(self):
self.closed = True
svc = CallerService()
ws = FakeWS()
svc.register_websocket("abc123", ws)
asyncio.get_event_loop().run_until_complete(
svc.disconnect_caller("abc123")
)
assert ws.closed
assert "abc123" not in svc._websockets
import json
msg = json.loads(ws.sent[0])
assert msg["status"] == "disconnected"
def test_send_audio_binary():
"""send_audio_to_caller sends raw PCM bytes (not mulaw/JSON)"""
import asyncio
class FakeWS:
def __init__(self):
self.sent_bytes = []
async def send_bytes(self, data):
self.sent_bytes.append(data)
svc = CallerService()
ws = FakeWS()
svc.register_websocket("abc123", ws)
pcm = b"\x00\x01" * 100
asyncio.get_event_loop().run_until_complete(
svc.send_audio_to_caller("abc123", pcm, 16000)
)
assert len(ws.sent_bytes) == 1
assert ws.sent_bytes[0] == pcm
def test_take_call_preserves_caller_name():
"""take_call uses the name from the queue, not a generic counter name"""
svc = CallerService()
svc.add_to_queue("abc123", "Dave from Chicago")
result = svc.take_call("abc123")
assert result["name"] == "Dave from Chicago"

39
tests/test_followup.py Normal file
View File

@@ -0,0 +1,39 @@
import sys
sys.path.insert(0, "/Users/lukemacneil/ai-podcast")
from backend.main import Session, CallRecord, get_caller_prompt
def test_caller_prompt_includes_show_history():
s = Session()
s.call_history.append(CallRecord(
caller_type="real", caller_name="Dave",
summary="Called about his wife leaving after 12 years",
transcript=[],
))
s.start_call("1") # Tony
caller = s.caller
show_history = s.get_show_history()
prompt = get_caller_prompt(caller, "", show_history)
assert "Dave" in prompt
assert "wife leaving" in prompt
assert "EARLIER IN THE SHOW" in prompt
def test_caller_prompt_without_history():
s = Session()
s.start_call("1")
caller = s.caller
prompt = get_caller_prompt(caller, "")
assert "EARLIER IN THE SHOW" not in prompt
assert caller["name"] in prompt
def test_caller_prompt_backward_compatible():
"""Verify get_caller_prompt works with just 2 args (no show_history)"""
s = Session()
s.start_call("1")
caller = s.caller
prompt = get_caller_prompt(caller, "Host: hello")
assert "hello" in prompt

View File

@@ -30,8 +30,8 @@ def test_session_active_real_caller():
s = Session()
assert s.active_real_caller is None
s.active_real_caller = {
"call_sid": "CA123", "phone": "+15125550142",
"channel": 3, "name": "Caller #1",
"caller_id": "abc123",
"channel": 3, "name": "Dave",
}
assert s.active_real_caller["channel"] == 3
@@ -70,7 +70,7 @@ def test_session_reset_clears_history():
caller_type="real", caller_name="Dave",
summary="test", transcript=[],
))
s.active_real_caller = {"call_sid": "CA123"}
s.active_real_caller = {"caller_id": "abc123"}
s.ai_respond_mode = "auto"
s.reset()
assert s.call_history == []

View File

@@ -1,103 +0,0 @@
import sys
sys.path.insert(0, "/Users/lukemacneil/ai-podcast")
from backend.services.twilio_service import TwilioService
def test_queue_starts_empty():
svc = TwilioService()
assert svc.get_queue() == []
def test_add_caller_to_queue():
svc = TwilioService()
svc.add_to_queue("CA123", "+15125550142")
q = svc.get_queue()
assert len(q) == 1
assert q[0]["call_sid"] == "CA123"
assert q[0]["phone"] == "+15125550142"
assert "wait_time" in q[0]
def test_remove_caller_from_queue():
svc = TwilioService()
svc.add_to_queue("CA123", "+15125550142")
svc.remove_from_queue("CA123")
assert svc.get_queue() == []
def test_allocate_channel():
svc = TwilioService()
ch1 = svc.allocate_channel()
ch2 = svc.allocate_channel()
assert ch1 == 3
assert ch2 == 4
svc.release_channel(ch1)
ch3 = svc.allocate_channel()
assert ch3 == 3
def test_take_call():
svc = TwilioService()
svc.add_to_queue("CA123", "+15125550142")
result = svc.take_call("CA123")
assert result["call_sid"] == "CA123"
assert result["channel"] >= 3
assert svc.get_queue() == []
assert svc.active_calls["CA123"]["channel"] == result["channel"]
def test_hangup_real_caller():
svc = TwilioService()
svc.add_to_queue("CA123", "+15125550142")
svc.take_call("CA123")
ch = svc.active_calls["CA123"]["channel"]
svc.hangup("CA123")
assert "CA123" not in svc.active_calls
assert ch not in svc._allocated_channels
def test_caller_counter_increments():
svc = TwilioService()
svc.add_to_queue("CA1", "+15125550001")
svc.add_to_queue("CA2", "+15125550002")
r1 = svc.take_call("CA1")
r2 = svc.take_call("CA2")
assert r1["name"] == "Caller #1"
assert r2["name"] == "Caller #2"
def test_register_and_unregister_websocket():
svc = TwilioService()
fake_ws = object()
svc.register_websocket("CA123", fake_ws)
assert svc._websockets["CA123"] is fake_ws
svc.unregister_websocket("CA123")
assert "CA123" not in svc._websockets
def test_hangup_clears_websocket():
svc = TwilioService()
svc.add_to_queue("CA123", "+15125550142")
svc.take_call("CA123")
svc.register_websocket("CA123", object())
svc.hangup("CA123")
assert "CA123" not in svc._websockets
def test_reset_clears_websockets():
svc = TwilioService()
svc.register_websocket("CA1", object())
svc.register_websocket("CA2", object())
svc.reset()
assert svc._websockets == {}
def test_send_audio_no_websocket():
"""send_audio_to_caller returns silently when no WS registered"""
import asyncio
svc = TwilioService()
# Should not raise
asyncio.get_event_loop().run_until_complete(
svc.send_audio_to_caller("CA_NONE", b"\x00" * 100, 8000)
)