from __future__ import annotations import asyncio import logging import os import uuid from contextlib import asynccontextmanager from fastapi import FastAPI, WebSocket from realtime_voice_service.core.session import CallSession from realtime_voice_service.core.vad import SileroVADDetector from realtime_voice_service.providers.llm import OpenAILLM from realtime_voice_service.providers.stt import ElevenLabsSTT from realtime_voice_service.providers.tts import ElevenLabsTTS from realtime_voice_service.transports.audiosocket import AudioSocketServer from realtime_voice_service.transports.base import BaseMediaTransport from realtime_voice_service.transports.websocket import WebSocketMediaTransport LOGGER = logging.getLogger("uvicorn.error") def _int_env(name: str, default: int) -> int: raw = os.getenv(name) if raw is None: return default try: return int(str(raw).strip()) except ValueError: return default def _float_env(name: str, default: float) -> float: raw = os.getenv(name) if raw is None: return default try: return float(str(raw).strip()) except ValueError: return default def _optional_float_env(name: str) -> float | None: raw = os.getenv(name) if raw is None: return None normalized = str(raw).strip() if not normalized: return None try: return float(normalized) except ValueError: return None def _bool_env(name: str, default: bool = False) -> bool: raw = os.getenv(name) if raw is None: return default return str(raw).strip().lower() in {"1", "true", "yes", "on"} def _audiosocket_host() -> str: return str(os.getenv("REALTIME_VOICE_AUDIOSOCKET_HOST", "0.0.0.0") or "0.0.0.0").strip() def _audiosocket_port() -> int: return max(_int_env("REALTIME_VOICE_AUDIOSOCKET_PORT", 9092), 1) def _http_host() -> str: return str(os.getenv("REALTIME_VOICE_HTTP_HOST", "0.0.0.0") or "0.0.0.0").strip() def _http_port() -> int: return max(_int_env("REALTIME_VOICE_HTTP_PORT", 8000), 1) class RealtimeVoiceService: def __init__(self) -> None: self._stt = ElevenLabsSTT() self._llm = OpenAILLM() self._tts = ElevenLabsTTS() self._audiosocket_server = AudioSocketServer( host=_audiosocket_host(), port=_audiosocket_port(), session_handler=self._run_transport_session, ) self._active_sessions: dict[str, CallSession] = {} self._session_lock = asyncio.Lock() @property def active_session_count(self) -> int: return len(self._active_sessions) async def start(self) -> None: await self._audiosocket_server.start() async def stop(self) -> None: await self._audiosocket_server.stop() sessions = list(self._active_sessions.values()) for session in sessions: await session.stop() self._active_sessions.clear() async def handle_websocket(self, websocket: WebSocket, *, client_id: str | None = None) -> None: await websocket.accept() transport = WebSocketMediaTransport( websocket=websocket, transport_id=client_id or str(uuid.uuid4()), ) await self._run_transport_session(transport) async def _run_transport_session(self, transport: BaseMediaTransport) -> None: session = self._build_session(transport) async with self._track_session(session): LOGGER.info( "starting realtime session %s via %s", session.session_id, transport.protocol, ) await session.run() def _build_session(self, transport: BaseMediaTransport) -> CallSession: return CallSession( session_id=transport.transport_id, transport=transport, vad=SileroVADDetector( sample_rate_hz=transport.sample_rate_hz, threshold=_float_env("VAD_THRESHOLD", 0.5), negative_threshold=_optional_float_env("VAD_NEGATIVE_THRESHOLD"), speech_end_silence_ms=_int_env("VAD_SILENCE_TIMEOUT_MS", 1600), speech_pad_ms=_int_env("VAD_SPEECH_PAD_MS", 64), min_speech_duration_ms=_int_env("VAD_MIN_SPEECH_DURATION_MS", 0), use_onnx=_bool_env("VAD_USE_ONNX", False), ), stt=self._stt, llm=self._llm, tts=self._tts, ) @asynccontextmanager async def _track_session(self, session: CallSession): async with self._session_lock: self._active_sessions[session.session_id] = session try: yield finally: async with self._session_lock: self._active_sessions.pop(session.session_id, None) service = RealtimeVoiceService() @asynccontextmanager async def lifespan(_: FastAPI): await service.start() try: yield finally: await service.stop() app = FastAPI(title="realtime-voice-service", version="0.1.0", lifespan=lifespan) @app.get("/health") async def health() -> dict[str, object]: return { "status": "ok", "service": "realtime-voice-service", "active_sessions": service.active_session_count, "audiosocket_port": _audiosocket_port(), } @app.websocket("/ws/{client_id}") async def websocket_media(websocket: WebSocket, client_id: str) -> None: await service.handle_websocket(websocket, client_id=client_id) if __name__ == "__main__": import uvicorn uvicorn.run( "realtime_voice_service.main:app", host=_http_host(), port=_http_port(), reload=False, )