- Music crossfade: smooth 3-second blend between tracks instead of hard stop/start - Emotional detection: analyze host mood from recent messages so callers adapt tone - AI caller summaries: generate call summaries with timestamps for show history - Returning callers: persist regular callers across sessions with call history - Session export: generate transcripts with speaker labels and chapter markers - Caller screening: AI pre-screens phone callers to get name and topic while queued Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
26 KiB
SignalWire Phone Call-In Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Replace browser-based WebSocket call-in with real phone calls via SignalWire (208-439-5853).
Architecture: SignalWire hits our webhook on inbound calls, we return XML to open a bidirectional WebSocket stream with L16@16kHz audio. The existing queue, channel allocation, transcription, host mic streaming, and TTS streaming are reused — only the WebSocket message format changes (base64 JSON instead of raw binary).
Tech Stack: Python/FastAPI, SignalWire Compatibility API (LaML XML + WebSocket), httpx for REST calls, existing audio pipeline.
Task 1: Add SignalWire Config
Files:
- Modify:
backend/config.py - Modify:
.env
Step 1: Add SignalWire settings to config.py
In backend/config.py, add these fields to the Settings class after the existing API keys block (after line 16):
# SignalWire
signalwire_project_id: str = os.getenv("SIGNALWIRE_PROJECT_ID", "")
signalwire_space: str = os.getenv("SIGNALWIRE_SPACE", "")
signalwire_token: str = os.getenv("SIGNALWIRE_TOKEN", "")
signalwire_phone: str = os.getenv("SIGNALWIRE_PHONE", "")
Step 2: Add SignalWire vars to .env
Append to .env:
# SignalWire
SIGNALWIRE_PROJECT_ID=8eb54732-ade3-4487-8b40-ecd2cd680df7
SIGNALWIRE_SPACE=macneil-media-group-llc.signalwire.com
SIGNALWIRE_TOKEN=PT9c9b61f44ee49914c614fed32aa5c3d7b9372b5199d81dec
SIGNALWIRE_PHONE=+12084395853
Step 3: Verify config loads
cd /Users/lukemacneil/ai-podcast && python -c "from backend.config import settings; print(settings.signalwire_space)"
Expected: macneil-media-group-llc.signalwire.com
Step 4: Commit
git add backend/config.py .env
git commit -m "Add SignalWire configuration"
Task 2: Update CallerService for SignalWire Protocol
Files:
- Modify:
backend/services/caller_service.py
The CallerService currently sends raw binary PCM frames. SignalWire needs base64-encoded L16 PCM wrapped in JSON. Also swap name field to phone since callers now have phone numbers.
Step 1: Update queue to use phone instead of name
In caller_service.py, make these changes:
-
Update docstring (line 1):
"""Phone caller queue and audio stream service""" -
In
add_to_queue(line 24): Change parameternametophone, and update the dict:
def add_to_queue(self, caller_id: str, phone: str):
with self._lock:
self._queue.append({
"caller_id": caller_id,
"phone": phone,
"queued_at": time.time(),
})
print(f"[Caller] {phone} added to queue (ID: {caller_id})")
- In
get_queue(line 38): Returnphoneinstead ofname:
def get_queue(self) -> list[dict]:
now = time.time()
with self._lock:
return [
{
"caller_id": c["caller_id"],
"phone": c["phone"],
"wait_time": int(now - c["queued_at"]),
}
for c in self._queue
]
- In
take_call(line 62): Usephoneinstead ofname:
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
phone = caller["phone"]
call_info = {
"caller_id": caller_id,
"phone": phone,
"channel": channel,
"started_at": time.time(),
}
self.active_calls[caller_id] = call_info
print(f"[Caller] {phone} taken on air — channel {channel}")
return call_info
- In
hangup(line 89): Usephoneinstead ofname:
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['phone']} hung up — channel {call_info['channel']} released")
self._websockets.pop(caller_id, None)
Step 2: Update send_audio_to_caller for SignalWire JSON format
Replace the existing send_audio_to_caller method with:
async def send_audio_to_caller(self, caller_id: str, pcm_data: bytes, sample_rate: int):
"""Send small audio chunk to caller via SignalWire WebSocket.
Encodes L16 PCM as base64 JSON per SignalWire protocol.
"""
ws = self._websockets.get(caller_id)
if not ws:
return
try:
import base64
if sample_rate != 16000:
audio = np.frombuffer(pcm_data, dtype=np.int16).astype(np.float32) / 32768.0
ratio = 16000 / sample_rate
out_len = int(len(audio) * ratio)
indices = (np.arange(out_len) / ratio).astype(int)
indices = np.clip(indices, 0, len(audio) - 1)
audio = audio[indices]
pcm_data = (audio * 32767).astype(np.int16).tobytes()
payload = base64.b64encode(pcm_data).decode('ascii')
import json
await ws.send_text(json.dumps({
"event": "media",
"media": {"payload": payload}
}))
except Exception as e:
print(f"[Caller] Failed to send audio: {e}")
Step 3: Update stream_audio_to_caller for SignalWire JSON format
Replace the existing stream_audio_to_caller method with:
async def stream_audio_to_caller(self, caller_id: str, pcm_data: bytes, sample_rate: int):
"""Stream large audio (TTS) to caller in real-time chunks via SignalWire WebSocket."""
ws = self._websockets.get(caller_id)
if not ws:
return
self.streaming_tts = True
try:
import base64
import json
audio = np.frombuffer(pcm_data, dtype=np.int16).astype(np.float32) / 32768.0
if sample_rate != 16000:
ratio = 16000 / sample_rate
out_len = int(len(audio) * ratio)
indices = (np.arange(out_len) / ratio).astype(int)
indices = np.clip(indices, 0, len(audio) - 1)
audio = audio[indices]
chunk_samples = 960
for i in range(0, len(audio), chunk_samples):
if caller_id not in self._websockets:
break
chunk = audio[i:i + chunk_samples]
pcm_chunk = (chunk * 32767).astype(np.int16).tobytes()
payload = base64.b64encode(pcm_chunk).decode('ascii')
await ws.send_text(json.dumps({
"event": "media",
"media": {"payload": payload}
}))
await asyncio.sleep(0.055)
except Exception as e:
print(f"[Caller] Failed to stream audio: {e}")
finally:
self.streaming_tts = False
Step 4: Remove notify_caller and disconnect_caller methods
These sent browser-specific JSON control messages. SignalWire callers are disconnected via REST API (handled in main.py). Delete methods notify_caller (line 168) and disconnect_caller (line 175). They will be replaced with a REST-based hangup in Task 4.
Step 5: Add call_sid tracking for SignalWire call hangup
Add a dict to track SignalWire call SIDs so we can end calls via REST:
In __init__, after self._websockets line, add:
self._call_sids: dict[str, str] = {} # caller_id -> SignalWire callSid
Add methods:
def register_call_sid(self, caller_id: str, call_sid: str):
"""Track SignalWire callSid for a caller"""
self._call_sids[caller_id] = call_sid
def get_call_sid(self, caller_id: str) -> str | None:
"""Get SignalWire callSid for a caller"""
return self._call_sids.get(caller_id)
def unregister_call_sid(self, caller_id: str):
"""Remove callSid tracking"""
self._call_sids.pop(caller_id, None)
In reset, also clear self._call_sids:
self._call_sids.clear()
In hangup, also clean up call_sid:
self._call_sids.pop(caller_id, None)
Step 6: Run existing tests
cd /Users/lukemacneil/ai-podcast && python -m pytest tests/test_caller_service.py -v
Tests will likely need updates due to name → phone rename. Fix any failures.
Step 7: Commit
git add backend/services/caller_service.py
git commit -m "Update CallerService for SignalWire protocol"
Task 3: Add SignalWire Voice Webhook
Files:
- Modify:
backend/main.py
Step 1: Add the voice webhook endpoint
Add after the existing route definitions (after line 421), replacing the /call-in route:
# --- SignalWire Endpoints ---
from fastapi import Request, Response
@app.post("/api/signalwire/voice")
async def signalwire_voice_webhook(request: Request):
"""Handle inbound call from SignalWire — return XML to start bidirectional stream"""
form = await request.form()
caller_phone = form.get("From", "Unknown")
call_sid = form.get("CallSid", "")
print(f"[SignalWire] Inbound call from {caller_phone} (CallSid: {call_sid})")
# Build WebSocket URL from the request
ws_scheme = "wss"
host = request.headers.get("host", "radioshow.macneilmediagroup.com")
stream_url = f"{ws_scheme}://{host}/api/signalwire/stream"
xml = f"""<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Connect>
<Stream url="{stream_url}" codec="L16@16000h">
<Parameter name="caller_phone" value="{caller_phone}"/>
<Parameter name="call_sid" value="{call_sid}"/>
</Stream>
</Connect>
</Response>"""
return Response(content=xml, media_type="application/xml")
Step 2: Remove the /call-in route
Delete these lines (around line 419-421):
@app.get("/call-in")
async def call_in_page():
return FileResponse(frontend_dir / "call-in.html")
Step 3: Verify server starts
cd /Users/lukemacneil/ai-podcast && python -c "from backend.main import app; print('OK')"
Step 4: Commit
git add backend/main.py
git commit -m "Add SignalWire voice webhook, remove call-in route"
Task 4: Add SignalWire WebSocket Stream Handler
Files:
- Modify:
backend/main.py
This replaces the browser caller WebSocket handler at /api/caller/stream.
Step 1: Replace the browser WebSocket handler
Delete the entire caller_audio_stream function (the @app.websocket("/api/caller/stream") handler, lines 807-887).
Add the new SignalWire WebSocket handler:
@app.websocket("/api/signalwire/stream")
async def signalwire_audio_stream(websocket: WebSocket):
"""Handle SignalWire bidirectional audio stream"""
await websocket.accept()
caller_id = str(uuid.uuid4())[:8]
caller_phone = "Unknown"
call_sid = ""
audio_buffer = bytearray()
CHUNK_DURATION_S = 3
SAMPLE_RATE = 16000
chunk_samples = CHUNK_DURATION_S * SAMPLE_RATE
stream_started = False
try:
while True:
raw = await websocket.receive_text()
msg = json.loads(raw)
event = msg.get("event")
if event == "start":
# Extract caller info from stream parameters
params = {}
for p in msg.get("start", {}).get("customParameters", {}):
pass
# customParameters comes as a dict
custom = msg.get("start", {}).get("customParameters", {})
caller_phone = custom.get("caller_phone", "Unknown")
call_sid = custom.get("call_sid", "")
stream_started = True
print(f"[SignalWire WS] Stream started: {caller_phone} (CallSid: {call_sid})")
# Add to queue and register
caller_service.add_to_queue(caller_id, caller_phone)
caller_service.register_websocket(caller_id, websocket)
if call_sid:
caller_service.register_call_sid(caller_id, call_sid)
elif event == "media" and stream_started:
# Decode base64 L16 PCM audio
import base64
payload = msg.get("media", {}).get("payload", "")
if not payload:
continue
pcm_data = base64.b64decode(payload)
# Only process audio if caller is on air
call_info = caller_service.active_calls.get(caller_id)
if not call_info:
continue
audio_buffer.extend(pcm_data)
# Route to configured live caller Loopback channel
audio_service.route_real_caller_audio(pcm_data, 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 event == "stop":
print(f"[SignalWire WS] Stream stopped: {caller_phone}")
break
except WebSocketDisconnect:
print(f"[SignalWire WS] Disconnected: {caller_id} ({caller_phone})")
except Exception as e:
print(f"[SignalWire WS] Error: {e}")
finally:
caller_service.unregister_websocket(caller_id)
caller_service.unregister_call_sid(caller_id)
caller_service.remove_from_queue(caller_id)
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
if len(caller_service.active_calls) == 0:
audio_service.stop_host_stream()
if audio_buffer:
asyncio.create_task(
_handle_real_caller_transcription(caller_id, bytes(audio_buffer), SAMPLE_RATE)
)
Step 2: Commit
git add backend/main.py
git commit -m "Add SignalWire WebSocket stream handler, remove browser handler"
Task 5: Update Hangup and Queue Endpoints for SignalWire
Files:
- Modify:
backend/main.py
When the host hangs up or drops a caller, we need to end the actual phone call via SignalWire's REST API.
Step 1: Add SignalWire hangup helper
Add this function near the top of main.py (after imports):
async def _signalwire_end_call(call_sid: str):
"""End a phone call via SignalWire REST API"""
if not call_sid or not settings.signalwire_space:
return
try:
url = f"https://{settings.signalwire_space}/api/laml/2010-04-01/Accounts/{settings.signalwire_project_id}/Calls/{call_sid}"
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
url,
data={"Status": "completed"},
auth=(settings.signalwire_project_id, settings.signalwire_token),
)
print(f"[SignalWire] End call {call_sid}: {response.status_code}")
except Exception as e:
print(f"[SignalWire] Failed to end call {call_sid}: {e}")
Also add import httpx at the top of main.py if not already present.
Step 2: Update take_call_from_queue
In the take_call_from_queue endpoint, update name references to phone:
@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 = caller_service.take_call(caller_id)
except ValueError as e:
raise HTTPException(404, str(e))
session.active_real_caller = {
"caller_id": call_info["caller_id"],
"channel": call_info["channel"],
"phone": call_info["phone"],
}
# Start host mic streaming if this is the first real caller
if len(caller_service.active_calls) == 1:
_start_host_audio_sender()
audio_service.start_host_stream(_host_audio_sync_callback)
return {
"status": "on_air",
"caller": call_info,
}
Note: The notify_caller call is removed — SignalWire callers don't need a JSON status message, they're already connected via the phone.
Step 3: Update drop_from_queue
End the phone call when dropping:
@app.post("/api/queue/drop/{caller_id}")
async def drop_from_queue(caller_id: str):
"""Drop a caller from the queue"""
call_sid = caller_service.get_call_sid(caller_id)
caller_service.remove_from_queue(caller_id)
if call_sid:
await _signalwire_end_call(call_sid)
return {"status": "dropped"}
Step 4: Update hangup_real_caller
End the phone call when hanging up:
@app.post("/api/hangup/real")
async def hangup_real_caller():
"""Hang up on real caller — disconnect immediately, summarize in background"""
if not session.active_real_caller:
raise HTTPException(400, "No active real caller")
caller_id = session.active_real_caller["caller_id"]
caller_phone = session.active_real_caller["phone"]
conversation_snapshot = list(session.conversation)
auto_followup_enabled = session.auto_followup
# End the phone call via SignalWire
call_sid = caller_service.get_call_sid(caller_id)
caller_service.hangup(caller_id)
if call_sid:
asyncio.create_task(_signalwire_end_call(call_sid))
# 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 in background
import threading
hangup_sound = settings.sounds_dir / "hangup.wav"
if hangup_sound.exists():
threading.Thread(target=audio_service.play_sfx, args=(str(hangup_sound),), daemon=True).start()
# Summarize and store history in background
asyncio.create_task(
_summarize_real_call(caller_phone, conversation_snapshot, auto_followup_enabled)
)
return {
"status": "disconnected",
"caller": caller_phone,
}
Step 5: Update _handle_real_caller_transcription
Change caller_name to caller_phone:
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 = caller_service.active_calls.get(caller_id)
if not call_info:
return
text = await transcribe_audio(pcm_data, source_sample_rate=sample_rate)
if not text or not text.strip():
return
caller_phone = call_info["phone"]
print(f"[Real Caller] {caller_phone}: {text}")
session.add_message(f"real_caller:{caller_phone}", text)
if session.ai_respond_mode == "auto" and session.current_caller_key:
asyncio.create_task(_check_ai_auto_respond(text, caller_phone))
Step 6: Update _summarize_real_call
Change caller_name parameter to caller_phone:
async def _summarize_real_call(caller_phone: str, conversation: list, auto_followup_enabled: bool):
"""Background task: summarize call and store in history"""
summary = ""
if conversation:
transcript_text = "\n".join(
f"{msg['role']}: {msg['content']}" for msg in 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.",
)
session.call_history.append(CallRecord(
caller_type="real",
caller_name=caller_phone,
summary=summary,
transcript=conversation,
))
print(f"[Real Caller] {caller_phone} call summarized: {summary[:80]}...")
if auto_followup_enabled:
await _auto_followup(summary)
Step 7: Update _check_ai_auto_respond
Change parameter name from real_caller_name to real_caller_phone:
async def _check_ai_auto_respond(real_caller_text: str, real_caller_phone: str):
(The body doesn't use the name/phone parameter in any way that needs changing.)
Step 8: Update TTS streaming references
In text_to_speech endpoint and _check_ai_auto_respond, the session.active_real_caller dict now uses phone instead of name. No code change needed for the TTS streaming since it only uses caller_id.
Step 9: Verify server starts
cd /Users/lukemacneil/ai-podcast && python -c "from backend.main import app; print('OK')"
Step 10: Commit
git add backend/main.py
git commit -m "Update hangup and queue endpoints for SignalWire REST API"
Task 6: Update Frontend for Phone Callers
Files:
- Modify:
frontend/js/app.js - Modify:
frontend/index.html
Step 1: Update queue rendering in app.js
In renderQueue function (around line 875), change caller.name to caller.phone:
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.phone}</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('');
Step 2: Update takeCall log message
In takeCall function (around line 896), change data.caller.name to data.caller.phone:
if (data.status === 'on_air') {
showRealCaller(data.caller);
log(`${data.caller.phone} is on air — Channel ${data.caller.channel}`);
}
Step 3: Update showRealCaller to use phone
In showRealCaller function (around line 939):
function showRealCaller(callerInfo) {
const nameEl = document.getElementById('real-caller-name');
const chEl = document.getElementById('real-caller-channel');
if (nameEl) nameEl.textContent = callerInfo.phone;
if (chEl) chEl.textContent = `Ch ${callerInfo.channel}`;
Step 4: Update index.html queue section header
In frontend/index.html, change the queue section header (line 56) — remove the call-in page link:
<section class="queue-section">
<h2>Incoming Calls</h2>
<div id="call-queue" class="call-queue">
Step 5: Bump cache version in index.html
Find the app.js script tag and bump the version:
<script src="/js/app.js?v=13"></script>
Step 6: Commit
git add frontend/js/app.js frontend/index.html
git commit -m "Update frontend for phone caller display"
Task 7: Remove Browser Call-In Files
Files:
- Delete:
frontend/call-in.html - Delete:
frontend/js/call-in.js
Step 1: Delete files
cd /Users/lukemacneil/ai-podcast && rm frontend/call-in.html frontend/js/call-in.js
Step 2: Commit
git add frontend/call-in.html frontend/js/call-in.js
git commit -m "Remove browser call-in page"
Task 8: Update Tests
Files:
- Modify:
tests/test_caller_service.py
Step 1: Update tests for name → phone rename
Throughout test_caller_service.py, change:
add_to_queue(caller_id, "TestName")→add_to_queue(caller_id, "+15551234567")caller["name"]→caller["phone"]call_info["name"]→call_info["phone"]
Also remove any tests for notify_caller or disconnect_caller if they exist, since those methods were removed.
Step 2: Run all tests
cd /Users/lukemacneil/ai-podcast && python -m pytest tests/ -v
Expected: All pass.
Step 3: Commit
git add tests/
git commit -m "Update tests for SignalWire phone caller format"
Task 9: Configure SignalWire Webhook and End-to-End Test
Step 1: Start the server
cd /Users/lukemacneil/ai-podcast && python -m uvicorn backend.main:app --reload --host 0.0.0.0 --port 8000
Step 2: Verify webhook endpoint responds
curl -X POST http://localhost:8000/api/signalwire/voice \
-d "From=+15551234567&CallSid=test123" \
-H "Content-Type: application/x-www-form-urlencoded"
Expected: XML response with <Connect><Stream> containing the WebSocket URL.
Step 3: Verify Cloudflare tunnel is running
curl -s https://radioshow.macneilmediagroup.com/api/server/status
Expected: JSON response with "status": "running".
Step 4: Configure SignalWire webhook
In the SignalWire dashboard:
- Go to Phone Numbers → 208-439-5853
- Set "When a call comes in" to:
https://radioshow.macneilmediagroup.com/api/signalwire/voice - Method: POST
- Handler type: LaML Webhooks
Step 5: Test with a real call
Call 208-439-5853 from a phone. Expected:
- Call connects (no ringing/hold — goes straight to stream)
- Caller appears in queue on host dashboard with phone number
- Host clicks "Take Call" → audio flows bidirectionally
- Host clicks "Hang Up" → phone call ends
Step 6: Commit any fixes needed
git add -A
git commit -m "Final SignalWire integration fixes"
Summary
| Task | What | Key Files |
|---|---|---|
| 1 | SignalWire config | config.py, .env |
| 2 | CallerService protocol update | caller_service.py |
| 3 | Voice webhook endpoint | main.py |
| 4 | WebSocket stream handler | main.py |
| 5 | Hangup/queue via REST API | main.py |
| 6 | Frontend phone display | app.js, index.html |
| 7 | Remove browser call-in | call-in.html, call-in.js |
| 8 | Update tests | tests/ |
| 9 | Configure & test | SignalWire dashboard |
Tasks 1-5 are sequential backend. Task 6-7 are frontend (can parallel after task 5). Task 8 after task 2. Task 9 is final integration test.