UnleashX commited on
Commit
bbb89bf
·
verified ·
1 Parent(s): 9e53d4c

Upload 8 files

Browse files
Files changed (8) hide show
  1. Dockerfile +19 -0
  2. LANG.txt +0 -0
  3. README.md +3 -3
  4. app.py +301 -0
  5. output.mp3 +0 -0
  6. output.wav +0 -0
  7. requirements.txt +27 -0
  8. terminal_streamer.py +129 -0
Dockerfile ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.13
2
+
3
+ RUN useradd -m -u 1000 user
4
+ USER user
5
+ ENV PATH="/home/user/.local/bin:$PATH"
6
+
7
+ WORKDIR /app
8
+
9
+ COPY --chown=user ./requirements.txt requirements.txt
10
+ RUN pip install --no-cache-dir --upgrade -r requirements.txt
11
+
12
+ COPY --chown=user . /app
13
+
14
+ # Ensure audio folder exists and is writable
15
+ USER root
16
+ RUN mkdir -p /app/audio && chmod 777 /app/audio
17
+ USER user
18
+
19
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
LANG.txt ADDED
File without changes
README.md CHANGED
@@ -1,8 +1,8 @@
1
  ---
2
  title: Telephony
3
- emoji: 🐢
4
- colorFrom: blue
5
- colorTo: indigo
6
  sdk: docker
7
  pinned: false
8
  ---
 
1
  ---
2
  title: Telephony
3
+ emoji: 📉
4
+ colorFrom: green
5
+ colorTo: pink
6
  sdk: docker
7
  pinned: false
8
  ---
app.py ADDED
@@ -0,0 +1,301 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ #
3
+ # san_integration_script.py (v5 - Provider Format Fix)
4
+ # ======================================================
5
+ # Description:
6
+ # - Establishes a real-time, two-way audio bridge between a SAN system
7
+ # and the Millis AI platform.
8
+ # - Dynamically detects the audio format from the SAN `start` event.
9
+ # - Forwards inbound audio to Millis AI at 16kHz for processing.
10
+ # - Receives the AI's audio response at 16kHz.
11
+ # - Streams the audio back to the SAN system using the exact format
12
+ # it originally specified.
13
+ #
14
+ # Changes in this version:
15
+ # - Fixed the `reverse-media` event payload to match the provider's
16
+ # expected format (simplified JSON, lowercase 'callid').
17
+ # - Fixed `ImportError` by changing `starlette.websockets.State` to
18
+ # `starlette.websockets.WebSocketState`.
19
+ # - Updated the final connection check to use `WebSocketState.DISCONNECTED`.
20
+ # -------------------------------------------------------------------
21
+
22
+ import os
23
+ import json
24
+ import base64
25
+ import asyncio
26
+ import logging
27
+ from datetime import datetime
28
+
29
+ # Third-party libraries
30
+ import numpy as np
31
+ from scipy import signal as scipy_signal
32
+ import websockets
33
+ from websockets.connection import State as WsState
34
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect
35
+ import uvicorn
36
+
37
+ # Import WebSocketState instead of State
38
+ from starlette.websockets import WebSocketState
39
+
40
+ # ---------- Logging Configuration -----------------------------------------
41
+ logging.basicConfig(
42
+ level=logging.INFO,
43
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
44
+ )
45
+ logger = logging.getLogger("san-integration-app")
46
+
47
+ # ---------- FastAPI Application -------------------------------------------
48
+ app = FastAPI()
49
+
50
+ # ---------- Environment & Configuration -----------------------------------
51
+ AGENT_ID = os.getenv("MILLIS_AGENT_ID", "-OTBEKt8tHp6GI6AeRJ2")
52
+ PUBLIC_KEY = os.getenv("MILLIS_PUBLIC_KEY", "Dhr5TEtwlpACHNrDmdxQZXDDtM3PgEJi")
53
+ MILLIS_WS_URI = "wss://api-west.millis.ai:8080/millis"
54
+
55
+ # ---------------------------------------------------------------------------#
56
+ # REAL-TIME AUDIO PROCESSOR #
57
+ # ---------------------------------------------------------------------------#
58
+ class RealTimeAudioProcessor:
59
+ """
60
+ Manages a single live call, bridging audio between the SAN system and Millis AI.
61
+ """
62
+ PHONE_RATE = 8000
63
+ MILLIS_RATE = 16000
64
+ CHUNK_MS = 20
65
+ BYTES_PER_SAMPLE = 2
66
+
67
+ MILLIS_CHUNK_SIZE = int(MILLIS_RATE * CHUNK_MS / 1000 * BYTES_PER_SAMPLE)
68
+ PHONE_CHUNK_SIZE = int(PHONE_RATE * CHUNK_MS / 1000 * BYTES_PER_SAMPLE)
69
+
70
+ def __init__(self, agent_id: str, public_key: str):
71
+ self.agent_id = agent_id
72
+ self.public_key = public_key
73
+ self.ws: websockets.WebSocketClientProtocol | None = None
74
+ self.connected = False
75
+
76
+ self.inbound = bytearray()
77
+ self.outbound = bytearray()
78
+ self.in_lock = asyncio.Lock()
79
+ self.out_lock = asyncio.Lock()
80
+
81
+ self.is_paused = False
82
+ self.stream_id: str | None = None
83
+ self.call_id: str | None = None
84
+ self.media_format: dict = {
85
+ "encoding": "PCM", "sampleRate": self.PHONE_RATE, "channels": 1
86
+ }
87
+ self._packet_counter = 0
88
+
89
+ async def connect(self) -> bool:
90
+ logger.info("🤖 Connecting to Millis AI...")
91
+ try:
92
+ self.ws = await websockets.connect(MILLIS_WS_URI, open_timeout=10)
93
+ await self.ws.send(
94
+ json.dumps({
95
+ "method": "initiate",
96
+ "data": {"agent": {"agent_id": self.agent_id}, "public_key": self.public_key},
97
+ })
98
+ )
99
+ msg = await asyncio.wait_for(self.ws.recv(), timeout=10)
100
+ if json.loads(msg).get("method") != "onready":
101
+ raise RuntimeError("Millis AI did not send 'onready' confirmation.")
102
+ self.connected = True
103
+ logger.info("✅ Successfully connected to Millis AI.")
104
+ return True
105
+ except Exception as e:
106
+ logger.error(f"❌ Millis AI connection failed: {e}")
107
+ self.connected = False
108
+ return False
109
+
110
+ async def disconnect(self):
111
+ if self.ws and self.ws.state != WsState.CLOSED:
112
+ await self.ws.close()
113
+ self.connected = False
114
+ self.ws = None
115
+ logger.info("🔌 Disconnected from Millis AI.")
116
+
117
+ @staticmethod
118
+ def _resample(data: bytes, from_rate: int, to_rate: int) -> bytes:
119
+ if not data: return b""
120
+ arr = np.frombuffer(data, dtype=np.int16)
121
+ if arr.size == 0: return b""
122
+ new_len = int(arr.size * to_rate / from_rate)
123
+ resampled = scipy_signal.resample(arr, new_len).astype(np.int16)
124
+ return resampled.tobytes()
125
+
126
+ async def _pump_inbound_to_millis(self):
127
+ while self.connected:
128
+ chunk8 = None
129
+ async with self.in_lock:
130
+ if len(self.inbound) >= self.PHONE_CHUNK_SIZE:
131
+ chunk8 = self.inbound[:self.PHONE_CHUNK_SIZE]
132
+ del self.inbound[:self.PHONE_CHUNK_SIZE]
133
+ if not chunk8:
134
+ await asyncio.sleep(0.005)
135
+ continue
136
+ try:
137
+ chunk16 = self._resample(chunk8, self.PHONE_RATE, self.MILLIS_RATE)
138
+ await self.ws.send(chunk16)
139
+ self._packet_counter += 1
140
+ if self._packet_counter >= 1_000:
141
+ await self.ws.send(json.dumps({"method": "ping"}))
142
+ self._packet_counter = 0
143
+ except Exception as e:
144
+ logger.error(f"❌ Error in _pump_inbound_to_millis: {e}")
145
+ self.connected = False
146
+
147
+ async def _pump_millis_to_outbound(self):
148
+ while self.connected and self.ws and self.ws.state == WsState.OPEN:
149
+ try:
150
+ msg = await self.ws.recv()
151
+ if isinstance(msg, bytes):
152
+ async with self.out_lock: self.outbound.extend(msg)
153
+ continue
154
+ evt = json.loads(msg)
155
+ method = evt.get("method")
156
+ logger.info(f"🤖 JSON from Millis: {evt}")
157
+ if method == "pause": self.is_paused = True
158
+ elif method == "unpause": self.is_paused = False
159
+ elif method in ("clear", "start_answering"):
160
+ async with self.out_lock: self.outbound.clear()
161
+ self.is_paused = False
162
+ except websockets.exceptions.ConnectionClosed:
163
+ logger.warning("🔌 Millis AI closed the connection.")
164
+ self.connected = False
165
+ except Exception as e:
166
+ logger.warning(f"⚠️ Error reading from Millis AI: {e}")
167
+ self.connected = False
168
+
169
+ async def _pump_outbound_to_carrier(self, client_ws: WebSocket):
170
+ sent_packets = 0
171
+ while self.connected:
172
+ if self.is_paused:
173
+ await asyncio.sleep(0.01)
174
+ continue
175
+ chunk16 = None
176
+ async with self.out_lock:
177
+ if len(self.outbound) >= self.MILLIS_CHUNK_SIZE:
178
+ chunk16 = self.outbound[:self.MILLIS_CHUNK_SIZE]
179
+ del self.outbound[:self.MILLIS_CHUNK_SIZE]
180
+ if not chunk16:
181
+ await asyncio.sleep(0.005)
182
+ continue
183
+ try:
184
+ target_rate = self.media_format.get("sampleRate", self.PHONE_RATE)
185
+ chunk_resampled = self._resample(chunk16, self.MILLIS_RATE, target_rate)
186
+ payload = base64.b64encode(chunk_resampled).decode()
187
+ sent_packets += 1
188
+ if sent_packets % 100 == 1:
189
+ logger.info(f"⬆️ Sending upstream audio packet #{sent_packets} to SAN...")
190
+
191
+ ### --- FIX: Modified the JSON payload to match the provider's simple format --- ###
192
+ await client_ws.send_json({
193
+ "event": "reverse-media",
194
+ "callid": self.call_id, # Changed from "callId" to "callid"
195
+ "payload": payload,
196
+ # Removed "streamId" and "mediaFormat" fields
197
+ })
198
+ ### --- END FIX --- ###
199
+
200
+ except Exception as e:
201
+ logger.error(f"❌ Error in _pump_outbound_to_carrier: {e}")
202
+ break
203
+
204
+ async def start(self, client_ws: WebSocket) -> list[asyncio.Task]:
205
+ if not await self.connect(): return []
206
+ tasks = [
207
+ asyncio.create_task(self._pump_millis_to_outbound()),
208
+ asyncio.create_task(self._pump_inbound_to_millis()),
209
+ asyncio.create_task(self._pump_outbound_to_carrier(client_ws)),
210
+ ]
211
+ return tasks
212
+
213
+ async def stop_processor(proc: RealTimeAudioProcessor | None, tasks: list[asyncio.Task]):
214
+ if not proc: return
215
+ for t in tasks:
216
+ if not t.done(): t.cancel()
217
+ await proc.disconnect()
218
+
219
+ # ---------------------------------------------------------------------------#
220
+ # FASTAPI /media ENDPOINT #
221
+ # ---------------------------------------------------------------------------#
222
+ @app.websocket("/media")
223
+ async def media_socket(ws: WebSocket):
224
+ await ws.accept()
225
+ logger.info("🔗 SAN system WebSocket accepted.")
226
+
227
+ processor: RealTimeAudioProcessor | None = None
228
+ tasks: list[asyncio.Task] = []
229
+ active_call_id: str | None = None
230
+
231
+ try:
232
+ while True:
233
+ raw = await ws.receive_text()
234
+ msg = json.loads(raw)
235
+ event = msg.get("event")
236
+
237
+ if event == "start":
238
+ new_call_id = msg.get("callId")
239
+ stream_id = msg.get("streamId")
240
+
241
+ if processor and new_call_id != active_call_id:
242
+ logger.info(f"🔄 New call detected ({active_call_id} -> {new_call_id}). Stopping old processor.")
243
+ await stop_processor(processor, tasks)
244
+ processor, tasks = None, []
245
+
246
+ if processor is None:
247
+ logger.info(f"📞 Starting processor for call: {new_call_id}")
248
+ processor = RealTimeAudioProcessor(AGENT_ID, PUBLIC_KEY)
249
+ processor.stream_id = stream_id
250
+ processor.call_id = new_call_id
251
+
252
+ if "mediaFormat" in msg:
253
+ processor.media_format = msg["mediaFormat"]
254
+ logger.info(f"👂 Captured media format from SAN: {processor.media_format}")
255
+ else:
256
+ logger.warning("⚠️ No mediaFormat in 'start' event. Using default.")
257
+
258
+ tasks = await processor.start(ws)
259
+ if not tasks:
260
+ await ws.close(code=1011, reason="Could not connect to AI backend.")
261
+ return
262
+ active_call_id = new_call_id
263
+ continue
264
+
265
+ elif event == "media" and processor:
266
+ payload_b64 = msg.get("payload")
267
+ if payload_b64:
268
+ pcm = base64.b64decode(payload_b64)
269
+ async with processor.in_lock: processor.inbound.extend(pcm)
270
+ continue
271
+
272
+ elif event in ("hangup", "stop", "disconnect"):
273
+ logger.info(f"📞 Call {active_call_id} ended via '{event}' event.")
274
+ await stop_processor(processor, tasks)
275
+ processor, tasks, active_call_id = None, [], None
276
+ continue
277
+
278
+ elif event in ("connected", "answer", "ringing"):
279
+ logger.debug(f"ℹ️ Informational event received: {event}")
280
+ continue
281
+
282
+ logger.warning(f"⚠️ Received unhandled event: {event}")
283
+
284
+ except WebSocketDisconnect:
285
+ logger.info("🚪 SAN system disconnected the WebSocket.")
286
+ except Exception as e:
287
+ logger.error(f"❌ Unhandled error in media_socket: {e}", exc_info=True)
288
+ finally:
289
+ await stop_processor(processor, tasks)
290
+ if ws.client_state != WebSocketState.DISCONNECTED:
291
+ await ws.close()
292
+ logger.info("✅ Cleanup complete for this WebSocket connection.")
293
+
294
+ @app.get("/")
295
+ async def health():
296
+ return {"status": "ok", "timestamp": datetime.now().isoformat()}
297
+
298
+ if __name__ == "__main__":
299
+ print("🚀 Starting SAN to Millis AI Integration Server (v5 - Provider Format Fix)...")
300
+ uvicorn.run(app, host="0.0.0.0", port=7860)
301
+
output.mp3 ADDED
Binary file (56.9 kB). View file
 
output.wav ADDED
Binary file (82.9 kB). View file
 
requirements.txt ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # pip install -r requirements.txt
2
+
3
+ # standard libs
4
+
5
+ httpx==0.*
6
+ dataclasses-json==0.*
7
+ dataclasses==0.*
8
+ typing_extensions==4.*
9
+ aenum==3.*
10
+ deprecation==2.*
11
+ uvicorn
12
+
13
+ # Async functionality, likely to be already installed
14
+ aiohttp==3.*
15
+ aiofiles==23.*
16
+ fastapi
17
+ requests
18
+ pydub
19
+ ffmpeg-python
20
+
21
+ # Dependencies for terminal streaming
22
+ colorama>=0.4.0
23
+
24
+ numpy>=1.21.0
25
+ scipy>=1.7.0
26
+ websockets>=10.0
27
+
terminal_streamer.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import sys
3
+ import json
4
+ import threading
5
+ import queue
6
+ from datetime import datetime
7
+ from fastapi import WebSocket
8
+ from contextlib import redirect_stdout, redirect_stderr
9
+ import io
10
+ import logging
11
+
12
+ class TerminalStreamer:
13
+ def __init__(self):
14
+ self.connected_clients = set()
15
+ self.output_queue = queue.Queue()
16
+ self.original_stdout = sys.stdout
17
+ self.original_stderr = sys.stderr
18
+ self.capture_enabled = False
19
+
20
+ def start_capture(self):
21
+ """Start capturing terminal output"""
22
+ if not self.capture_enabled:
23
+ sys.stdout = self._create_capture_wrapper(sys.stdout, "stdout")
24
+ sys.stderr = self._create_capture_wrapper(sys.stderr, "stderr")
25
+ self.capture_enabled = True
26
+
27
+ def stop_capture(self):
28
+ """Stop capturing terminal output"""
29
+ if self.capture_enabled:
30
+ sys.stdout = self.original_stdout
31
+ sys.stderr = self.original_stderr
32
+ self.capture_enabled = False
33
+
34
+ def _create_capture_wrapper(self, original_stream, stream_type):
35
+ """Create a wrapper that captures output and forwards to clients"""
36
+ class StreamWrapper:
37
+ def __init__(self, original, streamer, stream_type):
38
+ self.original = original
39
+ self.streamer = streamer
40
+ self.stream_type = stream_type
41
+
42
+ def write(self, text):
43
+ # Write to original stream
44
+ self.original.write(text)
45
+ self.original.flush()
46
+
47
+ # Send to connected clients
48
+ if text.strip(): # Only send non-empty messages
49
+ message = {
50
+ "type": "terminal_output",
51
+ "stream": self.stream_type,
52
+ "content": text,
53
+ "timestamp": datetime.now().isoformat()
54
+ }
55
+ self.streamer._broadcast_message(message)
56
+ return len(text)
57
+
58
+ def flush(self):
59
+ self.original.flush()
60
+
61
+ def __getattr__(self, name):
62
+ return getattr(self.original, name)
63
+
64
+ return StreamWrapper(original_stream, self, stream_type)
65
+
66
+ def _broadcast_message(self, message):
67
+ """Broadcast message to all connected clients"""
68
+ if self.connected_clients:
69
+ # Use asyncio to send to all clients
70
+ asyncio.create_task(self._send_to_all_clients(message))
71
+
72
+ async def _send_to_all_clients(self, message):
73
+ """Send message to all connected WebSocket clients"""
74
+ if not self.connected_clients:
75
+ return
76
+
77
+ disconnected_clients = set()
78
+ for client in self.connected_clients.copy():
79
+ try:
80
+ await client.send_json(message)
81
+ except Exception as e:
82
+ print(f"Error sending to client: {e}")
83
+ disconnected_clients.add(client)
84
+
85
+ # Remove disconnected clients
86
+ self.connected_clients -= disconnected_clients
87
+
88
+ async def add_client(self, websocket: WebSocket):
89
+ """Add a new WebSocket client"""
90
+ await websocket.accept()
91
+ self.connected_clients.add(websocket)
92
+
93
+ # Send welcome message
94
+ welcome_message = {
95
+ "type": "connection_established",
96
+ "message": "Terminal output streaming started",
97
+ "timestamp": datetime.now().isoformat()
98
+ }
99
+ await websocket.send_json(welcome_message)
100
+
101
+ try:
102
+ # Keep connection alive and handle incoming messages
103
+ while True:
104
+ try:
105
+ # Wait for messages (client can send ping/pong)
106
+ data = await asyncio.wait_for(websocket.receive_text(), timeout=30.0)
107
+ message = json.loads(data)
108
+
109
+ if message.get("type") == "ping":
110
+ await websocket.send_json({"type": "pong", "timestamp": datetime.now().isoformat()})
111
+
112
+ except asyncio.TimeoutError:
113
+ # Send periodic heartbeat
114
+ await websocket.send_json({
115
+ "type": "heartbeat",
116
+ "timestamp": datetime.now().isoformat()
117
+ })
118
+
119
+ except Exception as e:
120
+ print(f"Client disconnected: {e}")
121
+ finally:
122
+ self.connected_clients.discard(websocket)
123
+
124
+ def remove_client(self, websocket: WebSocket):
125
+ """Remove a WebSocket client"""
126
+ self.connected_clients.discard(websocket)
127
+
128
+ # Global instance
129
+ terminal_streamer = TerminalStreamer()