Files
ai-podcast/docs/plans/2026-02-05-signalwire-implementation.md
tcpsyn 356bf145b8 Add show improvement features: crossfade, emotions, returning callers, transcripts, screening
- 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>
2026-02-07 02:43:01 -07:00

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:

  1. Update docstring (line 1): """Phone caller queue and audio stream service"""

  2. In add_to_queue (line 24): Change parameter name to phone, 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})")
  1. In get_queue (line 38): Return phone instead of name:
    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
            ]
  1. In take_call (line 62): Use phone instead of name:
    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
  1. In hangup (line 89): Use phone instead of name:
    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 namephone 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 namephone 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:

  1. Go to Phone Numbers → 208-439-5853
  2. Set "When a call comes in" to: https://radioshow.macneilmediagroup.com/api/signalwire/voice
  3. Method: POST
  4. Handler type: LaML Webhooks

Step 5: Test with a real call

Call 208-439-5853 from a phone. Expected:

  1. Call connects (no ringing/hold — goes straight to stream)
  2. Caller appears in queue on host dashboard with phone number
  3. Host clicks "Take Call" → audio flows bidirectionally
  4. 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.