File size: 7,086 Bytes
de7fd77 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 | """
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}
|