Initial commit
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
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,
|
||||
)
|
||||
Reference in New Issue
Block a user