Spaces:
Sleeping
Sleeping
| """ | |
| 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} | |
| 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"""<?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"} | |
| # 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} | |