| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import os |
| import json |
| import base64 |
| import asyncio |
| import logging |
| from datetime import datetime |
|
|
| |
| import numpy as np |
| from scipy import signal as scipy_signal |
| import websockets |
| from websockets.connection import State as WsState |
| from fastapi import FastAPI, WebSocket, WebSocketDisconnect |
| import uvicorn |
|
|
| |
| from starlette.websockets import WebSocketState |
|
|
| |
| logging.basicConfig( |
| level=logging.INFO, |
| format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", |
| ) |
| logger = logging.getLogger("san-integration-app") |
|
|
| |
| app = FastAPI() |
|
|
| |
| AGENT_ID = os.getenv("MILLIS_AGENT_ID", "-OTBEKt8tHp6GI6AeRJ2") |
| PUBLIC_KEY = os.getenv("MILLIS_PUBLIC_KEY", "Dhr5TEtwlpACHNrDmdxQZXDDtM3PgEJi") |
| MILLIS_WS_URI = "wss://api-west.millis.ai:8080/millis" |
|
|
| |
| |
| |
| class RealTimeAudioProcessor: |
| """ |
| Manages a single live call, bridging audio between the SAN system and Millis AI. |
| """ |
| PHONE_RATE = 8000 |
| MILLIS_RATE = 16000 |
| CHUNK_MS = 20 |
| BYTES_PER_SAMPLE = 2 |
|
|
| MILLIS_CHUNK_SIZE = int(MILLIS_RATE * CHUNK_MS / 1000 * BYTES_PER_SAMPLE) |
| PHONE_CHUNK_SIZE = int(PHONE_RATE * CHUNK_MS / 1000 * BYTES_PER_SAMPLE) |
|
|
| def __init__(self, agent_id: str, public_key: str): |
| self.agent_id = agent_id |
| self.public_key = public_key |
| self.ws: websockets.WebSocketClientProtocol | None = None |
| self.connected = False |
|
|
| self.inbound = bytearray() |
| self.outbound = bytearray() |
| self.in_lock = asyncio.Lock() |
| self.out_lock = asyncio.Lock() |
|
|
| self.is_paused = False |
| self.stream_id: str | None = None |
| self.call_id: str | None = None |
| self.media_format: dict = { |
| "encoding": "PCM", "sampleRate": self.PHONE_RATE, "channels": 1 |
| } |
| self._packet_counter = 0 |
|
|
| async def connect(self) -> bool: |
| logger.info("π€ Connecting to Millis AI...") |
| try: |
| self.ws = await websockets.connect(MILLIS_WS_URI, open_timeout=10) |
| await self.ws.send( |
| json.dumps({ |
| "method": "initiate", |
| "data": {"agent": {"agent_id": self.agent_id}, "public_key": self.public_key}, |
| }) |
| ) |
| msg = await asyncio.wait_for(self.ws.recv(), timeout=10) |
| if json.loads(msg).get("method") != "onready": |
| raise RuntimeError("Millis AI did not send 'onready' confirmation.") |
| self.connected = True |
| logger.info("β
Successfully connected to Millis AI.") |
| return True |
| except Exception as e: |
| logger.error(f"β Millis AI connection failed: {e}") |
| self.connected = False |
| return False |
|
|
| async def disconnect(self): |
| if self.ws and self.ws.state != WsState.CLOSED: |
| await self.ws.close() |
| self.connected = False |
| self.ws = None |
| logger.info("π Disconnected from Millis AI.") |
|
|
| @staticmethod |
| def _resample(data: bytes, from_rate: int, to_rate: int) -> bytes: |
| if not data: return b"" |
| arr = np.frombuffer(data, dtype=np.int16) |
| if arr.size == 0: return b"" |
| new_len = int(arr.size * to_rate / from_rate) |
| resampled = scipy_signal.resample(arr, new_len).astype(np.int16) |
| return resampled.tobytes() |
|
|
| async def _pump_inbound_to_millis(self): |
| while self.connected: |
| chunk8 = None |
| async with self.in_lock: |
| if len(self.inbound) >= self.PHONE_CHUNK_SIZE: |
| chunk8 = self.inbound[:self.PHONE_CHUNK_SIZE] |
| del self.inbound[:self.PHONE_CHUNK_SIZE] |
| if not chunk8: |
| await asyncio.sleep(0.005) |
| continue |
| try: |
| chunk16 = self._resample(chunk8, self.PHONE_RATE, self.MILLIS_RATE) |
| await self.ws.send(chunk16) |
| self._packet_counter += 1 |
| if self._packet_counter >= 1_000: |
| await self.ws.send(json.dumps({"method": "ping"})) |
| self._packet_counter = 0 |
| except Exception as e: |
| logger.error(f"β Error in _pump_inbound_to_millis: {e}") |
| self.connected = False |
|
|
| async def _pump_millis_to_outbound(self): |
| while self.connected and self.ws and self.ws.state == WsState.OPEN: |
| try: |
| msg = await self.ws.recv() |
| if isinstance(msg, bytes): |
| async with self.out_lock: self.outbound.extend(msg) |
| continue |
| evt = json.loads(msg) |
| method = evt.get("method") |
| logger.info(f"π€ JSON from Millis: {evt}") |
| if method == "pause": self.is_paused = True |
| elif method == "unpause": self.is_paused = False |
| elif method in ("clear", "start_answering"): |
| async with self.out_lock: self.outbound.clear() |
| self.is_paused = False |
| except websockets.exceptions.ConnectionClosed: |
| logger.warning("π Millis AI closed the connection.") |
| self.connected = False |
| except Exception as e: |
| logger.warning(f"β οΈ Error reading from Millis AI: {e}") |
| self.connected = False |
|
|
| async def _pump_outbound_to_carrier(self, client_ws: WebSocket): |
| sent_packets = 0 |
| while self.connected: |
| if self.is_paused: |
| await asyncio.sleep(0.01) |
| continue |
| chunk16 = None |
| async with self.out_lock: |
| if len(self.outbound) >= self.MILLIS_CHUNK_SIZE: |
| chunk16 = self.outbound[:self.MILLIS_CHUNK_SIZE] |
| del self.outbound[:self.MILLIS_CHUNK_SIZE] |
| if not chunk16: |
| await asyncio.sleep(0.005) |
| continue |
| try: |
| target_rate = self.media_format.get("sampleRate", self.PHONE_RATE) |
| chunk_resampled = self._resample(chunk16, self.MILLIS_RATE, target_rate) |
| payload = base64.b64encode(chunk_resampled).decode() |
| sent_packets += 1 |
| if sent_packets % 100 == 1: |
| logger.info(f"β¬οΈ Sending upstream audio packet #{sent_packets} to SAN...") |
|
|
| |
| await client_ws.send_json({ |
| "event": "reverse-media", |
| "callid": self.call_id, |
| "payload": payload, |
| |
| }) |
| |
|
|
| except Exception as e: |
| logger.error(f"β Error in _pump_outbound_to_carrier: {e}") |
| break |
|
|
| async def start(self, client_ws: WebSocket) -> list[asyncio.Task]: |
| if not await self.connect(): return [] |
| tasks = [ |
| asyncio.create_task(self._pump_millis_to_outbound()), |
| asyncio.create_task(self._pump_inbound_to_millis()), |
| asyncio.create_task(self._pump_outbound_to_carrier(client_ws)), |
| ] |
| return tasks |
|
|
| async def stop_processor(proc: RealTimeAudioProcessor | None, tasks: list[asyncio.Task]): |
| if not proc: return |
| for t in tasks: |
| if not t.done(): t.cancel() |
| await proc.disconnect() |
|
|
| |
| |
| |
| @app.websocket("/media") |
| async def media_socket(ws: WebSocket): |
| await ws.accept() |
| logger.info("π SAN system WebSocket accepted.") |
|
|
| processor: RealTimeAudioProcessor | None = None |
| tasks: list[asyncio.Task] = [] |
| active_call_id: str | None = None |
|
|
| try: |
| while True: |
| raw = await ws.receive_text() |
| msg = json.loads(raw) |
| event = msg.get("event") |
|
|
| if event == "start": |
| new_call_id = msg.get("callId") |
| stream_id = msg.get("streamId") |
|
|
| if processor and new_call_id != active_call_id: |
| logger.info(f"π New call detected ({active_call_id} -> {new_call_id}). Stopping old processor.") |
| await stop_processor(processor, tasks) |
| processor, tasks = None, [] |
|
|
| if processor is None: |
| logger.info(f"π Starting processor for call: {new_call_id}") |
| processor = RealTimeAudioProcessor(AGENT_ID, PUBLIC_KEY) |
| processor.stream_id = stream_id |
| processor.call_id = new_call_id |
| |
| if "mediaFormat" in msg: |
| processor.media_format = msg["mediaFormat"] |
| logger.info(f"π Captured media format from SAN: {processor.media_format}") |
| else: |
| logger.warning("β οΈ No mediaFormat in 'start' event. Using default.") |
|
|
| tasks = await processor.start(ws) |
| if not tasks: |
| await ws.close(code=1011, reason="Could not connect to AI backend.") |
| return |
| active_call_id = new_call_id |
| continue |
|
|
| elif event == "media" and processor: |
| payload_b64 = msg.get("payload") |
| if payload_b64: |
| pcm = base64.b64decode(payload_b64) |
| async with processor.in_lock: processor.inbound.extend(pcm) |
| continue |
|
|
| elif event in ("hangup", "stop", "disconnect"): |
| logger.info(f"π Call {active_call_id} ended via '{event}' event.") |
| await stop_processor(processor, tasks) |
| processor, tasks, active_call_id = None, [], None |
| continue |
|
|
| elif event in ("connected", "answer", "ringing"): |
| logger.debug(f"βΉοΈ Informational event received: {event}") |
| continue |
| |
| logger.warning(f"β οΈ Received unhandled event: {event}") |
|
|
| except WebSocketDisconnect: |
| logger.info("πͺ SAN system disconnected the WebSocket.") |
| except Exception as e: |
| logger.error(f"β Unhandled error in media_socket: {e}", exc_info=True) |
| finally: |
| await stop_processor(processor, tasks) |
| if ws.client_state != WebSocketState.DISCONNECTED: |
| await ws.close() |
| logger.info("β
Cleanup complete for this WebSocket connection.") |
|
|
| @app.get("/") |
| async def health(): |
| return {"status": "ok", "timestamp": datetime.now().isoformat()} |
|
|
| if __name__ == "__main__": |
| print("π Starting SAN to Millis AI Integration Server (v5 - Provider Format Fix)...") |
| uvicorn.run(app, host="0.0.0.0", port=7860) |
|
|