UnleashX commited on
Commit
bb73224
·
verified ·
1 Parent(s): 6ce48d7

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +82 -535
app.py CHANGED
@@ -1,25 +1,18 @@
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
- # - Added comprehensive logging for debugging audio flow.
21
- # - Fixed agent ID extraction to use platform_agent_id from extraParams.
22
- # - Added MongoDB integration for credential management.
23
  # -------------------------------------------------------------------
24
 
25
  import os
@@ -35,8 +28,6 @@ from dotenv import load_dotenv
35
  load_dotenv()
36
 
37
  # Third-party libraries
38
- import numpy as np
39
- from scipy import signal as scipy_signal
40
  import websockets
41
  from websockets.connection import State as WsState
42
  from fastapi import FastAPI, WebSocket, WebSocketDisconnect
@@ -54,7 +45,6 @@ logging.basicConfig(
54
  logger = logging.getLogger("san-integration-app")
55
 
56
  # ---------- Environment & Configuration -----------------------------------
57
- #AGENT_ID = os.getenv("MILLIS_AGENT_ID")
58
  PUBLIC_KEY = os.getenv("MILLIS_PUBLIC_KEY")
59
  MILLIS_WS_URI = "wss://api-west.millis.ai:8080/millis"
60
 
@@ -66,40 +56,15 @@ MONGODB_COLLECTION = os.getenv("MONGODB_COLLECTION", "call_metadata")
66
  # ---------- FastAPI Application -------------------------------------------
67
  app = FastAPI()
68
 
69
- # def validate_environment():
70
- # """Validate that all required environment variables are set."""
71
- # required_vars = {
72
- # "MILLIS_AGENT_ID": AGENT_ID,
73
- # "MILLIS_PUBLIC_KEY": PUBLIC_KEY,
74
- # "MONGODB_CONNECTION_STRING": MONGODB_CONNECTION_STRING,
75
- # "MONGODB_DATABASE_NAME": MONGODB_DATABASE_NAME,
76
- # "MONGODB_COLLECTION": MONGODB_COLLECTION
77
- # }
78
-
79
- # missing_vars = [var for var, value in required_vars.items() if not value]
80
-
81
- # if missing_vars:
82
- # error_msg = f"Missing required environment variables: {', '.join(missing_vars)}"
83
- # logger.error(error_msg)
84
- # logger.error("Please set these variables in your .env file or environment")
85
- # raise ValueError(error_msg)
86
-
87
  @app.on_event("startup")
88
  async def startup_event():
89
  """Initialize MongoDB connection on startup."""
90
  logger.info("=== APPLICATION STARTUP ===")
91
 
92
- # Validate environment variables first
93
- #validate_environment()
94
-
95
  success = await connect_mongodb()
96
  if not success:
97
  logger.error("Failed to connect to MongoDB during startup")
98
  logger.warning("Application will continue running but MongoDB features will be disabled")
99
- logger.info("To fix this issue:")
100
- logger.info("1. Ensure MongoDB is installed and running")
101
- logger.info("2. Set MONGODB_CONNECTION_STRING environment variable")
102
- logger.info("3. For local development: mongodb://localhost:27017")
103
  else:
104
  logger.info("MongoDB connection established successfully")
105
  logger.info("=== STARTUP COMPLETE ===")
@@ -123,7 +88,6 @@ async def connect_mongodb():
123
 
124
  if not MONGODB_CONNECTION_STRING or MONGODB_CONNECTION_STRING == "MONGODB_DATABASE_NAME":
125
  logger.error("Invalid MongoDB connection string. Please set MONGODB_CONNECTION_STRING environment variable.")
126
- logger.error("Example: mongodb://localhost:27017 or mongodb://username:password@host:port/database")
127
  return False
128
 
129
  mongodb_client = AsyncIOMotorClient(MONGODB_CONNECTION_STRING)
@@ -135,8 +99,6 @@ async def connect_mongodb():
135
  return True
136
  except Exception as e:
137
  logger.error(f"Failed to connect to MongoDB: {e}")
138
- logger.error("Please ensure MongoDB is running and the connection string is correct.")
139
- logger.error("For local development, try: mongodb://localhost:27017")
140
  return False
141
 
142
  async def close_mongodb():
@@ -147,16 +109,7 @@ async def close_mongodb():
147
  logger.info("MongoDB connection closed")
148
 
149
  def filter_metadata_for_millis(document: dict) -> dict:
150
- """
151
- Filter MongoDB document to exclude unwanted fields before sending to Millis AI.
152
-
153
- Args:
154
- document: The MongoDB document containing call data
155
-
156
- Returns:
157
- Filtered dictionary with only the fields that should be sent to Millis AI
158
- """
159
- # Fields to exclude from metadata sent to Millis AI
160
  excluded_fields = {
161
  "public_key", "call_id", "platform_agent_id", "stored_at", "agent_id"
162
  }
@@ -165,38 +118,25 @@ def filter_metadata_for_millis(document: dict) -> dict:
165
 
166
  for key, value in document.items():
167
  if key not in excluded_fields:
168
- # Convert value to string if it's not None
169
  if value is not None:
170
  filtered_metadata[key] = str(value)
171
  else:
172
  filtered_metadata[key] = ""
173
 
174
- logger.info(f"Filtered metadata - Original fields: {list(document.keys())}")
175
- logger.info(f"Filtered metadata - Excluded fields: {list(excluded_fields)}")
176
  logger.info(f"Filtered metadata - Final fields: {list(filtered_metadata.keys())}")
177
-
178
  return filtered_metadata
179
 
180
  async def fetch_call_credentials(call_id: str) -> Dict[str, Any]:
181
- """
182
- Fetch call credentials and metadata from MongoDB.
183
-
184
- Args:
185
- call_id: The call ID to look up
186
-
187
- Returns:
188
- Dictionary containing platform_agent_id, public_key, metadata, and agent_id
189
- """
190
  if mongodb_db is None:
191
  logger.error("MongoDB not connected")
192
  return {}
193
 
194
  try:
195
  logger.info(f"Fetching credentials for call_id: {call_id}")
196
- logger.info(f"MongoDB collection: {MONGODB_COLLECTION}")
197
  collection = mongodb_db[MONGODB_COLLECTION]
198
 
199
- # Query for the call metadata - try multiple field names
200
  possible_queries = [
201
  {"call_id": call_id},
202
  {"metadata.call_id": call_id},
@@ -205,53 +145,25 @@ async def fetch_call_credentials(call_id: str) -> Dict[str, Any]:
205
  ]
206
 
207
  document = None
208
- for i, query in enumerate(possible_queries):
209
- logger.info(f"MongoDB query attempt {i+1}: {query}")
210
  document = await collection.find_one(query)
211
  if document:
212
- logger.info(f"Found document with query: {query}")
213
  break
214
 
215
- if not document:
216
- logger.info(f"No document found for call_id: {call_id}")
217
- logger.info("Available documents in collection:")
218
- all_docs = await collection.find({}).to_list(length=10)
219
- for doc in all_docs:
220
- if "call_id" in doc:
221
- logger.info(f" - call_id: {doc['call_id']}")
222
- if "metadata" in doc and "call_id" in doc["metadata"]:
223
- logger.info(f" - metadata.call_id: {doc['metadata']['call_id']}")
224
-
225
- # Use the first available document with metadata as fallback
226
- for doc in all_docs:
227
- if "metadata" in doc and doc["metadata"]:
228
- logger.info(f"Using fallback document with call_id: {doc.get('call_id', 'unknown')}")
229
- document = doc
230
- break
231
-
232
  if not document:
233
  logger.warning(f"No credentials found for call_id: {call_id}")
234
- logger.info("MongoDB query returned: None")
235
  return {}
236
 
237
- # Log document with masked sensitive data
238
- safe_document = document.copy()
239
- if "public_key" in safe_document:
240
- safe_document["public_key"] = safe_document["public_key"][:10] + "..." if safe_document["public_key"] else "None"
241
- logger.info(f"MongoDB document found: {safe_document}")
242
-
243
  # Filter the document to exclude unwanted fields for Millis AI
244
  filtered_metadata = filter_metadata_for_millis(document)
245
 
246
- # Extract the required fields
247
  credentials = {
248
  "platform_agent_id": document.get("platform_agent_id"),
249
  "public_key": document.get("public_key"),
250
  "metadata": filtered_metadata
251
  }
252
 
253
- logger.info(f"Retrieved credentials for call_id {call_id}: {credentials}")
254
- logger.info(f"Metadata keys: {list(credentials.get('metadata', {}).keys())}")
255
  return credentials
256
 
257
  except Exception as e:
@@ -259,17 +171,7 @@ async def fetch_call_credentials(call_id: str) -> Dict[str, Any]:
259
  return {}
260
 
261
  async def fetch_call_credentials_with_fallback(call_id: str, fallback_msg: dict) -> Dict[str, Any]:
262
- """
263
- Fetch call credentials from MongoDB with fallback to message data.
264
-
265
- Args:
266
- call_id: The call ID to look up
267
- fallback_msg: The original message to extract fallback credentials from
268
-
269
- Returns:
270
- Dictionary containing platform_agent_id, public_key, and agent_id
271
- """
272
- # Try MongoDB first
273
  logger.info(f"=== MONGODB FALLBACK FOR CALL {call_id} ===")
274
  credentials = await fetch_call_credentials(call_id)
275
 
@@ -277,18 +179,16 @@ async def fetch_call_credentials_with_fallback(call_id: str, fallback_msg: dict)
277
  logger.info(f"Using MongoDB credentials for call_id: {call_id}")
278
  return credentials
279
 
280
- # Fallback to message data (safety net)
281
  logger.warning(f"MongoDB credentials incomplete for call_id {call_id}, using message fallback")
282
 
283
  extra_params = fallback_msg.get("extraParams", {})
284
  custom_field = fallback_msg.get("custom_field", {})
285
 
286
- # Extract agent_id from fallback sources
287
  agent_id = (extra_params.get("platform_agent_id") or
288
  custom_field.get("agentId") or
289
  fallback_msg.get("agentId"))
290
 
291
- # Extract public_key from fallback sources
292
  public_key = (extra_params.get("publicKey") or extra_params.get("public_key") or
293
  custom_field.get("publicKey") or custom_field.get("public_key") or
294
  fallback_msg.get("publicKey") or fallback_msg.get("public_key"))
@@ -299,24 +199,17 @@ async def fetch_call_credentials_with_fallback(call_id: str, fallback_msg: dict)
299
  "metadata": {}
300
  }
301
 
302
- logger.warning(f"Using fallback credentials for call_id {call_id}: {fallback_credentials}")
303
  return fallback_credentials
304
 
305
  # ---------------------------------------------------------------------------#
306
- # REAL-TIME AUDIO PROCESSOR #
307
  # ---------------------------------------------------------------------------#
308
  class RealTimeAudioProcessor:
309
  """
310
- Manages a single live call, bridging audio between the SAN system and Millis AI.
311
- Optimized for faster interruption detection.
312
  """
313
- PHONE_RATE = 8000
314
- MILLIS_RATE = 16000
315
- CHUNK_MS = 40 # Reduced from 20ms to 10ms for faster processing
316
- BYTES_PER_SAMPLE = 2
317
-
318
- MILLIS_CHUNK_SIZE = int(MILLIS_RATE * CHUNK_MS / 1000 * BYTES_PER_SAMPLE)
319
- PHONE_CHUNK_SIZE = int(PHONE_RATE * CHUNK_MS / 1000 * BYTES_PER_SAMPLE)
320
 
321
  def __init__(self, agent_id: str, public_key: str, metadata: dict = None):
322
  self.agent_id = agent_id
@@ -324,53 +217,13 @@ class RealTimeAudioProcessor:
324
  self.metadata = metadata or {}
325
  self.ws: Optional[websockets.WebSocketClientProtocol] = None
326
  self.connected = False
327
-
328
- self.inbound = bytearray()
329
- self.outbound = bytearray()
330
- self.in_lock = asyncio.Lock()
331
- self.out_lock = asyncio.Lock()
332
-
333
  self.is_paused = False
334
  self.stream_id: Optional[str] = None
335
  self.call_id: Optional[str] = None
336
- self.media_format: dict = {
337
- "encoding": "PCM", "sampleRate": self.PHONE_RATE, "channels": 1
338
- }
339
  self._packet_counter = 0
340
-
341
- # Add interruption detection state
342
- self.last_interruption_time = 0
343
- self.interruption_threshold = 0.1 # 100ms threshold for interruption detection
344
-
345
- # Audio level detection configuration
346
- self.speech_threshold = 500 # RMS threshold for detecting speech
347
- self.silence_threshold = 200 # RMS threshold for detecting silence
348
- self.silence_duration = 1.0 # Seconds of silence before unpausing
349
-
350
- # Debug logging for audio levels (set to True to tune thresholds)
351
- self.debug_audio_levels = False
352
-
353
- def enable_audio_debug(self, enabled: bool = True):
354
- """Enable or disable debug logging for audio levels to help tune thresholds."""
355
- self.debug_audio_levels = enabled
356
- logger.info(f"Audio level debug logging {'enabled' if enabled else 'disabled'}")
357
-
358
- def set_audio_thresholds(self, speech_threshold: int = None, silence_threshold: int = None, silence_duration: float = None):
359
- """Set audio detection thresholds for interruption detection."""
360
- if speech_threshold is not None:
361
- self.speech_threshold = speech_threshold
362
- if silence_threshold is not None:
363
- self.silence_threshold = silence_threshold
364
- if silence_duration is not None:
365
- self.silence_duration = silence_duration
366
-
367
- logger.info(f"Audio thresholds updated - Speech: {self.speech_threshold}, Silence: {self.silence_threshold}, Duration: {self.silence_duration}s")
368
 
369
  async def connect(self) -> bool:
370
  logger.info(f"Connecting to Millis AI for call {self.call_id}...")
371
- logger.info(f"Agent ID: {self.agent_id}")
372
- logger.info(f"Public Key: {self.public_key[:10]}...")
373
- logger.info(f"Millis URI: {MILLIS_WS_URI}")
374
 
375
  try:
376
  logger.info("Establishing WebSocket connection...")
@@ -388,27 +241,16 @@ class RealTimeAudioProcessor:
388
  "include_metadata_in_prompt": True
389
  }
390
  }
391
- logger.info(f"Millis connection metadata: {self.metadata}")
392
- logger.info(f"Metadata keys being sent: {list(self.metadata.keys())}")
393
- logger.info(f"Sample metadata values:")
394
- for key, value in list(self.metadata.items())[:5]: # Show first 5 items
395
- logger.info(f" {key}: {value}")
396
- logger.info(f"Total metadata fields: {len(self.metadata)}")
397
- logger.info(f"include_metadata_in_prompt: {initiate_payload['data']['include_metadata_in_prompt']}")
398
- logger.info(f"Sending initiate payload: {json.dumps(initiate_payload, indent=2)}")
399
 
 
400
  await self.ws.send(json.dumps(initiate_payload))
401
- logger.info("Initiate payload sent, waiting for response...")
402
 
403
  logger.info("Waiting for Millis AI response...")
404
  msg = await asyncio.wait_for(self.ws.recv(), timeout=10)
405
- logger.info(f"Received response: {msg}")
406
 
407
  try:
408
  parsed_msg = json.loads(msg)
409
- logger.info(f"Parsed response: {json.dumps(parsed_msg, indent=2)}")
410
  method = parsed_msg.get("method")
411
- logger.info(f"Response method: {method}")
412
 
413
  if method != "onready":
414
  logger.error(f"Expected 'onready' method, got '{method}'")
@@ -420,27 +262,8 @@ class RealTimeAudioProcessor:
420
 
421
  except json.JSONDecodeError as e:
422
  logger.error(f"Failed to parse response as JSON: {e}")
423
- logger.error(f"Raw response: {msg}")
424
  raise
425
 
426
- except asyncio.TimeoutError:
427
- logger.error("Connection timeout - Millis AI did not respond within 10 seconds")
428
- logger.error(f"Agent ID: {self.agent_id}")
429
- logger.error(f"Public Key: {self.public_key[:10]}...")
430
- logger.error("Please check:")
431
- logger.error("1. Network connectivity to Millis AI")
432
- logger.error("2. Agent ID and Public Key are valid")
433
- logger.error("3. Millis AI service is running")
434
- self.connected = False
435
- return False
436
- except websockets.exceptions.InvalidURI:
437
- logger.error(f"Invalid WebSocket URI: {MILLIS_WS_URI}")
438
- self.connected = False
439
- return False
440
- except websockets.exceptions.ConnectionClosed:
441
- logger.error("WebSocket connection was closed unexpectedly")
442
- self.connected = False
443
- return False
444
  except Exception as e:
445
  logger.error(f"Millis AI connection failed: {type(e).__name__}: {e}")
446
  self.connected = False
@@ -453,143 +276,36 @@ class RealTimeAudioProcessor:
453
  self.ws = None
454
  logger.info("Disconnected from Millis AI.")
455
 
456
- @staticmethod
457
- def _resample(data: bytes, from_rate: int, to_rate: int) -> bytes:
458
- if not data: return b""
459
- arr = np.frombuffer(data, dtype=np.int16)
460
- if arr.size == 0: return b""
461
- new_len = int(arr.size * to_rate / from_rate)
462
- resampled = scipy_signal.resample(arr, new_len).astype(np.int16)
463
- return resampled.tobytes()
464
-
465
- async def _clear_buffers_immediately(self):
466
- """Immediately clear audio buffers for faster interruption response."""
467
- async with self.in_lock:
468
- self.inbound.clear()
469
- async with self.out_lock:
470
- self.outbound.clear()
471
- logger.info("Audio buffers cleared immediately")
472
-
473
- async def _detect_interruption(self, audio_data: bytes) -> bool:
474
- """
475
- Detect if user is speaking (interruption) by analyzing audio levels.
476
- Returns True if interruption is detected.
477
- """
478
- if not audio_data:
479
- return False
480
 
481
  try:
482
- # Convert bytes to numpy array
483
- audio_array = np.frombuffer(audio_data, dtype=np.int16)
484
- if len(audio_array) == 0:
485
- return False
486
-
487
- # Calculate RMS (Root Mean Square) to detect audio level
488
- rms = np.sqrt(np.mean(audio_array.astype(np.float32) ** 2))
489
-
490
- # Debug logging for threshold tuning
491
- if self.debug_audio_levels and rms > 100: # Only log when there's significant audio
492
- logger.debug(f"Audio RMS: {rms:.2f}, Speech threshold: {self.speech_threshold}")
493
-
494
- if rms > self.speech_threshold:
495
- current_time = asyncio.get_event_loop().time()
496
- if current_time - self.last_interruption_time > self.interruption_threshold:
497
- self.last_interruption_time = current_time
498
- logger.info(f"Interruption detected! RMS: {rms:.2f}, Threshold: {self.speech_threshold}")
499
- return True
500
-
501
- except Exception as e:
502
- logger.warning(f"Error in interruption detection: {e}")
503
-
504
- return False
505
-
506
- async def _detect_silence(self, audio_data: bytes) -> bool:
507
- """
508
- Detect if user has stopped speaking (silence) by analyzing audio levels.
509
- Returns True if silence is detected.
510
- """
511
- if not audio_data:
512
- return False
513
 
514
- try:
515
- # Convert bytes to numpy array
516
- audio_array = np.frombuffer(audio_data, dtype=np.int16)
517
- if len(audio_array) == 0:
518
- return False
519
 
520
- # Calculate RMS (Root Mean Square) to detect audio level
521
- rms = np.sqrt(np.mean(audio_array.astype(np.float32) ** 2))
522
-
523
- # Debug logging for threshold tuning
524
- if self.debug_audio_levels and rms < 300: # Only log when audio is low
525
- logger.debug(f"Silence check - RMS: {rms:.2f}, Silence threshold: {self.silence_threshold}")
526
-
527
- if rms < self.silence_threshold:
528
- current_time = asyncio.get_event_loop().time()
529
- # Only unpause after configured silence duration to avoid rapid toggling
530
- if current_time - self.last_interruption_time > self.silence_duration:
531
- logger.info(f"Silence detected! RMS: {rms:.2f}, Threshold: {self.silence_threshold}")
532
- return True
533
-
534
  except Exception as e:
535
- logger.warning(f"Error in silence detection: {e}")
536
-
537
- return False
538
-
539
- async def _pump_inbound_to_millis(self):
540
- logger.info(f"Starting inbound audio pump for call {self.call_id}")
541
- processed_chunks = 0
542
- while self.connected:
543
- chunk8 = None
544
- async with self.in_lock:
545
- # Process smaller chunks for faster response
546
- min_chunk_size = max(self.PHONE_CHUNK_SIZE // 2, 160) # At least 10ms of audio
547
- if len(self.inbound) >= min_chunk_size:
548
- chunk8 = self.inbound[:min_chunk_size]
549
- del self.inbound[:min_chunk_size]
550
- if not chunk8:
551
- await asyncio.sleep(0.002) # Reduced sleep time
552
- continue
553
- try:
554
- # Check for interruption before sending to Millis
555
- if await self._detect_interruption(chunk8):
556
- # Immediately pause AI and clear buffers
557
- self.is_paused = True
558
- await self._clear_buffers_immediately()
559
- logger.info("AI paused due to user interruption")
560
- elif self.is_paused and await self._detect_silence(chunk8):
561
- # Unpause AI when user stops speaking
562
- self.is_paused = False
563
- logger.info("AI unpaused due to user silence")
564
-
565
- chunk16 = self._resample(chunk8, self.PHONE_RATE, self.MILLIS_RATE)
566
- processed_chunks += 1
567
- # Only log every 200 chunks to reduce noise
568
- if processed_chunks % 200 == 0:
569
- logger.info(f"Processed {processed_chunks} audio chunks to Millis AI")
570
- await self.ws.send(chunk16)
571
- self._packet_counter += 1
572
- if self._packet_counter >= 1_000:
573
- logger.info("Sending ping to Millis AI")
574
- await self.ws.send(json.dumps({"method": "ping"}))
575
- self._packet_counter = 0
576
- except Exception as e:
577
- logger.error(f"Error in _pump_inbound_to_millis: {e}")
578
- self.connected = False
579
 
580
- async def _pump_millis_to_outbound(self):
581
- logger.info(f"Starting outbound audio pump for call {self.call_id}")
582
- received_chunks = 0
 
583
  while self.connected and self.ws and self.ws.state == WsState.OPEN:
584
  try:
585
  msg = await self.ws.recv()
 
586
  if isinstance(msg, bytes):
587
- received_chunks += 1
588
- # Only log every 200 chunks to reduce noise
589
- if received_chunks % 200 == 0:
590
- logger.info(f"Received {received_chunks} audio chunks from Millis AI")
591
- async with self.out_lock:
592
- self.outbound.extend(msg)
593
  continue
594
 
595
  # Handle JSON messages
@@ -597,85 +313,58 @@ class RealTimeAudioProcessor:
597
  evt = json.loads(msg)
598
  except json.JSONDecodeError as e:
599
  logger.warning(f"Failed to parse JSON message from Millis AI: {e}")
600
- logger.warning(f"Raw message: {msg}")
601
  continue
602
 
603
  method = evt.get("method")
604
- # Only log important JSON events, not every audio chunk
605
  if method not in ["ping", "pong"]:
606
- logger.info(f"Millis event: {method} - {evt.get('data', '')}")
 
607
  if method == "pause":
608
  self.is_paused = True
609
  logger.info("Audio paused")
610
- # Immediately clear buffers when paused
611
- await self._clear_buffers_immediately()
612
  elif method == "unpause":
613
  self.is_paused = False
614
  logger.info("Audio unpaused")
615
  elif method in ("clear", "start_answering"):
616
- await self._clear_buffers_immediately()
617
  self.is_paused = False
618
- logger.info("Audio buffer cleared")
 
619
  except websockets.exceptions.ConnectionClosed:
620
  logger.warning("Millis AI closed the connection.")
621
  self.connected = False
622
  except Exception as e:
623
- logger.warning(f"Error reading from Millis AI: {type(e).__name__}: {e}")
624
- logger.warning(f"Error details: {str(e)}")
625
  self.connected = False
626
 
627
- async def _pump_outbound_to_carrier(self, client_ws: WebSocket):
628
- logger.info(f"Starting carrier outbound pump for call {self.call_id}")
629
- sent_packets = 0
630
- while self.connected:
631
- if self.is_paused:
632
- await asyncio.sleep(0.005) # Reduced sleep time
633
- continue
634
- chunk16 = None
635
- async with self.out_lock:
636
- # Process smaller chunks for faster response
637
- min_chunk_size = max(self.MILLIS_CHUNK_SIZE // 2, 320) # At least 10ms of audio
638
- if len(self.outbound) >= min_chunk_size:
639
- chunk16 = self.outbound[:min_chunk_size]
640
- del self.outbound[:min_chunk_size]
641
- if not chunk16:
642
- await asyncio.sleep(0.002) # Reduced sleep time
643
- continue
644
- try:
645
- target_rate = self.media_format.get("sampleRate", self.PHONE_RATE)
646
- chunk_resampled = self._resample(chunk16, self.MILLIS_RATE, target_rate)
647
- payload = base64.b64encode(chunk_resampled).decode()
648
- sent_packets += 1
649
- # Only log every 400 packets to reduce noise
650
- if sent_packets % 400 == 0:
651
- logger.info(f"Sent {sent_packets} audio packets to SAN")
652
-
653
- ### --- FIX: Modified the JSON payload to match the provider's simple format --- ###
654
- reverse_media_payload = {
655
- "event": "reverse-media",
656
- "callid": self.call_id, # Changed from "callId" to "callid"
657
- "payload": payload,
658
- # Removed "streamId" and "mediaFormat" fields
659
- }
660
- await client_ws.send_json(reverse_media_payload)
661
- ### --- END FIX --- ###
662
-
663
- except Exception as e:
664
- logger.error(f"Error in _pump_outbound_to_carrier: {e}")
665
- break
666
 
667
  async def start(self, client_ws: WebSocket) -> list[asyncio.Task]:
 
668
  logger.info(f"Starting RealTimeAudioProcessor for call {self.call_id}")
 
669
  if not await self.connect():
670
  logger.error("Failed to connect to Millis AI")
671
  return []
672
 
673
- logger.info("Creating audio processing tasks")
674
  tasks = [
675
- asyncio.create_task(self._pump_millis_to_outbound()),
676
- asyncio.create_task(self._pump_inbound_to_millis()),
677
- asyncio.create_task(self._pump_outbound_to_carrier(client_ws)),
678
  ]
 
679
  logger.info(f"Created {len(tasks)} tasks")
680
  return tasks
681
 
@@ -701,160 +390,72 @@ async def media_socket(ws: WebSocket):
701
  try:
702
  while True:
703
  raw = await ws.receive_text()
704
-
705
  msg = json.loads(raw)
706
  event = msg.get("event")
707
 
708
- # Enhanced logging for start events only
709
  if event == "start":
710
  logger.info("=== START EVENT ===")
711
- logger.info(f"Call ID: {msg.get('callId')}, Stream ID: {msg.get('streamId')}")
712
- logger.info(f"Agent ID: {msg.get('extraParams', {}).get('platform_agent_id')}")
713
- logger.info("=== END START EVENT ===")
714
- elif event == "media":
715
- payload_b64 = msg.get("payload")
716
- if payload_b64:
717
- pcm = base64.b64decode(payload_b64)
718
- # Reduce logging frequency for media chunks
719
- logger.debug(f"Received media chunk: {len(pcm)} bytes for call")
720
- else:
721
- logger.warning("Media event received but no payload found")
722
- else:
723
- logger.info(f"Received event: {event}")
724
-
725
- if event == "start":
726
- logger.info("=== START EVENT PROCESSING ===")
727
  new_call_id = msg.get("callId")
728
  stream_id = msg.get("streamId")
729
- logger.info(f"Start event details - callId: {new_call_id}, streamId: {stream_id}")
730
 
731
  if processor and new_call_id != active_call_id:
732
- logger.info(f"New call detected ({active_call_id} -> {new_call_id}). Stopping old processor.")
733
  await stop_processor(processor, tasks)
734
  processor, tasks = None, []
735
 
736
  if processor is None:
737
  logger.info(f"Starting processor for call: {new_call_id}")
738
 
739
- # Extract credentials directly from message first
740
- logger.info(f"=== EXTRACTING CREDENTIALS FOR CALL {new_call_id} ===")
741
  extra_params = msg.get("extraParams", {})
742
-
743
- # Get credentials from message
744
  agent_id = extra_params.get("platform_agent_id")
745
-
746
- # Always use environment variable for public_key (primary source)
747
  public_key = PUBLIC_KEY
748
- logger.info(f"Using environment PUBLIC_KEY: {public_key[:10] + '...' if public_key else 'None'}")
749
 
750
- # Use environment variables as fallback for missing agent_id
751
- if not agent_id:
752
- agent_id = AGENT_ID
753
- logger.info(f"Using environment AGENT_ID: {agent_id}")
754
-
755
- # If agent_id still missing, try MongoDB as final fallback
756
- if not agent_id:
757
- logger.warning("Agent ID missing, trying MongoDB fallback")
758
  credentials = await fetch_call_credentials_with_fallback(new_call_id, msg)
759
- agent_id = credentials.get("platform_agent_id") or agent_id
760
- # Keep using environment public_key, don't override
761
-
762
- logger.info(f"Using credentials - agent_id: {agent_id}, public_key: {public_key[:10] + '...' if public_key else 'None'}")
763
 
764
  if not agent_id:
765
  logger.error(f"No agent_id found for call_id: {new_call_id}")
766
- logger.error("Closing WebSocket due to missing agent_id")
767
  await ws.close(code=1008, reason="Missing agent_id")
768
  return
769
 
770
  if not public_key:
771
- logger.error("No public_key found in environment variables")
772
- logger.error("Closing WebSocket due to missing public_key")
773
  await ws.close(code=1008, reason="Missing public_key")
774
  return
775
 
776
- # Try to get metadata from MongoDB using call_id from extraParams
777
- logger.info("=== FETCHING METADATA ===")
778
- extra_params = msg.get("extraParams", {})
779
  mongodb_call_id = extra_params.get("call_id")
780
-
781
  if mongodb_call_id:
782
- logger.info(f"Found call_id in extraParams: {mongodb_call_id}")
783
  mongodb_credentials = await fetch_call_credentials(mongodb_call_id)
784
  metadata = mongodb_credentials.get("metadata", {})
785
-
786
- if metadata:
787
- logger.info(f"Using MongoDB metadata with {len(metadata)} fields")
788
- logger.info(f"MongoDB metadata keys: {list(metadata.keys())}")
789
- # Merge with extraParams to include platform_agent_id
790
- metadata.update(extra_params)
791
- logger.info(f"Metadata after merging with extraParams: {dict(list(metadata.items())[:3])}")
792
- else:
793
- logger.info("No MongoDB metadata found, using extraParams")
794
- metadata = extra_params
795
  else:
796
- logger.info("No call_id in extraParams, trying SAN callId")
797
- mongodb_credentials = await fetch_call_credentials(new_call_id)
798
- metadata = mongodb_credentials.get("metadata", {})
799
-
800
- if metadata:
801
- logger.info(f"Using MongoDB metadata with {len(metadata)} fields")
802
- logger.info(f"MongoDB metadata keys: {list(metadata.keys())}")
803
- metadata.update(extra_params)
804
- else:
805
- logger.info("No MongoDB metadata found, using extraParams")
806
- metadata = extra_params
807
-
808
- logger.info("=== METADATA RESOLVED ===")
809
 
810
- logger.info("Agent configuration resolved:")
811
- logger.info(f" - agent_id: {agent_id}")
812
- logger.info(f" - public_key: {public_key[:10]}...")
813
- logger.info(f" - metadata: {metadata}")
814
-
815
- logger.info(f"=== CREATING PROCESSOR FOR CALL {new_call_id} ===")
816
  processor = RealTimeAudioProcessor(agent_id, public_key, metadata)
817
  processor.stream_id = stream_id
818
  processor.call_id = new_call_id
819
-
820
- # Set audio thresholds from environment or use defaults
821
- speech_threshold = int(os.getenv("SPEECH_THRESHOLD", "500"))
822
- silence_threshold = int(os.getenv("SILENCE_THRESHOLD", "200"))
823
- silence_duration = float(os.getenv("SILENCE_DURATION", "1.0"))
824
- enable_debug = os.getenv("AUDIO_DEBUG", "false").lower() == "true"
825
-
826
- processor.set_audio_thresholds(speech_threshold, silence_threshold, silence_duration)
827
- if enable_debug:
828
- processor.enable_audio_debug(True)
829
-
830
- logger.info(f"Audio processor configured - Speech: {speech_threshold}, Silence: {silence_threshold}, Duration: {silence_duration}, Debug: {enable_debug}")
831
-
832
- if "mediaFormat" in msg:
833
- processor.media_format = msg["mediaFormat"]
834
- logger.info(f"Captured media format from SAN: {processor.media_format}")
835
- else:
836
- logger.warning("No mediaFormat in 'start' event. Using default.")
837
 
838
- logger.info(f"=== STARTING PROCESSOR ===")
839
  tasks = await processor.start(ws)
840
  if not tasks:
841
  logger.error("Failed to start processor")
842
  await ws.close(code=1011, reason="Could not connect to AI backend.")
843
  return
844
  active_call_id = new_call_id
845
- logger.info(f"=== START EVENT COMPLETED - {len(tasks)} tasks started ===")
846
  continue
847
 
848
  elif event == "media" and processor:
849
  payload_b64 = msg.get("payload")
850
  if payload_b64:
851
- pcm = base64.b64decode(payload_b64)
852
- async with processor.in_lock:
853
- processor.inbound.extend(pcm)
854
- # Reduce logging frequency for media processing
855
- logger.debug(f"Added {len(pcm)} bytes to inbound buffer for call {active_call_id}")
856
- else:
857
- logger.warning("Media event received but no payload found")
858
  continue
859
 
860
  elif event in ("hangup", "stop", "disconnect"):
@@ -872,9 +473,7 @@ async def media_socket(ws: WebSocket):
872
 
873
  except WebSocketDisconnect:
874
  logger.info("=== WEBSOCKET DISCONNECT ===")
875
- logger.info("SAN system disconnected the WebSocket.")
876
  except Exception as e:
877
- logger.error("=== UNHANDLED ERROR ===")
878
  logger.error(f"Unhandled error in media_socket: {e}", exc_info=True)
879
  finally:
880
  logger.info("=== FINAL CLEANUP ===")
@@ -890,7 +489,6 @@ async def media_socket(ws: WebSocket):
890
  async def health():
891
  logger.info("Health check endpoint called")
892
 
893
- # Check MongoDB connection status
894
  mongodb_status = "connected" if mongodb_client is not None and mongodb_db is not None else "disconnected"
895
 
896
  return {
@@ -902,7 +500,6 @@ async def health():
902
  "collection": MONGODB_COLLECTION or "not_set"
903
  },
904
  "config": {
905
- "agent_id": "not_set",
906
  "public_key": PUBLIC_KEY[:10] + "..." if PUBLIC_KEY else "not_set",
907
  "millis_ws_uri": MILLIS_WS_URI
908
  }
@@ -915,7 +512,7 @@ async def test():
915
 
916
  @app.get("/test-mongodb")
917
  async def test_mongodb():
918
- """Test MongoDB connectivity and add sample data."""
919
  logger.info("MongoDB test endpoint called")
920
 
921
  if mongodb_db is None:
@@ -924,7 +521,6 @@ async def test_mongodb():
924
  try:
925
  collection = mongodb_db[MONGODB_COLLECTION]
926
 
927
- # Test insert
928
  test_doc = {
929
  "call_id": "test-call-123",
930
  "platform_agent_id": "test-agent-456",
@@ -933,9 +529,6 @@ async def test_mongodb():
933
  }
934
 
935
  result = await collection.insert_one(test_doc)
936
- logger.info(f"Test document inserted with ID: {result.inserted_id}")
937
-
938
- # Test query
939
  retrieved = await collection.find_one({"call_id": "test-call-123"})
940
 
941
  return {
@@ -954,56 +547,10 @@ async def test_mongodb():
954
  "mongodb_connected": mongodb_db is not None
955
  }
956
 
957
- @app.get("/audio-config")
958
- async def get_audio_config():
959
- """Get current audio detection configuration."""
960
- return {
961
- "chunk_ms": RealTimeAudioProcessor.CHUNK_MS,
962
- "phone_chunk_size": RealTimeAudioProcessor.PHONE_CHUNK_SIZE,
963
- "millis_chunk_size": RealTimeAudioProcessor.MILLIS_CHUNK_SIZE,
964
- "interruption_threshold": 0.1,
965
- "speech_threshold": 500,
966
- "silence_threshold": 200,
967
- "silence_duration": 1.0,
968
- "description": "Audio detection configuration for interruption handling"
969
- }
970
-
971
- @app.post("/audio-config")
972
- async def update_audio_config(
973
- speech_threshold: int = None,
974
- silence_threshold: int = None,
975
- silence_duration: float = None,
976
- enable_debug: bool = False
977
- ):
978
- """Update audio detection configuration."""
979
- # Update global defaults for new processors
980
- if speech_threshold is not None:
981
- RealTimeAudioProcessor.speech_threshold = speech_threshold
982
- if silence_threshold is not None:
983
- RealTimeAudioProcessor.silence_threshold = silence_threshold
984
- if silence_duration is not None:
985
- RealTimeAudioProcessor.silence_duration = silence_duration
986
-
987
- logger.info(f"Audio config updated - Speech: {speech_threshold}, Silence: {silence_threshold}, Duration: {silence_duration}, Debug: {enable_debug}")
988
-
989
- return {
990
- "status": "updated",
991
- "speech_threshold": speech_threshold,
992
- "silence_threshold": silence_threshold,
993
- "silence_duration": silence_duration,
994
- "enable_debug": enable_debug,
995
- "message": "Audio configuration updated. Changes will apply to new connections."
996
- }
997
-
998
-
999
  if __name__ == "__main__":
1000
- print("Starting SAN to Millis AI Integration Server (v7 - MongoDB Integration)...")
1001
  logger.info("=== SERVER STARTING ===")
1002
  logger.info(f"Public Key: {PUBLIC_KEY[:10] + '...' if PUBLIC_KEY else 'NOT_SET'}")
1003
  logger.info(f"Millis URI: {MILLIS_WS_URI}")
1004
- logger.info(f"MongoDB URI: {MONGODB_CONNECTION_STRING or 'NOT_SET'}")
1005
- logger.info(f"MongoDB Database: {MONGODB_DATABASE_NAME or 'NOT_SET'}")
1006
- logger.info(f"MongoDB Collection: {MONGODB_COLLECTION or 'NOT_SET'}")
1007
  logger.info("=== SERVER READY ===")
1008
  uvicorn.run(app, host="0.0.0.0", port=8000)
1009
-
 
1
  #!/usr/bin/env python3
2
  #
3
+ # san_integration_script.py (v6 - Simplified Audio Processing)
4
+ # ============================================================
5
  # Description:
6
  # - Establishes a real-time, two-way audio bridge between a SAN system
7
  # and the Millis AI platform.
8
+ # - Direct audio forwarding at 16kHz PCM format (no conversion needed).
9
+ # - Removed buffer logic for direct streaming.
 
 
 
10
  #
11
  # Changes in this version:
12
+ # - Removed audio format conversion logic (resampling)
13
+ # - Removed buffer management and chunking
14
+ # - Simplified audio flow for direct 16kHz PCM streaming
15
+ # - Removed scipy dependency
 
 
 
 
16
  # -------------------------------------------------------------------
17
 
18
  import os
 
28
  load_dotenv()
29
 
30
  # Third-party libraries
 
 
31
  import websockets
32
  from websockets.connection import State as WsState
33
  from fastapi import FastAPI, WebSocket, WebSocketDisconnect
 
45
  logger = logging.getLogger("san-integration-app")
46
 
47
  # ---------- Environment & Configuration -----------------------------------
 
48
  PUBLIC_KEY = os.getenv("MILLIS_PUBLIC_KEY")
49
  MILLIS_WS_URI = "wss://api-west.millis.ai:8080/millis"
50
 
 
56
  # ---------- FastAPI Application -------------------------------------------
57
  app = FastAPI()
58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  @app.on_event("startup")
60
  async def startup_event():
61
  """Initialize MongoDB connection on startup."""
62
  logger.info("=== APPLICATION STARTUP ===")
63
 
 
 
 
64
  success = await connect_mongodb()
65
  if not success:
66
  logger.error("Failed to connect to MongoDB during startup")
67
  logger.warning("Application will continue running but MongoDB features will be disabled")
 
 
 
 
68
  else:
69
  logger.info("MongoDB connection established successfully")
70
  logger.info("=== STARTUP COMPLETE ===")
 
88
 
89
  if not MONGODB_CONNECTION_STRING or MONGODB_CONNECTION_STRING == "MONGODB_DATABASE_NAME":
90
  logger.error("Invalid MongoDB connection string. Please set MONGODB_CONNECTION_STRING environment variable.")
 
91
  return False
92
 
93
  mongodb_client = AsyncIOMotorClient(MONGODB_CONNECTION_STRING)
 
99
  return True
100
  except Exception as e:
101
  logger.error(f"Failed to connect to MongoDB: {e}")
 
 
102
  return False
103
 
104
  async def close_mongodb():
 
109
  logger.info("MongoDB connection closed")
110
 
111
  def filter_metadata_for_millis(document: dict) -> dict:
112
+ """Filter MongoDB document to exclude unwanted fields before sending to Millis AI."""
 
 
 
 
 
 
 
 
 
113
  excluded_fields = {
114
  "public_key", "call_id", "platform_agent_id", "stored_at", "agent_id"
115
  }
 
118
 
119
  for key, value in document.items():
120
  if key not in excluded_fields:
 
121
  if value is not None:
122
  filtered_metadata[key] = str(value)
123
  else:
124
  filtered_metadata[key] = ""
125
 
 
 
126
  logger.info(f"Filtered metadata - Final fields: {list(filtered_metadata.keys())}")
 
127
  return filtered_metadata
128
 
129
  async def fetch_call_credentials(call_id: str) -> Dict[str, Any]:
130
+ """Fetch call credentials and metadata from MongoDB."""
 
 
 
 
 
 
 
 
131
  if mongodb_db is None:
132
  logger.error("MongoDB not connected")
133
  return {}
134
 
135
  try:
136
  logger.info(f"Fetching credentials for call_id: {call_id}")
 
137
  collection = mongodb_db[MONGODB_COLLECTION]
138
 
139
+ # Query for the call metadata
140
  possible_queries = [
141
  {"call_id": call_id},
142
  {"metadata.call_id": call_id},
 
145
  ]
146
 
147
  document = None
148
+ for query in possible_queries:
 
149
  document = await collection.find_one(query)
150
  if document:
 
151
  break
152
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  if not document:
154
  logger.warning(f"No credentials found for call_id: {call_id}")
 
155
  return {}
156
 
 
 
 
 
 
 
157
  # Filter the document to exclude unwanted fields for Millis AI
158
  filtered_metadata = filter_metadata_for_millis(document)
159
 
 
160
  credentials = {
161
  "platform_agent_id": document.get("platform_agent_id"),
162
  "public_key": document.get("public_key"),
163
  "metadata": filtered_metadata
164
  }
165
 
166
+ logger.info(f"Retrieved credentials for call_id {call_id}")
 
167
  return credentials
168
 
169
  except Exception as e:
 
171
  return {}
172
 
173
  async def fetch_call_credentials_with_fallback(call_id: str, fallback_msg: dict) -> Dict[str, Any]:
174
+ """Fetch call credentials from MongoDB with fallback to message data."""
 
 
 
 
 
 
 
 
 
 
175
  logger.info(f"=== MONGODB FALLBACK FOR CALL {call_id} ===")
176
  credentials = await fetch_call_credentials(call_id)
177
 
 
179
  logger.info(f"Using MongoDB credentials for call_id: {call_id}")
180
  return credentials
181
 
182
+ # Fallback to message data
183
  logger.warning(f"MongoDB credentials incomplete for call_id {call_id}, using message fallback")
184
 
185
  extra_params = fallback_msg.get("extraParams", {})
186
  custom_field = fallback_msg.get("custom_field", {})
187
 
 
188
  agent_id = (extra_params.get("platform_agent_id") or
189
  custom_field.get("agentId") or
190
  fallback_msg.get("agentId"))
191
 
 
192
  public_key = (extra_params.get("publicKey") or extra_params.get("public_key") or
193
  custom_field.get("publicKey") or custom_field.get("public_key") or
194
  fallback_msg.get("publicKey") or fallback_msg.get("public_key"))
 
199
  "metadata": {}
200
  }
201
 
202
+ logger.warning(f"Using fallback credentials for call_id {call_id}")
203
  return fallback_credentials
204
 
205
  # ---------------------------------------------------------------------------#
206
+ # SIMPLIFIED AUDIO PROCESSOR #
207
  # ---------------------------------------------------------------------------#
208
  class RealTimeAudioProcessor:
209
  """
210
+ Simplified audio processor that directly forwards 16kHz PCM audio
211
+ between SAN system and Millis AI without buffering or conversion.
212
  """
 
 
 
 
 
 
 
213
 
214
  def __init__(self, agent_id: str, public_key: str, metadata: dict = None):
215
  self.agent_id = agent_id
 
217
  self.metadata = metadata or {}
218
  self.ws: Optional[websockets.WebSocketClientProtocol] = None
219
  self.connected = False
 
 
 
 
 
 
220
  self.is_paused = False
221
  self.stream_id: Optional[str] = None
222
  self.call_id: Optional[str] = None
 
 
 
223
  self._packet_counter = 0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224
 
225
  async def connect(self) -> bool:
226
  logger.info(f"Connecting to Millis AI for call {self.call_id}...")
 
 
 
227
 
228
  try:
229
  logger.info("Establishing WebSocket connection...")
 
241
  "include_metadata_in_prompt": True
242
  }
243
  }
 
 
 
 
 
 
 
 
244
 
245
+ logger.info(f"Sending initiate payload to Millis AI")
246
  await self.ws.send(json.dumps(initiate_payload))
 
247
 
248
  logger.info("Waiting for Millis AI response...")
249
  msg = await asyncio.wait_for(self.ws.recv(), timeout=10)
 
250
 
251
  try:
252
  parsed_msg = json.loads(msg)
 
253
  method = parsed_msg.get("method")
 
254
 
255
  if method != "onready":
256
  logger.error(f"Expected 'onready' method, got '{method}'")
 
262
 
263
  except json.JSONDecodeError as e:
264
  logger.error(f"Failed to parse response as JSON: {e}")
 
265
  raise
266
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
267
  except Exception as e:
268
  logger.error(f"Millis AI connection failed: {type(e).__name__}: {e}")
269
  self.connected = False
 
276
  self.ws = None
277
  logger.info("Disconnected from Millis AI.")
278
 
279
+ async def _forward_to_millis(self, audio_data: bytes):
280
+ """Forward audio data directly to Millis AI."""
281
+ if not self.connected or not self.ws:
282
+ return
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
283
 
284
  try:
285
+ await self.ws.send(audio_data)
286
+ self._packet_counter += 1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
287
 
288
+ if self._packet_counter >= 1000:
289
+ logger.info("Sending ping to Millis AI")
290
+ await self.ws.send(json.dumps({"method": "ping"}))
291
+ self._packet_counter = 0
 
292
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
293
  except Exception as e:
294
+ logger.error(f"Error forwarding audio to Millis: {e}")
295
+ self.connected = False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
296
 
297
+ async def _handle_millis_messages(self, client_ws: WebSocket):
298
+ """Handle messages from Millis AI and forward audio to SAN."""
299
+ logger.info(f"Starting Millis message handler for call {self.call_id}")
300
+
301
  while self.connected and self.ws and self.ws.state == WsState.OPEN:
302
  try:
303
  msg = await self.ws.recv()
304
+
305
  if isinstance(msg, bytes):
306
+ # Direct audio data - forward to SAN if not paused
307
+ if not self.is_paused:
308
+ await self._forward_to_san(client_ws, msg)
 
 
 
309
  continue
310
 
311
  # Handle JSON messages
 
313
  evt = json.loads(msg)
314
  except json.JSONDecodeError as e:
315
  logger.warning(f"Failed to parse JSON message from Millis AI: {e}")
 
316
  continue
317
 
318
  method = evt.get("method")
 
319
  if method not in ["ping", "pong"]:
320
+ logger.info(f"Millis event: {method}")
321
+
322
  if method == "pause":
323
  self.is_paused = True
324
  logger.info("Audio paused")
 
 
325
  elif method == "unpause":
326
  self.is_paused = False
327
  logger.info("Audio unpaused")
328
  elif method in ("clear", "start_answering"):
 
329
  self.is_paused = False
330
+ logger.info("Audio cleared/started")
331
+
332
  except websockets.exceptions.ConnectionClosed:
333
  logger.warning("Millis AI closed the connection.")
334
  self.connected = False
335
  except Exception as e:
336
+ logger.error(f"Error in Millis message handler: {e}")
 
337
  self.connected = False
338
 
339
+ async def _forward_to_san(self, client_ws: WebSocket, audio_data: bytes):
340
+ """Forward audio data to SAN system."""
341
+ try:
342
+ payload = base64.b64encode(audio_data).decode()
343
+
344
+ reverse_media_payload = {
345
+ "event": "reverse-media",
346
+ "callid": self.call_id,
347
+ "payload": payload,
348
+ }
349
+
350
+ await client_ws.send_json(reverse_media_payload)
351
+
352
+ except Exception as e:
353
+ logger.error(f"Error forwarding audio to SAN: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
354
 
355
  async def start(self, client_ws: WebSocket) -> list[asyncio.Task]:
356
+ """Start the audio processor."""
357
  logger.info(f"Starting RealTimeAudioProcessor for call {self.call_id}")
358
+
359
  if not await self.connect():
360
  logger.error("Failed to connect to Millis AI")
361
  return []
362
 
363
+ logger.info("Creating audio processing task")
364
  tasks = [
365
+ asyncio.create_task(self._handle_millis_messages(client_ws)),
 
 
366
  ]
367
+
368
  logger.info(f"Created {len(tasks)} tasks")
369
  return tasks
370
 
 
390
  try:
391
  while True:
392
  raw = await ws.receive_text()
 
393
  msg = json.loads(raw)
394
  event = msg.get("event")
395
 
 
396
  if event == "start":
397
  logger.info("=== START EVENT ===")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
398
  new_call_id = msg.get("callId")
399
  stream_id = msg.get("streamId")
400
+ logger.info(f"Call ID: {new_call_id}, Stream ID: {stream_id}")
401
 
402
  if processor and new_call_id != active_call_id:
403
+ logger.info(f"New call detected. Stopping old processor.")
404
  await stop_processor(processor, tasks)
405
  processor, tasks = None, []
406
 
407
  if processor is None:
408
  logger.info(f"Starting processor for call: {new_call_id}")
409
 
410
+ # Extract credentials
 
411
  extra_params = msg.get("extraParams", {})
 
 
412
  agent_id = extra_params.get("platform_agent_id")
 
 
413
  public_key = PUBLIC_KEY
 
414
 
415
+ if not agent_id or not public_key:
 
 
 
 
 
 
 
416
  credentials = await fetch_call_credentials_with_fallback(new_call_id, msg)
417
+ agent_id = agent_id or credentials.get("platform_agent_id")
418
+ public_key = public_key or credentials.get("public_key")
 
 
419
 
420
  if not agent_id:
421
  logger.error(f"No agent_id found for call_id: {new_call_id}")
 
422
  await ws.close(code=1008, reason="Missing agent_id")
423
  return
424
 
425
  if not public_key:
426
+ logger.error("No public_key found")
 
427
  await ws.close(code=1008, reason="Missing public_key")
428
  return
429
 
430
+ # Get metadata from MongoDB
 
 
431
  mongodb_call_id = extra_params.get("call_id")
 
432
  if mongodb_call_id:
 
433
  mongodb_credentials = await fetch_call_credentials(mongodb_call_id)
434
  metadata = mongodb_credentials.get("metadata", {})
435
+ metadata.update(extra_params)
 
 
 
 
 
 
 
 
 
436
  else:
437
+ metadata = extra_params
 
 
 
 
 
 
 
 
 
 
 
 
438
 
439
+ logger.info(f"Creating processor with agent_id: {agent_id}")
 
 
 
 
 
440
  processor = RealTimeAudioProcessor(agent_id, public_key, metadata)
441
  processor.stream_id = stream_id
442
  processor.call_id = new_call_id
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
443
 
 
444
  tasks = await processor.start(ws)
445
  if not tasks:
446
  logger.error("Failed to start processor")
447
  await ws.close(code=1011, reason="Could not connect to AI backend.")
448
  return
449
  active_call_id = new_call_id
450
+ logger.info("=== START EVENT COMPLETED ===")
451
  continue
452
 
453
  elif event == "media" and processor:
454
  payload_b64 = msg.get("payload")
455
  if payload_b64:
456
+ # Decode and directly forward audio data to Millis AI
457
+ pcm_data = base64.b64decode(payload_b64)
458
+ await processor._forward_to_millis(pcm_data)
 
 
 
 
459
  continue
460
 
461
  elif event in ("hangup", "stop", "disconnect"):
 
473
 
474
  except WebSocketDisconnect:
475
  logger.info("=== WEBSOCKET DISCONNECT ===")
 
476
  except Exception as e:
 
477
  logger.error(f"Unhandled error in media_socket: {e}", exc_info=True)
478
  finally:
479
  logger.info("=== FINAL CLEANUP ===")
 
489
  async def health():
490
  logger.info("Health check endpoint called")
491
 
 
492
  mongodb_status = "connected" if mongodb_client is not None and mongodb_db is not None else "disconnected"
493
 
494
  return {
 
500
  "collection": MONGODB_COLLECTION or "not_set"
501
  },
502
  "config": {
 
503
  "public_key": PUBLIC_KEY[:10] + "..." if PUBLIC_KEY else "not_set",
504
  "millis_ws_uri": MILLIS_WS_URI
505
  }
 
512
 
513
  @app.get("/test-mongodb")
514
  async def test_mongodb():
515
+ """Test MongoDB connectivity."""
516
  logger.info("MongoDB test endpoint called")
517
 
518
  if mongodb_db is None:
 
521
  try:
522
  collection = mongodb_db[MONGODB_COLLECTION]
523
 
 
524
  test_doc = {
525
  "call_id": "test-call-123",
526
  "platform_agent_id": "test-agent-456",
 
529
  }
530
 
531
  result = await collection.insert_one(test_doc)
 
 
 
532
  retrieved = await collection.find_one({"call_id": "test-call-123"})
533
 
534
  return {
 
547
  "mongodb_connected": mongodb_db is not None
548
  }
549
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
550
  if __name__ == "__main__":
551
+ print("Starting SAN to Millis AI Integration Server (v6 - Simplified)...")
552
  logger.info("=== SERVER STARTING ===")
553
  logger.info(f"Public Key: {PUBLIC_KEY[:10] + '...' if PUBLIC_KEY else 'NOT_SET'}")
554
  logger.info(f"Millis URI: {MILLIS_WS_URI}")
 
 
 
555
  logger.info("=== SERVER READY ===")
556
  uvicorn.run(app, host="0.0.0.0", port=8000)