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}