""" 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) + 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 → 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""" Sannu, barka da zuwa PlotWeaver. Muna jira… """ 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'{audio_url}' ) 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""" {message_ha or message_en} """ 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 # mu-law is stored inverted sign = u & 0x80 exponent = (u >> 4) & 0x07 mantissa = u & 0x0F # ITU-T G.711: t = ((mantissa << 3) + BIAS) << exponent, BIAS = 0x84 t = ((mantissa << 3) + 0x84) << exponent pcm8k = np.where(sign != 0, 0x84 - t, t - 0x84).astype(np.int16) # 8kHz → 16kHz (linear interpolation; the band-limited content of a # phone call makes a higher-order filter unnecessary here) if len(pcm8k) == 0: return b"" x = np.arange(len(pcm8k)) xi = np.arange(len(pcm8k) * 2) / 2.0 # exact 2x: 0, 0.5, 1, 1.5, … 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""" Sannu da zuwa PlotWeaver. """ 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"} # Production: PATCH /calls/{callId} with BXML 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() # demo mode 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}