Toadoum's picture
Upload 7 files
de7fd77 verified
Raw
History Blame Contribute Delete
7.09 kB
"""
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 # 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}