UnleashX commited on
Commit
77230c3
ยท
verified ยท
1 Parent(s): 5e2e0dc

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +278 -505
app.py CHANGED
@@ -1,527 +1,300 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import os
2
- import sys
3
  import json
4
- import signal
5
- import argparse
6
- import uvicorn
7
  import base64
8
- import wave
9
  import asyncio
 
10
  from datetime import datetime
 
 
 
 
 
 
11
  from fastapi import FastAPI, WebSocket, WebSocketDisconnect
12
- from fastapi.responses import JSONResponse
13
- from websockets.exceptions import ConnectionClosed
 
 
14
 
 
 
 
 
 
 
 
 
15
  app = FastAPI()
16
 
17
- # Try to create audio folder with proper error handling
18
- AUDIO_FOLDER = os.path.join(os.getcwd(), "audio")
19
- try:
20
- if not os.path.exists(AUDIO_FOLDER):
21
- os.makedirs(AUDIO_FOLDER, exist_ok=True)
22
- # Test if we can write to the folder
23
- test_file = os.path.join(AUDIO_FOLDER, "test.txt")
24
- with open(test_file, 'w') as f:
25
- f.write("test")
26
- os.remove(test_file)
27
- print(f"[*] Successfully created and verified audio folder at: {AUDIO_FOLDER}")
28
- except Exception as e:
29
- print(f"[!] Warning: Could not create or write to audio folder: {str(e)}")
30
- print(f"[!] Audio saving will be disabled")
31
- AUDIO_FOLDER = None
32
-
33
- def show_exact_websocket_data(message, message_count, client_ip):
34
- """Show the EXACT raw WebSocket message as received"""
35
- timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')
36
-
37
- print(f"\n" + "="*100)
38
- print(f"[*] EXACT WEBSOCKET MESSAGE #{message_count} FROM {client_ip}")
39
- print(f"[*] Timestamp: {timestamp}")
40
- print(f"[*] Message Object Type: {type(message).__name__}")
41
- print(f"[*] Message Keys: {list(message.keys()) if isinstance(message, dict) else 'N/A'}")
42
- print(f"[*] Message Type Field: {message.get('type', 'NO TYPE FIELD')}")
43
- print("="*100)
44
-
45
- # Show the COMPLETE raw message object
46
- print(f"[*] === COMPLETE RAW MESSAGE OBJECT ===")
47
- print(f"EXACT MESSAGE: {message}")
48
- print(f"MESSAGE REPR: {repr(message)}")
49
- print(f"MESSAGE STR: {str(message)}")
50
-
51
- # Handle different message content types
52
- if message.get("type") == "websocket.receive":
53
- if "text" in message:
54
- text_data = message["text"]
55
- print(f"\n[*] === TEXT DATA (EXACT AS RECEIVED) ===")
56
- print(f"Text Data Type: {type(text_data).__name__}")
57
- print(f"Text Data Length: {len(text_data)} characters")
58
- print(f"EXACT TEXT DATA:")
59
- print(f"'{text_data}'")
60
- print(f"TEXT DATA REPR:")
61
- print(f"{repr(text_data)}")
62
-
63
- # Show character-by-character breakdown if not too long
64
- if len(text_data) <= 1000:
65
- print(f"\n[*] CHARACTER-BY-CHARACTER BREAKDOWN:")
66
- for i, char in enumerate(text_data):
67
- if i < 100: # Limit to first 100 chars for readability
68
- print(f" [{i:3d}]: '{char}' (ord: {ord(char)}, hex: 0x{ord(char):02x})")
69
- elif i == 100:
70
- print(f" ... (showing first 100 characters only)")
71
- break
72
-
73
- elif "bytes" in message:
74
- binary_data = message["bytes"]
75
- print(f"\n[*] === BINARY DATA (EXACT AS RECEIVED) ===")
76
- print(f"Binary Data Type: {type(binary_data).__name__}")
77
- print(f"Binary Data Length: {len(binary_data)} bytes")
78
- print(f"\nEXACT BASE64 REPRESENTATION:")
79
- print(f"{base64.b64encode(binary_data).decode('utf-8')}")
80
-
81
- else:
82
- print(f"\n[*] === OTHER MESSAGE CONTENT ===")
83
- for key, value in message.items():
84
- if key != "type":
85
- print(f"{key}: {repr(value)}")
86
-
87
- else:
88
- print(f"\n[*] === NON-RECEIVE MESSAGE ===")
89
- print(f"Complete message content: {message}")
90
-
91
- print("="*100 + "\n")
92
-
93
- def save_exact_raw_data(call_id, message_count, message):
94
- """Save the exact raw WebSocket message to file"""
95
- if AUDIO_FOLDER is None:
96
- return False
97
-
98
- try:
99
- timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
100
- filename = f"{AUDIO_FOLDER}/raw_websocket_{call_id}_{message_count}_{timestamp}.txt"
101
-
102
- with open(filename, 'w', encoding='utf-8') as f:
103
- f.write(f"WebSocket Message #{message_count}\n")
104
- f.write(f"Timestamp: {timestamp}\n")
105
- f.write(f"Call ID: {call_id}\n")
106
- f.write(f"Message Type: {type(message).__name__}\n")
107
- f.write(f"Message Keys: {list(message.keys()) if isinstance(message, dict) else 'N/A'}\n")
108
- f.write(f"\n=== EXACT RAW MESSAGE ===\n")
109
- f.write(f"{message}\n")
110
- f.write(f"\n=== MESSAGE REPR ===\n")
111
- f.write(f"{repr(message)}\n")
112
-
113
- if message.get("type") == "websocket.receive":
114
- if "text" in message:
115
- f.write(f"\n=== TEXT DATA ===\n")
116
- f.write(f"Length: {len(message['text'])}\n")
117
- f.write(f"Content: {repr(message['text'])}\n")
118
- f.write(f"Raw Text:\n{message['text']}\n")
119
-
120
- elif "bytes" in message:
121
- f.write(f"\n=== BINARY DATA ===\n")
122
- f.write(f"Length: {len(message['bytes'])}\n")
123
- f.write(f"Hex: {message['bytes'].hex()}\n")
124
- f.write(f"Base64: {base64.b64encode(message['bytes']).decode('utf-8')}\n")
125
- f.write(f"Repr: {repr(message['bytes'])}\n")
126
-
127
- print(f"[*] โœ… Exact raw data saved to: {filename}")
128
- return True
129
-
130
- except Exception as e:
131
- print(f"[!] โŒ Failed to save exact raw data: {e}")
132
- return False
133
-
134
- def save_audio_chunk(call_id, payload, sample_rate=44100, encoding="LINEAR16"):
135
- """Save audio chunk to WAV file with proper format handling."""
136
- if AUDIO_FOLDER is None:
137
- print("[!] Audio saving is disabled due to folder permission issues")
138
- return False
139
-
140
- try:
141
- # Decode base64 payload
142
- audio_data = base64.b64decode(payload)
143
-
144
- # Create filename with timestamp
145
- timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
146
- filename = f"{AUDIO_FOLDER}/call_{call_id}_{timestamp}.wav"
147
-
148
- # Save as WAV file with correct format
149
- with wave.open(filename, 'wb') as wav_file:
150
- wav_file.setnchannels(1) # Mono audio
151
-
152
- # Handle different encodings
153
- if encoding == "LINEAR16":
154
- wav_file.setsampwidth(2) # 16-bit PCM
155
- else:
156
- wav_file.setsampwidth(2) # Default to 16-bit
157
-
158
- wav_file.setframerate(sample_rate)
159
- wav_file.writeframes(audio_data)
160
-
161
- print(f"[*] Saved audio chunk to {filename} (Rate: {sample_rate}Hz, Encoding: {encoding})")
162
- return True
163
- except Exception as e:
164
- print(f"[!] Error saving audio chunk: {str(e)}")
165
- return False
166
-
167
- def save_binary_audio_chunk(call_id, audio_data, sample_rate=44100, encoding="LINEAR16"):
168
- """Save binary audio data directly to WAV file."""
169
- if AUDIO_FOLDER is None:
170
- print("[!] Audio saving is disabled due to folder permission issues")
171
- return False
172
-
173
- try:
174
- # Create filename with timestamp
175
- timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
176
- filename = f"{AUDIO_FOLDER}/call_{call_id}_{timestamp}.wav"
177
-
178
- # Save as WAV file with correct format
179
- with wave.open(filename, 'wb') as wav_file:
180
- wav_file.setnchannels(1) # Mono audio
181
-
182
- # Handle different encodings
183
- if encoding == "LINEAR16":
184
- wav_file.setsampwidth(2) # 16-bit PCM
185
- else:
186
- wav_file.setsampwidth(2) # Default to 16-bit
187
-
188
- wav_file.setframerate(sample_rate)
189
- wav_file.writeframes(audio_data)
190
-
191
- print(f"[*] Saved binary audio chunk to {filename} (Rate: {sample_rate}Hz, Encoding: {encoding})")
192
- return True
193
- except Exception as e:
194
- print(f"[!] Error saving binary audio chunk: {str(e)}")
195
- return False
196
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
  @app.websocket("/media")
198
- async def media_endpoint(websocket: WebSocket):
199
- client_ip = websocket.client.host if websocket.client else "unknown"
200
- print(f"[*] WebSocket connection attempt from {client_ip}")
201
-
202
- # Connection state tracking
203
- connection_start_time = datetime.now()
204
- message_count = 0
205
- current_media_format = None
206
- last_message_time = None
207
-
208
  try:
209
- await websocket.accept()
210
- print(f"[*] WebSocket connection accepted from {client_ip} at {connection_start_time}")
211
-
212
  while True:
213
- try:
214
- # Receive any type of message (text or binary)
215
- message = await asyncio.wait_for(websocket.receive(), timeout=60.0)
216
- message_count += 1
217
- last_message_time = datetime.now()
218
-
219
- # Show EXACT raw WebSocket data as received
220
- show_exact_websocket_data(message, message_count, client_ip)
221
-
222
- # Save exact raw data to file
223
- call_id = f"session_{connection_start_time.strftime('%H%M%S')}"
224
- save_exact_raw_data(call_id, message_count, message)
225
-
226
- # Process the message for application logic
227
- if message["type"] == "websocket.receive":
228
- if "text" in message:
229
- text_data = message["text"]
230
-
231
- try:
232
- json_data = json.loads(text_data)
233
-
234
- # Handle media format setup
235
- if "mediaFormat" in json_data:
236
- print(f"[*] ๐Ÿ“ก Media format detected: {json_data['mediaFormat']}")
237
- current_media_format = json_data["mediaFormat"]
238
- print(f"[*] Media format updated globally")
239
- continue
240
-
241
- # Handle media events with payload
242
- if json_data.get("event") == "media" and "payload" in json_data:
243
- call_id = json_data.get("callId", "unknown")
244
-
245
- if current_media_format:
246
- sample_rate = current_media_format.get("sampleRate", 44100)
247
- encoding = current_media_format.get("encoding", "LINEAR16")
248
- else:
249
- sample_rate = json_data.get("sampleRate", 44100)
250
- encoding = json_data.get("encoding", "LINEAR16")
251
-
252
- print(f"[*] ๐ŸŽต Processing audio chunk: Rate={sample_rate}Hz, Encoding={encoding}, CallID={call_id}")
253
-
254
- if save_audio_chunk(call_id, json_data["payload"], sample_rate, encoding):
255
- print(f"[*] โœ… Audio chunk saved successfully for callId: {call_id}")
256
- continue
257
-
258
- # Handle base64 payload messages
259
- if "payload" in json_data and json_data.get("encoding") == "base64":
260
- call_id = json_data.get("callId", json_data.get("streamId", f"session_{connection_start_time.strftime('%H%M%S')}"))
261
- print(f"[*] ๐ŸŽต Processing base64 payload for callId: {call_id}")
262
-
263
- sample_rate = 44100
264
- encoding = "LINEAR16"
265
- if current_media_format:
266
- sample_rate = current_media_format.get("sampleRate", 44100)
267
- encoding = current_media_format.get("encoding", "LINEAR16")
268
-
269
- if save_audio_chunk(call_id, json_data["payload"], sample_rate, encoding):
270
- print(f"[*] โœ… Base64 audio chunk saved successfully")
271
- continue
272
-
273
- # Handle events that need responses
274
- if json_data.get("event") in ["connected", "start", "ready"]:
275
- print(f"[*] ๐Ÿ“ž Setup event received: {json_data['event']}")
276
-
277
- response = {
278
- "event": "media-ready",
279
- "callId": json_data.get("callId", ""),
280
- "streamId": json_data.get("streamId", ""),
281
- "status": "ready",
282
- "timestamp": datetime.now().isoformat()
283
- }
284
- await websocket.send_json(response)
285
- print(f"[*] โœ… Acknowledgment sent for {json_data['event']} event")
286
- continue
287
-
288
- # Handle answer events
289
- required_fields = {
290
- "timestamp": str, "streamId": str, "callerId": str,
291
- "channelId": str, "event": str, "callDirection": str,
292
- "did": str, "callId": str, "cid": str, "extraParams": str
293
- }
294
-
295
- if (all(field in json_data and isinstance(json_data[field], field_type)
296
- for field, field_type in required_fields.items()) and
297
- json_data["event"] == "answer"):
298
-
299
- print(f"[*] ๐Ÿ“ž Answer event detected, preparing response...")
300
-
301
- response_format = {
302
- "encoding": "LINEAR16",
303
- "sampleRate": 44100,
304
- "channels": 1
305
- }
306
-
307
- if current_media_format:
308
- response_format.update(current_media_format)
309
-
310
- response = {
311
- "event": "reverse-media",
312
- "callId": json_data["callId"],
313
- "streamId": json_data["streamId"],
314
- "chunk": 1,
315
- "chunk_durn_ms": 20,
316
- "mediaFormat": response_format,
317
- "payload": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//8AAAAAAAAAAAAAAAAAAAAA//8AAAAAAAD//////////////////wAA/////wAAAAD/////AAD//wAAAAABAP//AAAAAAEAAAAAAAEAAQABAAAAAQACAAEAAAABAAMAAAD//wEAAQABAAEAAAABAAAAAgD+/wAA//8BAPr/7f/z/+r/7P/w/+H/1//a/+L/5f/j/+r/8/////b/5v/j//3/AgDt/9r/6f8DAAAA7//9/xYADQDs/+//BgAJAAUAAgAJAA4ADwARABgAIAAlADIAMwAxAEQAUQBZAFIASgA/ADgAOwBFAEUARwBRAFMAOwAvAEIATQBPAEMAQgBJAEQAOgA8AEEAPQA9ADUAHwAgACYALQAaABIAFQATAAQA9f8FAPP/7v/w/+v/8f/X/83/x//L/83/yf/Q/7n/v/+9/8P/wP++/8j/yf/B/67/uv/X/9T/0v/I/9n/3v/K/9D/0v/0//P/8f/O/9z/8f/4//r/7v8LAAQAAwANAAwADgAaABkACwAZACMAMAA3ACMAHAAnACkAKwAoADoAPQA5AD0ANQAuACMARgBEADAANgBAAD8AKwApADIAOwAnAAsAEAASABEAGwAWAA4A/f/6//3/AQD6/+7/9v/+//X/4f/k/+r/5//s//X/7P/a/9j/4P/v/+v/2v/k/+X/5P/i/9T/2v/r//3/+P/7/wAA+f8IAAIABgAKACIAFAD9/xIAGwAsACMANgArAC4AJQAYACEAMwBTAEMAIgApADQAMAAWAC0ARgBFACAAGAAnACUAHwAPACAAIwAVAAkABQDy//T/FQAVAO7/5//1//D/3f/d//j//P/a/8L/wv+9/8X/2f/h/87/s//A/7v/u//C/73/vv+8/7//vP+//8T/y//I/8D/vf/K/9N/1v/P/9b/2//T/9f/2v/f/+r/4v/X/9f/7f/6//D/7//q//P/8P/1//7/AgALAA0ACgAHAAkAEAAQAA4ADgAZABMABgAJABYAGAAGAAwAEwAXABAABwAHAAoAEAAVABIACAABAAIABgAGABAAEAAWAA0A/P/4/wQACAADAAcABwAAAPP/8f/+/woABgALAAAA6//q//3/BAD1/wEABAD3//b/AwAKAPz/+P8JABoAEAAOAB0AGAAOAAcADAAaACYALQAoABgAHAAoACgAJwAiABgALQAwAB8AEQAkADgAMQAbAAcAIgAmACEABQAVACcAGwAIAPT/BgAXABwA/P/x/+//9f/x//n////0/+r/3f/b/+D/6v/u/+n/3P/U/8b/yP/T/93/4f/g/9d/vv+7/8j/2//c/9b/z//C/7j/wP/W/9//5v/d/83/u//G/9N/3f/Z/97/3//T/9H/2//1/+X/3P/j//r/9v/k/+r////+/+n/8P8AAAQA9f/1//v/BAD///v/AwD7/+z/9f8DAAAA+v/3//H/6v/t//P/9//y//f/9P/k/9j/4f/2/+7/6f/f/+L/6f/w/+f/3v/m/+P/5f/g/+f/7f/1/+X/4v/s//z/+//x//r/BgAIAPz/AAACAAoAHgAhABEADAAdACEAGwAdACkAMwAjABQAFwAmADQAOgBBADgAMgAwADQAOwBAAEQARABAADkAOwBDAEYAQABDAEIAPQAzADIANQA5ADMAMAAsACQAIAAhABoAFAAXAB0AEgAIAAkADQANAAcACQAOABAA+f/p/+f/+/8EAAAA/f/0//D/4f/m//T/9P/j/9z/3//Z/9b/3//u/w=="
318
- }
319
-
320
- try:
321
- await websocket.send_json(response)
322
- print(f"[*] โœ… Response sent successfully for answer event from callId: {json_data['callId']}")
323
- except Exception as send_error:
324
- print(f"[!] โŒ Failed to send response: {send_error}")
325
-
326
- else:
327
- print(f"[*] ๐Ÿ“จ Other JSON message: Event={json_data.get('event', 'unknown')}")
328
-
329
- except json.JSONDecodeError:
330
- print(f"[*] Non-JSON text data received")
331
-
332
- elif "bytes" in message:
333
- binary_data = message["bytes"]
334
-
335
- if current_media_format:
336
- sample_rate = current_media_format.get("sampleRate", 44100)
337
- encoding = current_media_format.get("encoding", "LINEAR16")
338
- channels = current_media_format.get("channels", 1)
339
-
340
- duration_ms = (len(binary_data) * 1000) / (sample_rate * channels * 2)
341
- print(f"[*] Audio info: Rate={sample_rate}Hz, Encoding={encoding}, Duration={duration_ms:.1f}ms")
342
-
343
- call_id = f"call_{connection_start_time.strftime('%H%M%S')}"
344
-
345
- if save_binary_audio_chunk(call_id, binary_data, sample_rate, encoding):
346
- print(f"[*] โœ… Binary audio chunk saved successfully")
347
- else:
348
- print(f"[!] โš ๏ธ No media format, using defaults...")
349
- call_id = f"call_{connection_start_time.strftime('%H%M%S')}"
350
- if save_binary_audio_chunk(call_id, binary_data, 44100, "LINEAR16"):
351
- print(f"[*] โœ… Binary audio chunk saved with defaults")
352
-
353
- elif message["type"] == "websocket.disconnect":
354
- print(f"[*] ๐Ÿ”Œ Disconnect message received")
355
- break
356
-
357
- else:
358
- print(f"[!] โš ๏ธ Unknown message type: {message['type']}")
359
-
360
- except asyncio.TimeoutError:
361
- print(f"[!] โฐ No message received from {client_ip} for 60 seconds")
362
- try:
363
- await websocket.ping()
364
- print(f"[*] โœ… Ping sent successfully")
365
- except Exception as ping_error:
366
- print(f"[!] โŒ Ping failed: {ping_error}")
367
- break
368
 
369
- except ConnectionClosed:
370
- print(f"[!] ๐Ÿ”Œ Connection closed by {client_ip}")
371
- break
372
-
373
- except WebSocketDisconnect:
374
- print(f"[!] ๐Ÿ”Œ WebSocket disconnected by {client_ip}")
375
- break
376
-
377
- except Exception as message_error:
378
- print(f"[!] โŒ Error processing message from {client_ip}: {message_error}")
379
- print(f"[!] Error type: {type(message_error).__name__}")
380
-
381
- if "receive" in str(message_error).lower() or "closed" in str(message_error).lower():
382
- print(f"[!] ๐Ÿ”Œ Connection appears to be closed. Exiting receive loop.")
383
- break
384
-
 
 
 
 
 
 
 
 
 
 
 
 
385
  continue
386
-
 
 
387
  except WebSocketDisconnect:
388
- print(f"[!] ๐Ÿ”Œ WebSocket disconnected from {client_ip}")
389
- except ConnectionClosed:
390
- print(f"[!] ๐Ÿ”Œ Connection closed from {client_ip}")
391
  except Exception as e:
392
- print(f"[!] โŒ WebSocket error from {client_ip}: {str(e)}")
393
- print(f"[!] Error type: {type(e).__name__}")
394
- import traceback
395
- traceback.print_exc()
396
  finally:
397
- connection_end_time = datetime.now()
398
- connection_duration = (connection_end_time - connection_start_time).total_seconds()
399
-
400
- print(f"\n[*] === CONNECTION SUMMARY FOR {client_ip} ===")
401
- print(f"[*] Connection started: {connection_start_time}")
402
- print(f"[*] Connection ended: {connection_end_time}")
403
- print(f"[*] Total duration: {connection_duration:.2f} seconds")
404
- print(f"[*] Total messages received: {message_count}")
405
- if message_count > 0:
406
- print(f"[*] Average messages per second: {message_count/connection_duration:.2f}")
407
- print(f"[*] Last message received: {last_message_time}")
408
- print(f"[*] =======================================\n")
409
-
410
- @app.websocket("/test")
411
- async def test_websocket(websocket: WebSocket):
412
- await websocket.accept()
413
- client_ip = websocket.client.host if websocket.client else "unknown"
414
- print(f"[*] Test WebSocket connected from {client_ip}")
415
-
416
- try:
417
- counter = 0
418
- while True:
419
- counter += 1
420
- test_message = {
421
- "event": "test",
422
- "message": f"Test message #{counter}",
423
- "timestamp": datetime.now().isoformat(),
424
- "client_ip": client_ip
425
- }
426
- await websocket.send_text(json.dumps(test_message))
427
- print(f"[*] Sent test message #{counter} to {client_ip}")
428
- await asyncio.sleep(5)
429
- except WebSocketDisconnect:
430
- print(f"[*] Test WebSocket disconnected from {client_ip}")
431
- except Exception as e:
432
- print(f"[!] Test WebSocket error from {client_ip}: {e}")
433
-
434
- @app.get("/health")
435
- async def health_check_get():
436
- return JSONResponse(content={"message": "GET OK", "timestamp": datetime.now().isoformat()}, status_code=200)
437
-
438
- @app.post("/health")
439
- async def health_check_post():
440
- return JSONResponse(content={"message": "POST OK", "timestamp": datetime.now().isoformat()}, status_code=200)
441
-
442
- @app.get("/status")
443
- async def connection_status():
444
- audio_file_count = 0
445
- log_file_count = 0
446
- raw_file_count = 0
447
-
448
- if AUDIO_FOLDER and os.path.exists(AUDIO_FOLDER):
449
- try:
450
- all_files = os.listdir(AUDIO_FOLDER)
451
- audio_file_count = len([f for f in all_files if f.endswith('.wav')])
452
- log_file_count = len([f for f in all_files if f.endswith('.log') or f.endswith('.hex') or f.endswith('.bin')])
453
- raw_file_count = len([f for f in all_files if f.startswith('raw_websocket_')])
454
- except:
455
- audio_file_count = -1
456
- log_file_count = -1
457
- raw_file_count = -1
458
-
459
- return JSONResponse(content={
460
- "status": "running",
461
- "websocket_endpoint": "/media",
462
- "test_endpoint": "/test",
463
- "audio_folder": AUDIO_FOLDER,
464
- "audio_folder_exists": os.path.exists(AUDIO_FOLDER) if AUDIO_FOLDER else False,
465
- "audio_files_count": audio_file_count,
466
- "log_files_count": log_file_count,
467
- "raw_websocket_files_count": raw_file_count,
468
- "exact_raw_logging": True,
469
- "timestamp": datetime.now().isoformat(),
470
- "server_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
471
- "endpoints": {
472
- "websocket_media": "/media",
473
- "websocket_test": "/test",
474
- "health_get": "/health",
475
- "health_post": "/health",
476
- "status": "/status"
477
- }
478
- })
479
 
480
  @app.get("/")
481
- async def root():
482
- return JSONResponse(content={
483
- "message": "FastAPI WebSocket Server with EXACT Raw Data Display",
484
- "status": "โœ… Connected and ready to show EXACT websocket data as received",
485
- "features": [
486
- "Shows EXACT raw WebSocket message as received",
487
- "Complete message object display",
488
- "Character-by-character breakdown for text",
489
- "Byte-by-byte breakdown for binary",
490
- "Multiple representations (raw, repr, hex, base64)",
491
- "Saves exact raw data to files"
492
- ],
493
- "endpoints": {
494
- "websocket_media": "/media",
495
- "websocket_test": "/test",
496
- "health_check": "/health",
497
- "status": "/status"
498
- },
499
- "timestamp": datetime.now().isoformat()
500
- })
501
-
502
- def signal_handler(sig, frame):
503
- print("\n[*] ๐Ÿ›‘ Shutting down server...")
504
- print("[*] Cleaning up resources...")
505
- sys.exit(0)
506
 
507
  if __name__ == "__main__":
508
- parser = argparse.ArgumentParser(description='FastAPI WebSocket Server - EXACT Raw Data Display')
509
- parser.add_argument('--port', type=int, default=8000, help='Port to run the server on')
510
- parser.add_argument('--host', type=str, default="0.0.0.0", help='Host to bind the server to')
511
- parser.add_argument('--debug', action='store_true', help='Enable debug mode')
512
- args = parser.parse_args()
513
-
514
- signal.signal(signal.SIGINT, signal_handler)
515
-
516
- print(f"[*] ===== FastAPI WebSocket Server - EXACT Raw Data Display =====")
517
- print(f"[*] ๐Ÿš€ Server running at ws://{args.host}:{args.port}/media")
518
- print(f"[*] ๐Ÿ“Š Status check at http://{args.host}:{args.port}/status")
519
- print(f"[*] ๐Ÿ“ Audio folder: {AUDIO_FOLDER}")
520
- print(f"[*] ๐Ÿ” EXACT Raw Data Logging: ENABLED")
521
- print(f"[*] ๐Ÿ“ Raw WebSocket files: raw_websocket_*.txt")
522
- print(f"[*] ๐Ÿ“„ Shows complete message objects, character/byte breakdowns")
523
- print(f"[*] ๐Ÿ• Timestamp: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
524
- print(f"[*] =============================================================")
525
-
526
- log_level = "debug" if args.debug else "info"
527
- uvicorn.run("app:app", host=args.host, port=args.port, log_level=log_level, reload=args.debug)
 
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)