| """ |
| Phone / SIP Integration (Twilio + Bandwidth stubs) |
| ===================================================== |
| Handles inbound calls → streams audio → runs pipeline → streams TTS back. |
| |
| For a real deployment, use: |
| - Twilio Media Streams (WebSocket) + <Stream> TwiML verb |
| - Bandwidth BXML + WebSocket audio streaming |
| - Vonage Voice API + WebSocket |
| |
| Environment variables: |
| TWILIO_ACCOUNT_SID = ACxxxx |
| TWILIO_AUTH_TOKEN = xxxx |
| TWILIO_PHONE_NUMBER = +1234567890 |
| SIP_PROVIDER = twilio | bandwidth | demo |
| """ |
|
|
| import os |
| import logging |
| from typing import Callable, Optional |
|
|
| logger = logging.getLogger(__name__) |
|
|
|
|
| class TwilioAdapter: |
| """ |
| Twilio Media Streams WebSocket adapter. |
| |
| Flow: |
| 1. Inbound call → Twilio webhook → /voice endpoint |
| 2. Return TwiML with <Connect><Stream> → Twilio opens WS |
| 3. WebSocket handler receives mulaw 8kHz chunks |
| 4. Chunks accumulated → ASR → NLU → TTS → send back over WS |
| """ |
|
|
| def __init__(self): |
| self.sid = os.getenv("TWILIO_ACCOUNT_SID", "DEMO") |
| self.token = os.getenv("TWILIO_AUTH_TOKEN", "DEMO") |
| self.phone = os.getenv("TWILIO_PHONE_NUMBER", "+0000000000") |
| self._demo = self.sid == "DEMO" |
|
|
| def incoming_call_twiml(self, websocket_url: str) -> str: |
| """ |
| Returns TwiML that Twilio will execute when a call arrives. |
| websocket_url: wss://yourserver.com/ws/audio |
| """ |
| return f"""<?xml version="1.0" encoding="UTF-8"?> |
| <Response> |
| <Say language="ha-NG">Sannu, barka da zuwa PlotWeaver. Muna jira…</Say> |
| <Connect> |
| <Stream url="{websocket_url}"> |
| <Parameter name="language" value="hausa"/> |
| </Stream> |
| </Connect> |
| </Response>""" |
|
|
| def handle_ws_message(self, message: dict, |
| on_audio_chunk: Callable[[bytes], None]) -> None: |
| """ |
| Called for each WebSocket message from Twilio. |
| Twilio sends: start, media (base64 mulaw), stop events. |
| """ |
| import base64 |
| event = message.get("event") |
| if event == "media": |
| chunk = base64.b64decode(message["media"]["payload"]) |
| on_audio_chunk(chunk) |
| elif event == "stop": |
| logger.info(f"Call ended: {message.get('stop', {}).get('callSid')}") |
|
|
| def send_audio_twiml(self, call_sid: str, audio_url: str) -> dict: |
| """ |
| Interrupt the current call and play synthesised audio. |
| Production: POST to Twilio API to update call. |
| """ |
| if self._demo: |
| logger.info(f"[DEMO] Would play {audio_url} on call {call_sid}") |
| return {"status": "demo"} |
| from twilio.rest import Client |
| client = Client(self.sid, self.token) |
| call = client.calls(call_sid).update( |
| twiml=f'<Response><Play>{audio_url}</Play></Response>' |
| ) |
| return {"status": call.status} |
|
|
| def make_outbound_call(self, to: str, message_en: str, |
| message_ha: str = "") -> dict: |
| """Outbound IVR call with TTS message.""" |
| twiml = f"""<?xml version="1.0" encoding="UTF-8"?> |
| <Response> |
| <Say language="ha-NG">{message_ha or message_en}</Say> |
| </Response>""" |
| if self._demo: |
| logger.info(f"[DEMO] Outbound to {to}: {message_en[:60]}…") |
| return {"status": "demo_queued", "to": to} |
| from twilio.rest import Client |
| client = Client(self.sid, self.token) |
| call = client.calls.create( |
| to=to, from_=self.phone, twiml=twiml |
| ) |
| return {"sid": call.sid, "status": call.status} |
|
|
| @staticmethod |
| def mulaw_to_pcm(mulaw_bytes: bytes) -> bytes: |
| """ |
| Convert 8kHz G.711 mu-law to 16-bit PCM at 16kHz for Whisper. |
| |
| Implemented in numpy rather than the stdlib `audioop` module, which |
| was removed in Python 3.13. Keeping this dependency-free means the |
| telephony path works on any modern image. |
| """ |
| import numpy as np |
|
|
| u = np.frombuffer(mulaw_bytes, dtype=np.uint8).astype(np.int32) |
| u = ~u & 0xFF |
| sign = u & 0x80 |
| exponent = (u >> 4) & 0x07 |
| mantissa = u & 0x0F |
| |
| t = ((mantissa << 3) + 0x84) << exponent |
| pcm8k = np.where(sign != 0, 0x84 - t, t - 0x84).astype(np.int16) |
|
|
| |
| |
| if len(pcm8k) == 0: |
| return b"" |
| x = np.arange(len(pcm8k)) |
| xi = np.arange(len(pcm8k) * 2) / 2.0 |
| pcm16k = np.interp(xi, x, pcm8k).astype(np.int16) |
| return pcm16k.tobytes() |
|
|
|
|
| class BandwidthAdapter: |
| """Bandwidth BXML + WebSocket audio streaming (stub).""" |
|
|
| def __init__(self): |
| self.account_id = os.getenv("BANDWIDTH_ACCOUNT_ID", "DEMO") |
| self.api_token = os.getenv("BANDWIDTH_API_TOKEN", "DEMO") |
| self._demo = self.account_id == "DEMO" |
|
|
| def incoming_call_bxml(self, websocket_url: str) -> str: |
| return f"""<?xml version="1.0" encoding="UTF-8"?> |
| <Response> |
| <SpeakSentence locale="ha-NG">Sannu da zuwa PlotWeaver.</SpeakSentence> |
| <StartStream url="{websocket_url}" streamEventUrl="{websocket_url}/events"/> |
| </Response>""" |
|
|
| def send_tts(self, call_id: str, text: str, locale: str = "ha-NG") -> dict: |
| if self._demo: |
| logger.info(f"[DEMO] Bandwidth TTS on call {call_id}: {text[:60]}…") |
| return {"status": "demo"} |
| |
| raise NotImplementedError |
|
|
|
|
| class SIPRouter: |
| """ |
| Routes a call to the correct adapter based on SIP_PROVIDER env var. |
| Also manages human-agent transfer via SIP REFER. |
| """ |
|
|
| PROVIDERS = {"twilio": TwilioAdapter, "bandwidth": BandwidthAdapter} |
|
|
| def __init__(self): |
| provider = os.getenv("SIP_PROVIDER", "demo").lower() |
| if provider in self.PROVIDERS: |
| self.adapter = self.PROVIDERS[provider]() |
| else: |
| self.adapter = TwilioAdapter() |
| logger.info(f"SIP provider: {provider}") |
|
|
| def transfer_to_human(self, call_sid: str, |
| agent_extension: str = "+0000000001") -> dict: |
| """ |
| REFER / warm transfer to human agent queue. |
| In demo mode just logs. |
| """ |
| logger.info(f"[SIP] Transferring {call_sid} → agent {agent_extension}") |
| if isinstance(self.adapter, TwilioAdapter) and not self.adapter._demo: |
| from twilio.rest import Client |
| client = Client(self.adapter.sid, self.adapter.token) |
| call = client.calls(call_sid).update( |
| url=f"http://twimlets.com/forward?PhoneNumber={agent_extension}" |
| ) |
| return {"status": call.status, "agent": agent_extension} |
| return {"status": "demo_transfer", "agent": agent_extension} |
|
|