Compare commits
10 Commits
c82420ddad
...
41ddc8ee35
| Author | SHA1 | Date | |
|---|---|---|---|
| 41ddc8ee35 | |||
| a72c1eb795 | |||
| 82ad234480 | |||
| 863a81f87b | |||
| bf140a77b7 | |||
| 06f334359e | |||
| 3961cfc9d4 | |||
| db134262fb | |||
| 8dc1d62487 | |||
| 141f81232e |
@@ -15,12 +15,6 @@ class Settings(BaseSettings):
|
|||||||
openrouter_api_key: str = os.getenv("OPENROUTER_API_KEY", "")
|
openrouter_api_key: str = os.getenv("OPENROUTER_API_KEY", "")
|
||||||
inworld_api_key: str = os.getenv("INWORLD_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 Settings
|
||||||
llm_provider: str = "openrouter" # "openrouter" or "ollama"
|
llm_provider: str = "openrouter" # "openrouter" or "ollama"
|
||||||
openrouter_model: str = "anthropic/claude-3-haiku"
|
openrouter_model: str = "anthropic/claude-3-haiku"
|
||||||
|
|||||||
396
backend/main.py
396
backend/main.py
@@ -4,20 +4,17 @@ import uuid
|
|||||||
import asyncio
|
import asyncio
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
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.staticfiles import StaticFiles
|
||||||
from fastapi.responses import FileResponse, Response
|
from fastapi.responses import FileResponse
|
||||||
from twilio.twiml.voice_response import VoiceResponse
|
|
||||||
import json
|
import json
|
||||||
import base64
|
|
||||||
import audioop
|
|
||||||
import time
|
import time
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from .config import settings
|
from .config import settings
|
||||||
from .services.twilio_service import TwilioService
|
from .services.caller_service import CallerService
|
||||||
from .services.transcription import transcribe_audio
|
from .services.transcription import transcribe_audio
|
||||||
from .services.llm import llm_service
|
from .services.llm import llm_service
|
||||||
from .services.tts import generate_speech
|
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}."""
|
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"""
|
"""Generate a natural system prompt for a caller"""
|
||||||
context = ""
|
context = ""
|
||||||
if conversation_summary:
|
if conversation_summary:
|
||||||
@@ -257,10 +254,14 @@ CONVERSATION SO FAR:
|
|||||||
Continue naturally. Don't repeat yourself.
|
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.
|
return f"""You're {caller['name']}, calling a late-night radio show. You trust this host.
|
||||||
|
|
||||||
{caller['vibe']}
|
{caller['vibe']}
|
||||||
{context}
|
{history}{context}
|
||||||
HOW TO TALK:
|
HOW TO TALK:
|
||||||
- Sound like a real person chatting, not writing.
|
- 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.
|
- 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()
|
session = Session()
|
||||||
twilio_service = TwilioService()
|
caller_service = CallerService()
|
||||||
|
|
||||||
|
|
||||||
# --- Static Files ---
|
# --- Static Files ---
|
||||||
@@ -415,6 +416,11 @@ async def index():
|
|||||||
return FileResponse(frontend_dir / "index.html")
|
return FileResponse(frontend_dir / "index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/call-in")
|
||||||
|
async def call_in_page():
|
||||||
|
return FileResponse(frontend_dir / "call-in.html")
|
||||||
|
|
||||||
|
|
||||||
# --- Request Models ---
|
# --- Request Models ---
|
||||||
|
|
||||||
class ChatRequest(BaseModel):
|
class ChatRequest(BaseModel):
|
||||||
@@ -611,9 +617,10 @@ async def chat(request: ChatRequest):
|
|||||||
|
|
||||||
session.add_message("user", request.text)
|
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()
|
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(
|
response = await llm_service.generate(
|
||||||
messages=session.conversation[-10:], # Reduced history for speed
|
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
|
# Also send to active real callers so they hear the AI
|
||||||
if session.active_real_caller:
|
if session.active_real_caller:
|
||||||
call_sid = session.active_real_caller["call_sid"]
|
caller_id = session.active_real_caller["caller_id"]
|
||||||
asyncio.create_task(
|
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}
|
return {"status": "playing", "duration": len(audio_bytes) / 2 / 24000}
|
||||||
@@ -775,67 +782,143 @@ async def update_settings(data: dict):
|
|||||||
return llm_service.get_settings()
|
return llm_service.get_settings()
|
||||||
|
|
||||||
|
|
||||||
# --- Twilio Webhook & Queue Endpoints ---
|
# --- Browser Caller WebSocket ---
|
||||||
|
|
||||||
@app.post("/api/twilio/voice")
|
@app.websocket("/api/caller/stream")
|
||||||
async def twilio_voice_webhook(
|
async def caller_audio_stream(websocket: WebSocket):
|
||||||
CallSid: str = Form(...),
|
"""Handle browser caller WebSocket — bidirectional audio"""
|
||||||
From: str = Form(...),
|
await websocket.accept()
|
||||||
):
|
|
||||||
"""Handle incoming Twilio call — greet and enqueue"""
|
|
||||||
twilio_service.add_to_queue(CallSid, From)
|
|
||||||
|
|
||||||
response = VoiceResponse()
|
caller_id = str(uuid.uuid4())[:8]
|
||||||
response.say("You're calling Luke at the Roost. Hold tight, we'll get to you.", voice="alice")
|
caller_name = "Anonymous"
|
||||||
response.enqueue(
|
audio_buffer = bytearray()
|
||||||
"radio_show",
|
CHUNK_DURATION_S = 3
|
||||||
wait_url="/api/twilio/hold-music",
|
SAMPLE_RATE = 16000
|
||||||
wait_url_method="POST",
|
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")
|
# --- Host Audio Broadcast ---
|
||||||
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")
|
|
||||||
|
|
||||||
|
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")
|
@app.get("/api/queue")
|
||||||
async def get_call_queue():
|
async def get_call_queue():
|
||||||
"""Get list of callers waiting in 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}")
|
@app.post("/api/queue/take/{caller_id}")
|
||||||
async def take_call_from_queue(call_sid: str):
|
async def take_call_from_queue(caller_id: str):
|
||||||
"""Take a caller off hold and put them on air"""
|
"""Take a caller off hold and put them on air"""
|
||||||
try:
|
try:
|
||||||
call_info = twilio_service.take_call(call_sid)
|
call_info = caller_service.take_call(caller_id)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(404, str(e))
|
raise HTTPException(404, str(e))
|
||||||
|
|
||||||
session.active_real_caller = {
|
session.active_real_caller = {
|
||||||
"call_sid": call_info["call_sid"],
|
"caller_id": call_info["caller_id"],
|
||||||
"phone": call_info["phone"],
|
|
||||||
"channel": call_info["channel"],
|
"channel": call_info["channel"],
|
||||||
"name": call_info["name"],
|
"name": call_info["name"],
|
||||||
}
|
}
|
||||||
|
|
||||||
# Connect Twilio media stream by updating the call
|
# Notify caller they're on air via WebSocket
|
||||||
from twilio.rest import Client as TwilioClient
|
await caller_service.notify_caller(caller_id, {"status": "on_air", "channel": call_info["channel"]})
|
||||||
if settings.twilio_account_sid and settings.twilio_auth_token:
|
|
||||||
client = TwilioClient(settings.twilio_account_sid, settings.twilio_auth_token)
|
# Start host mic streaming if this is the first real caller
|
||||||
twiml = VoiceResponse()
|
if len(caller_service.active_calls) == 1:
|
||||||
connect = twiml.connect()
|
audio_service.start_host_stream(_host_audio_sync_callback)
|
||||||
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))
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "on_air",
|
"status": "on_air",
|
||||||
@@ -843,97 +926,17 @@ async def take_call_from_queue(call_sid: str):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/queue/drop/{call_sid}")
|
@app.post("/api/queue/drop/{caller_id}")
|
||||||
async def drop_from_queue(call_sid: str):
|
async def drop_from_queue(caller_id: str):
|
||||||
"""Drop a caller from the queue"""
|
"""Drop a caller from the queue"""
|
||||||
twilio_service.remove_from_queue(call_sid)
|
caller_service.remove_from_queue(caller_id)
|
||||||
|
await caller_service.disconnect_caller(caller_id)
|
||||||
# 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}")
|
|
||||||
|
|
||||||
return {"status": "dropped"}
|
return {"status": "dropped"}
|
||||||
|
|
||||||
|
|
||||||
# --- Twilio WebSocket Media Stream ---
|
async def _handle_real_caller_transcription(caller_id: str, pcm_data: bytes, sample_rate: int):
|
||||||
|
|
||||||
@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):
|
|
||||||
"""Transcribe a chunk of real caller audio and add to conversation"""
|
"""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:
|
if not call_info:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -979,7 +982,8 @@ async def _check_ai_auto_respond(real_caller_text: str, real_caller_name: str):
|
|||||||
|
|
||||||
# Generate full response
|
# Generate full response
|
||||||
conversation_summary = session.get_conversation_summary()
|
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(
|
response = await llm_service.generate(
|
||||||
messages=session.conversation[-10:],
|
messages=session.conversation[-10:],
|
||||||
@@ -1003,6 +1007,120 @@ async def _check_ai_auto_respond(real_caller_text: str, real_caller_name: str):
|
|||||||
thread.start()
|
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 ---
|
# --- Server Control Endpoints ---
|
||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ class AudioService:
|
|||||||
self._caller_stop_event = threading.Event()
|
self._caller_stop_event = threading.Event()
|
||||||
self._caller_thread: Optional[threading.Thread] = None
|
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
|
# Sample rates
|
||||||
self.input_sample_rate = 16000 # For Whisper
|
self.input_sample_rate = 16000 # For Whisper
|
||||||
self.output_sample_rate = 24000 # For TTS
|
self.output_sample_rate = 24000 # For TTS
|
||||||
@@ -325,7 +329,7 @@ class AudioService:
|
|||||||
device_sr = int(device_info['default_samplerate'])
|
device_sr = int(device_info['default_samplerate'])
|
||||||
channel_idx = min(channel, num_channels) - 1
|
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:
|
if sample_rate != device_sr:
|
||||||
audio = librosa.resample(audio, orig_sr=sample_rate, target_sr=device_sr)
|
audio = librosa.resample(audio, orig_sr=sample_rate, target_sr=device_sr)
|
||||||
|
|
||||||
@@ -345,6 +349,55 @@ class AudioService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Real caller audio routing error: {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 ---
|
# --- Music Playback ---
|
||||||
|
|
||||||
def load_music(self, file_path: str) -> bool:
|
def load_music(self, file_path: str) -> bool:
|
||||||
|
|||||||
147
backend/services/caller_service.py
Normal file
147
backend/services/caller_service.py
Normal 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)
|
||||||
@@ -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
155
frontend/call-in.html
Normal 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>
|
||||||
@@ -541,3 +541,38 @@ section h2 {
|
|||||||
.server-log .log-line.chat {
|
.server-log .log-line.chat {
|
||||||
color: #f8f;
|
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; }
|
||||||
|
|||||||
@@ -21,11 +21,44 @@
|
|||||||
<section class="callers-section">
|
<section class="callers-section">
|
||||||
<h2>Callers <span id="session-id" class="session-id"></span></h2>
|
<h2>Callers <span id="session-id" class="session-id"></span></h2>
|
||||||
<div id="callers" class="caller-grid"></div>
|
<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="call-status" class="call-status">No active call</div>
|
||||||
<div id="caller-background" class="caller-background hidden"></div>
|
<div id="caller-background" class="caller-background hidden"></div>
|
||||||
<button id="hangup-btn" class="hangup-btn" disabled>Hang Up</button>
|
<button id="hangup-btn" class="hangup-btn" disabled>Hang Up</button>
|
||||||
</section>
|
</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 -->
|
<!-- Chat -->
|
||||||
<section class="chat-section">
|
<section class="chat-section">
|
||||||
<div id="chat" class="chat-log"></div>
|
<div id="chat" class="chat-log"></div>
|
||||||
@@ -173,6 +206,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/app.js?v=8"></script>
|
<script src="/js/app.js?v=9"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ function initEventListeners() {
|
|||||||
// Start log polling
|
// Start log polling
|
||||||
startLogPolling();
|
startLogPolling();
|
||||||
|
|
||||||
|
// Start queue polling
|
||||||
|
startQueuePolling();
|
||||||
|
|
||||||
// Talk button - now triggers server-side recording
|
// Talk button - now triggers server-side recording
|
||||||
const talkBtn = document.getElementById('talk-btn');
|
const talkBtn = document.getElementById('talk-btn');
|
||||||
if (talkBtn) {
|
if (talkBtn) {
|
||||||
@@ -97,6 +100,45 @@ function initEventListeners() {
|
|||||||
phoneFilter = e.target.checked;
|
phoneFilter = e.target.checked;
|
||||||
});
|
});
|
||||||
document.getElementById('refresh-ollama')?.addEventListener('click', refreshOllamaModels);
|
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 };
|
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('call-status').textContent = `On call: ${name}`;
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('hangup-btn').disabled = false;
|
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
|
// Show caller background
|
||||||
const bgEl = document.getElementById('caller-background');
|
const bgEl = document.getElementById('caller-background');
|
||||||
if (bgEl && data.background) {
|
if (bgEl && data.background) {
|
||||||
@@ -287,8 +344,10 @@ async function startCall(key, name) {
|
|||||||
btn.classList.toggle('active', btn.dataset.key === key);
|
btn.classList.toggle('active', btn.dataset.key === key);
|
||||||
});
|
});
|
||||||
|
|
||||||
log(`Connected to ${name}`);
|
log(`Connected to ${name}` + (realCallerActive ? ' (three-way)' : ''));
|
||||||
clearChat();
|
if (!realCallerActive) clearChat();
|
||||||
|
|
||||||
|
updateActiveCallIndicator();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -314,7 +373,6 @@ async function newSession() {
|
|||||||
async function hangup() {
|
async function hangup() {
|
||||||
if (!currentCaller) return;
|
if (!currentCaller) return;
|
||||||
|
|
||||||
// Stop any playing TTS
|
|
||||||
await fetch('/api/tts/stop', { method: 'POST' });
|
await fetch('/api/tts/stop', { method: 'POST' });
|
||||||
await fetch('/api/hangup', { method: 'POST' });
|
await fetch('/api/hangup', { method: 'POST' });
|
||||||
|
|
||||||
@@ -331,6 +389,10 @@ async function hangup() {
|
|||||||
// Hide caller background
|
// Hide caller background
|
||||||
const bgEl = document.getElementById('caller-background');
|
const bgEl = document.getElementById('caller-background');
|
||||||
if (bgEl) bgEl.classList.add('hidden');
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const div = document.createElement('div');
|
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}`;
|
div.innerHTML = `<strong>${sender}:</strong> ${text}`;
|
||||||
chat.appendChild(div);
|
chat.appendChild(div);
|
||||||
chat.scrollTop = chat.scrollHeight;
|
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() {
|
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;
|
||||||
|
|
||||||
|
|||||||
232
frontend/js/call-in.js
Normal file
232
frontend/js/call-in.js
Normal 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();
|
||||||
|
}
|
||||||
180
tests/test_caller_service.py
Normal file
180
tests/test_caller_service.py
Normal 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
39
tests/test_followup.py
Normal 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
|
||||||
@@ -30,8 +30,8 @@ def test_session_active_real_caller():
|
|||||||
s = Session()
|
s = Session()
|
||||||
assert s.active_real_caller is None
|
assert s.active_real_caller is None
|
||||||
s.active_real_caller = {
|
s.active_real_caller = {
|
||||||
"call_sid": "CA123", "phone": "+15125550142",
|
"caller_id": "abc123",
|
||||||
"channel": 3, "name": "Caller #1",
|
"channel": 3, "name": "Dave",
|
||||||
}
|
}
|
||||||
assert s.active_real_caller["channel"] == 3
|
assert s.active_real_caller["channel"] == 3
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ def test_session_reset_clears_history():
|
|||||||
caller_type="real", caller_name="Dave",
|
caller_type="real", caller_name="Dave",
|
||||||
summary="test", transcript=[],
|
summary="test", transcript=[],
|
||||||
))
|
))
|
||||||
s.active_real_caller = {"call_sid": "CA123"}
|
s.active_real_caller = {"caller_id": "abc123"}
|
||||||
s.ai_respond_mode = "auto"
|
s.ai_respond_mode = "auto"
|
||||||
s.reset()
|
s.reset()
|
||||||
assert s.call_history == []
|
assert s.call_history == []
|
||||||
|
|||||||
@@ -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)
|
|
||||||
)
|
|
||||||
Reference in New Issue
Block a user