[KM-501, KM-498, KM-499] Web Socket, STS and TTS
Browse files- develop STS module
- develop TTS module
- web socket protocol implementation
- .dockerignore +7 -0
- .env.example +9 -0
- .gitignore +24 -0
- Dockerfile +19 -0
- README.md +99 -5
- main.py +73 -0
- pyproject.toml +26 -0
- server.log +95 -0
- src/__init__.py +0 -0
- src/config.py +15 -0
- src/knowledge/__init__.py +0 -0
- src/knowledge/loader.py +6 -0
- src/llm/__init__.py +0 -0
- src/llm/answerer.py +5 -0
- src/pipeline.py +96 -0
- src/stt/__init__.py +0 -0
- src/stt/assemblyai_client.py +55 -0
- src/stt/deepgram_client.py +109 -0
- src/tts/__init__.py +0 -0
- src/tts/cartesia_client.py +44 -0
- uv.lock +0 -0
.dockerignore
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.env
|
| 2 |
+
.venv/
|
| 3 |
+
__pycache__/
|
| 4 |
+
*.py[cod]
|
| 5 |
+
output.pcm
|
| 6 |
+
.gitignore
|
| 7 |
+
.dockerignore
|
.env.example
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
DEEPGRAM_API_KEY=
|
| 2 |
+
CARTESIA_API_KEY=
|
| 3 |
+
CARTESIA_VOICE_ID=
|
| 4 |
+
CARTESIA_MODEL=
|
| 5 |
+
DEEPGRAM_ENDPOINTING_MS=
|
| 6 |
+
DEEPGRAM_UTTERANCE_END_MS=
|
| 7 |
+
|
| 8 |
+
SAMPLE_RATE=
|
| 9 |
+
WAKE_WORD="Hai <agent name>"
|
.gitignore
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Environment
|
| 2 |
+
.env
|
| 3 |
+
|
| 4 |
+
# Python
|
| 5 |
+
__pycache__/
|
| 6 |
+
*.py[cod]
|
| 7 |
+
*.pyo
|
| 8 |
+
.venv/
|
| 9 |
+
venv/
|
| 10 |
+
env/
|
| 11 |
+
|
| 12 |
+
# Test output
|
| 13 |
+
output.pcm
|
| 14 |
+
|
| 15 |
+
# IDE
|
| 16 |
+
.idea/
|
| 17 |
+
.vscode/
|
| 18 |
+
*.iml
|
| 19 |
+
|
| 20 |
+
# Others
|
| 21 |
+
test_client.py
|
| 22 |
+
/playground
|
| 23 |
+
convert_audio.py
|
| 24 |
+
API_CONTRACT.md
|
Dockerfile
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
# Install uv
|
| 4 |
+
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
| 5 |
+
|
| 6 |
+
WORKDIR /app
|
| 7 |
+
|
| 8 |
+
# Install dependencies (cached layer — only re-runs when pyproject.toml changes)
|
| 9 |
+
COPY pyproject.toml .
|
| 10 |
+
RUN uv sync --no-dev --no-install-project
|
| 11 |
+
|
| 12 |
+
# Copy source
|
| 13 |
+
COPY . .
|
| 14 |
+
|
| 15 |
+
ENV PATH="/app/.venv/bin:$PATH"
|
| 16 |
+
|
| 17 |
+
EXPOSE 7860
|
| 18 |
+
|
| 19 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -1,10 +1,104 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Interview Agent Service Data Eyond
|
| 3 |
+
emoji: 🌍
|
| 4 |
+
colorFrom: pink
|
| 5 |
+
colorTo: pink
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
+
# Voice Agent Service
|
| 11 |
+
|
| 12 |
+
Real-time voice agent backend with WebSocket-based STT (AssemblyAI) and TTS (Cartesia). Accepts audio stream from client, detects wake word, and streams back synthesized speech.
|
| 13 |
+
|
| 14 |
+
## Requirements
|
| 15 |
+
|
| 16 |
+
- Python 3.11+
|
| 17 |
+
- [uv](https://docs.astral.sh/uv/getting-started/installation/)
|
| 18 |
+
- AssemblyAI API key (free tier)
|
| 19 |
+
- Cartesia API key + Voice ID (free tier)
|
| 20 |
+
|
| 21 |
+
## Setup
|
| 22 |
+
|
| 23 |
+
**1. Clone & install dependencies**
|
| 24 |
+
```bash
|
| 25 |
+
uv sync
|
| 26 |
+
```
|
| 27 |
+
|
| 28 |
+
**2. Configure environment**
|
| 29 |
+
```bash
|
| 30 |
+
cp .env.example .env
|
| 31 |
+
```
|
| 32 |
+
|
| 33 |
+
Edit `.env` dan isi API keys:
|
| 34 |
+
```env
|
| 35 |
+
ASSEMBLYAI_API_KEY=your_key
|
| 36 |
+
CARTESIA_API_KEY=your_key
|
| 37 |
+
CARTESIA_VOICE_ID=your_voice_id
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
## Run
|
| 41 |
+
|
| 42 |
+
```bash
|
| 43 |
+
uv run uvicorn main:app --host 0.0.0.0 --port 7860
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
Server akan berjalan di `http://localhost:7860`.
|
| 47 |
+
|
| 48 |
+
## Test
|
| 49 |
+
|
| 50 |
+
**Health check:**
|
| 51 |
+
```bash
|
| 52 |
+
curl http://localhost:7860/health
|
| 53 |
+
```
|
| 54 |
+
|
| 55 |
+
Expected response:
|
| 56 |
+
```json
|
| 57 |
+
{
|
| 58 |
+
"status": "ok",
|
| 59 |
+
"version": "1.1.0",
|
| 60 |
+
"stt_ready": true,
|
| 61 |
+
"tts_ready": true
|
| 62 |
+
}
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
**WebSocket test (kirim audio WAV, terima TTS response):**
|
| 66 |
+
```bash
|
| 67 |
+
uv run python test_client.py path/to/audio.wav
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
> File WAV harus dalam format: 16kHz, 16-bit, mono PCM.
|
| 71 |
+
|
| 72 |
+
Output audio response akan disimpan ke `output.pcm`. Untuk memutarnya:
|
| 73 |
+
```bash
|
| 74 |
+
ffplay -f s16le -ar 16000 -ac 1 output.pcm
|
| 75 |
+
```
|
| 76 |
+
|
| 77 |
+
**Connectivity check (tanpa file audio):**
|
| 78 |
+
```bash
|
| 79 |
+
uv run python test_client.py
|
| 80 |
+
```
|
| 81 |
+
|
| 82 |
+
Mengirim 3 detik silence untuk memverifikasi koneksi WebSocket berhasil.
|
| 83 |
+
|
| 84 |
+
## Docker
|
| 85 |
+
|
| 86 |
+
**Build:**
|
| 87 |
+
```bash
|
| 88 |
+
docker build -t voice-agent .
|
| 89 |
+
```
|
| 90 |
+
|
| 91 |
+
**Run:**
|
| 92 |
+
```bash
|
| 93 |
+
docker run -p 7860:7860 --env-file .env voice-agent
|
| 94 |
+
```
|
| 95 |
+
|
| 96 |
+
## Wake Word
|
| 97 |
+
|
| 98 |
+
Default wake word: **"hi voice agent"** (case-insensitive)
|
| 99 |
+
|
| 100 |
+
Contoh: ucapkan _"Hi Voice Agent, what time is it?"_ → agent akan membalas dengan TTS _"what time is it"_.
|
| 101 |
+
|
| 102 |
+
## API Contract
|
| 103 |
+
|
| 104 |
+
Lihat [API_CONTRACT.md](API_CONTRACT.md) untuk dokumentasi lengkap WebSocket protocol.
|
main.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import logging
|
| 3 |
+
import uvicorn
|
| 4 |
+
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
| 5 |
+
from fastapi.responses import JSONResponse
|
| 6 |
+
from src.pipeline import EchoPipeline
|
| 7 |
+
from src.config import DEEPGRAM_API_KEY, CARTESIA_API_KEY, CARTESIA_VOICE_ID
|
| 8 |
+
|
| 9 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
VERSION = "1.1.0"
|
| 13 |
+
|
| 14 |
+
app = FastAPI(title="Voice Agent Service", version=VERSION)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@app.get("/health")
|
| 18 |
+
async def health() -> JSONResponse:
|
| 19 |
+
stt_ready = bool(DEEPGRAM_API_KEY)
|
| 20 |
+
tts_ready = bool(CARTESIA_API_KEY and CARTESIA_VOICE_ID)
|
| 21 |
+
all_ready = stt_ready and tts_ready
|
| 22 |
+
|
| 23 |
+
body: dict = {
|
| 24 |
+
"status": "ok" if all_ready else "degraded",
|
| 25 |
+
"version": VERSION,
|
| 26 |
+
"stt_ready": stt_ready,
|
| 27 |
+
"tts_ready": tts_ready,
|
| 28 |
+
}
|
| 29 |
+
if not all_ready:
|
| 30 |
+
body["message"] = "One or more API keys are missing. Check your .env file."
|
| 31 |
+
|
| 32 |
+
return JSONResponse(status_code=200 if all_ready else 503, content=body)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@app.websocket("/ws/voice")
|
| 36 |
+
async def voice_ws(ws: WebSocket) -> None:
|
| 37 |
+
await ws.accept()
|
| 38 |
+
logger.info("Client connected: %s", ws.client)
|
| 39 |
+
|
| 40 |
+
async def send_audio(chunk: bytes) -> None:
|
| 41 |
+
await ws.send_bytes(chunk)
|
| 42 |
+
|
| 43 |
+
async def send_event(event: dict) -> None:
|
| 44 |
+
await ws.send_text(json.dumps(event))
|
| 45 |
+
|
| 46 |
+
pipeline = EchoPipeline(send_audio=send_audio, send_event=send_event)
|
| 47 |
+
pipeline.start()
|
| 48 |
+
|
| 49 |
+
try:
|
| 50 |
+
while True:
|
| 51 |
+
data = await ws.receive()
|
| 52 |
+
if "bytes" in data and data["bytes"]:
|
| 53 |
+
pipeline.feed_audio(data["bytes"])
|
| 54 |
+
elif "text" in data and data["text"]:
|
| 55 |
+
try:
|
| 56 |
+
msg = json.loads(data["text"])
|
| 57 |
+
action = msg.get("action")
|
| 58 |
+
if action == "stop":
|
| 59 |
+
break
|
| 60 |
+
elif action == "ping":
|
| 61 |
+
await ws.send_text(json.dumps({"event": "pong"}))
|
| 62 |
+
elif action == "interrupt":
|
| 63 |
+
await pipeline.interrupt()
|
| 64 |
+
except json.JSONDecodeError:
|
| 65 |
+
pass
|
| 66 |
+
except WebSocketDisconnect:
|
| 67 |
+
logger.info("Client disconnected: %s", ws.client)
|
| 68 |
+
finally:
|
| 69 |
+
pipeline.stop()
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
if __name__ == "__main__":
|
| 73 |
+
uvicorn.run("main:app", host="0.0.0.0", port=7860, reload=False)
|
pyproject.toml
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "voice-agent"
|
| 3 |
+
version = "1.1.0"
|
| 4 |
+
requires-python = ">=3.11"
|
| 5 |
+
dependencies = [
|
| 6 |
+
"fastapi==0.115.0",
|
| 7 |
+
"uvicorn[standard]==0.30.6",
|
| 8 |
+
"websockets==13.1",
|
| 9 |
+
"httpx==0.27.2",
|
| 10 |
+
"python-dotenv==1.0.1",
|
| 11 |
+
"assemblyai==0.33.0",
|
| 12 |
+
"cartesia==1.3.1",
|
| 13 |
+
"deepgram-sdk>=6.1.1",
|
| 14 |
+
]
|
| 15 |
+
|
| 16 |
+
[project.optional-dependencies]
|
| 17 |
+
dev = [
|
| 18 |
+
"websockets==13.1",
|
| 19 |
+
]
|
| 20 |
+
|
| 21 |
+
[build-system]
|
| 22 |
+
requires = ["hatchling"]
|
| 23 |
+
build-backend = "hatchling.build"
|
| 24 |
+
|
| 25 |
+
[tool.hatch.build.targets.wheel]
|
| 26 |
+
packages = ["src"]
|
server.log
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
INFO: Started server process [3804]
|
| 2 |
+
INFO: Waiting for application startup.
|
| 3 |
+
INFO: Application startup complete.
|
| 4 |
+
ERROR: [Errno 10048] error while attempting to bind on address ('0.0.0.0', 7860): [winerror 10048] only one usage of each socket address (protocol/network address/port) is normally permitted
|
| 5 |
+
INFO: Waiting for application shutdown.
|
| 6 |
+
INFO: INFO: 127.0.0.1:62731 - "GET /health HTTP/1.1" 200 OK
|
| 7 |
+
INFO: 127.0.0.1:58019 - "GET /health HTTP/1.1" 200 OK
|
| 8 |
+
INFO: 127.0.0.1:58328 - "GET /health HTTP/1.1" 200 OK
|
| 9 |
+
INFO: ('127.0.0.1', 64692) - "WebSocket /ws/voice" [accepted]
|
| 10 |
+
2026-04-19 10:55:42,299 [INFO] main: Client connected: Address(host='127.0.0.1', port=64692)
|
| 11 |
+
2026-04-19 10:55:43,272 [ERROR] src.stt.assemblyai_client: AssemblyAI error: Could not connect to the real-time service: server rejected WebSocket connection: HTTP 404
|
| 12 |
+
2026-04-19 10:55:43,280 [INFO] src.stt.assemblyai_client: AssemblyAI STT connected
|
| 13 |
+
INFO: connection open
|
| 14 |
+
2026-04-19 10:55:43,287 [INFO] src.stt.assemblyai_client: AssemblyAI STT closed
|
| 15 |
+
INFO: connection closed
|
| 16 |
+
INFO: ('127.0.0.1', 64730) - "WebSocket /ws/voice" [accepted]
|
| 17 |
+
2026-04-19 10:55:45,331 [INFO] main: Client connected: Address(host='127.0.0.1', port=64730)
|
| 18 |
+
2026-04-19 10:55:45,932 [ERROR] src.stt.assemblyai_client: AssemblyAI error: Could not connect to the real-time service: server rejected WebSocket connection: HTTP 404
|
| 19 |
+
2026-04-19 10:55:45,932 [INFO] src.stt.assemblyai_client: AssemblyAI STT connected
|
| 20 |
+
INFO: connection open
|
| 21 |
+
2026-04-19 10:55:45,934 [INFO] src.pipeline: TTS interrupted by client
|
| 22 |
+
2026-04-19 10:55:45,935 [INFO] src.stt.assemblyai_client: AssemblyAI STT closed
|
| 23 |
+
ERROR: Exception in ASGI application
|
| 24 |
+
Traceback (most recent call last):
|
| 25 |
+
File "C:\Users\HarryyantoIshaqAgasi\Documents\HarryProjects\DataEyond\Interview-Agent-Service-Data-Eyond\.venv\Lib\site-packages\uvicorn\protocols\websockets\websockets_impl.py", line 244, in run_asgi
|
| 26 |
+
result = await self.app(self.scope, self.asgi_receive, self.asgi_send) # type: ignore[func-returns-value]
|
| 27 |
+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
| 28 |
+
File "C:\Users\HarryyantoIshaqAgasi\Documents\HarryProjects\DataEyond\Interview-Agent-Service-Data-Eyond\.venv\Lib\site-packages\uvicorn\middleware\proxy_headers.py", line 70, in __call__
|
| 29 |
+
return await self.app(scope, receive, send)
|
| 30 |
+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
| 31 |
+
File "C:\Users\HarryyantoIshaqAgasi\Documents\HarryProjects\DataEyond\Interview-Agent-Service-Data-Eyond\.venv\Lib\site-packages\fastapi\applications.py", line 1054, in __call__
|
| 32 |
+
await super().__call__(scope, receive, send)
|
| 33 |
+
File "C:\Users\HarryyantoIshaqAgasi\Documents\HarryProjects\DataEyond\Interview-Agent-Service-Data-Eyond\.venv\Lib\site-packages\starlette\applications.py", line 113, in __call__
|
| 34 |
+
await self.middleware_stack(scope, receive, send)
|
| 35 |
+
File "C:\Users\HarryyantoIshaqAgasi\Documents\HarryProjects\DataEyond\Interview-Agent-Service-Data-Eyond\.venv\Lib\site-packages\starlette\middleware\errors.py", line 152, in __call__
|
| 36 |
+
await self.app(scope, receive, send)
|
| 37 |
+
File "C:\Users\HarryyantoIshaqAgasi\Documents\HarryProjects\DataEyond\Interview-Agent-Service-Data-Eyond\.venv\Lib\site-packages\starlette\middleware\exceptions.py", line 62, in __call__
|
| 38 |
+
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
|
| 39 |
+
File "C:\Users\HarryyantoIshaqAgasi\Documents\HarryProjects\DataEyond\Interview-Agent-Service-Data-Eyond\.venv\Lib\site-packages\starlette\_exception_handler.py", line 62, in wrapped_app
|
| 40 |
+
raise exc
|
| 41 |
+
File "C:\Users\HarryyantoIshaqAgasi\Documents\HarryProjects\DataEyond\Interview-Agent-Service-Data-Eyond\.venv\Lib\site-packages\starlette\_exception_handler.py", line 51, in wrapped_app
|
| 42 |
+
await app(scope, receive, sender)
|
| 43 |
+
File "C:\Users\HarryyantoIshaqAgasi\Documents\HarryProjects\DataEyond\Interview-Agent-Service-Data-Eyond\.venv\Lib\site-packages\starlette\routing.py", line 715, in __call__
|
| 44 |
+
await self.middleware_stack(scope, receive, send)
|
| 45 |
+
File "C:\Users\HarryyantoIshaqAgasi\Documents\HarryProjects\DataEyond\Interview-Agent-Service-Data-Eyond\.venv\Lib\site-packages\starlette\routing.py", line 735, in app
|
| 46 |
+
await route.handle(scope, receive, send)
|
| 47 |
+
File "C:\Users\HarryyantoIshaqAgasi\Documents\HarryProjects\DataEyond\Interview-Agent-Service-Data-Eyond\.venv\Lib\site-packages\starlette\routing.py", line 362, in handle
|
| 48 |
+
await self.app(scope, receive, send)
|
| 49 |
+
File "C:\Users\HarryyantoIshaqAgasi\Documents\HarryProjects\DataEyond\Interview-Agent-Service-Data-Eyond\.venv\Lib\site-packages\starlette\routing.py", line 95, in app
|
| 50 |
+
await wrap_app_handling_exceptions(app, session)(scope, receive, send)
|
| 51 |
+
File "C:\Users\HarryyantoIshaqAgasi\Documents\HarryProjects\DataEyond\Interview-Agent-Service-Data-Eyond\.venv\Lib\site-packages\starlette\_exception_handler.py", line 62, in wrapped_app
|
| 52 |
+
raise exc
|
| 53 |
+
File "C:\Users\HarryyantoIshaqAgasi\Documents\HarryProjects\DataEyond\Interview-Agent-Service-Data-Eyond\.venv\Lib\site-packages\starlette\_exception_handler.py", line 51, in wrapped_app
|
| 54 |
+
await app(scope, receive, sender)
|
| 55 |
+
File "C:\Users\HarryyantoIshaqAgasi\Documents\HarryProjects\DataEyond\Interview-Agent-Service-Data-Eyond\.venv\Lib\site-packages\starlette\routing.py", line 93, in app
|
| 56 |
+
await func(session)
|
| 57 |
+
File "C:\Users\HarryyantoIshaqAgasi\Documents\HarryProjects\DataEyond\Interview-Agent-Service-Data-Eyond\.venv\Lib\site-packages\fastapi\routing.py", line 383, in app
|
| 58 |
+
await dependant.call(**solved_result.values)
|
| 59 |
+
File "C:\Users\HarryyantoIshaqAgasi\Documents\HarryProjects\DataEyond\Interview-Agent-Service-Data-Eyond\main.py", line 51, in voice_ws
|
| 60 |
+
data = await ws.receive()
|
| 61 |
+
^^^^^^^^^^^^^^^^^^
|
| 62 |
+
File "C:\Users\HarryyantoIshaqAgasi\Documents\HarryProjects\DataEyond\Interview-Agent-Service-Data-Eyond\.venv\Lib\site-packages\starlette\websockets.py", line 56, in receive
|
| 63 |
+
raise RuntimeError('Cannot call "receive" once a disconnect message has been received.')
|
| 64 |
+
RuntimeError: Cannot call "receive" once a disconnect message has been received.
|
| 65 |
+
INFO: connection closed
|
| 66 |
+
INFO: ('127.0.0.1', 64763) - "WebSocket /ws/voice" [accepted]
|
| 67 |
+
2026-04-19 10:55:47,981 [INFO] main: Client connected: Address(host='127.0.0.1', port=64763)
|
| 68 |
+
2026-04-19 10:55:48,609 [ERROR] src.stt.assemblyai_client: AssemblyAI error: Could not connect to the real-time service: server rejected WebSocket connection: HTTP 404
|
| 69 |
+
2026-04-19 10:55:48,609 [INFO] src.stt.assemblyai_client: AssemblyAI STT connected
|
| 70 |
+
INFO: connection open
|
| 71 |
+
2026-04-19 10:55:48,611 [INFO] src.stt.assemblyai_client: AssemblyAI STT closed
|
| 72 |
+
INFO: connection closed
|
| 73 |
+
INFO: 127.0.0.1:64988 - "GET /health HTTP/1.1" 200 OK
|
| 74 |
+
INFO: ('127.0.0.1', 65015) - "WebSocket /ws/voice" [accepted]
|
| 75 |
+
2026-04-19 10:56:08,787 [INFO] main: Client connected: Address(host='127.0.0.1', port=65015)
|
| 76 |
+
2026-04-19 10:56:09,406 [ERROR] src.stt.assemblyai_client: AssemblyAI error: Could not connect to the real-time service: server rejected WebSocket connection: HTTP 404
|
| 77 |
+
2026-04-19 10:56:09,406 [INFO] src.stt.assemblyai_client: AssemblyAI STT connected
|
| 78 |
+
INFO: connection open
|
| 79 |
+
2026-04-19 10:56:09,408 [INFO] src.stt.assemblyai_client: AssemblyAI STT closed
|
| 80 |
+
INFO: connection closed
|
| 81 |
+
INFO: ('127.0.0.1', 65047) - "WebSocket /ws/voice" [accepted]
|
| 82 |
+
2026-04-19 10:56:11,439 [INFO] main: Client connected: Address(host='127.0.0.1', port=65047)
|
| 83 |
+
2026-04-19 10:56:12,038 [ERROR] src.stt.assemblyai_client: AssemblyAI error: Could not connect to the real-time service: server rejected WebSocket connection: HTTP 404
|
| 84 |
+
2026-04-19 10:56:12,039 [INFO] src.stt.assemblyai_client: AssemblyAI STT connected
|
| 85 |
+
INFO: connection open
|
| 86 |
+
2026-04-19 10:56:12,040 [INFO] src.pipeline: TTS interrupted by client
|
| 87 |
+
2026-04-19 10:56:12,040 [INFO] src.stt.assemblyai_client: AssemblyAI STT closed
|
| 88 |
+
INFO: connection closed
|
| 89 |
+
INFO: ('127.0.0.1', 54874) - "WebSocket /ws/voice" [accepted]
|
| 90 |
+
2026-04-19 10:56:14,069 [INFO] main: Client connected: Address(host='127.0.0.1', port=54874)
|
| 91 |
+
2026-04-19 10:56:14,681 [ERROR] src.stt.assemblyai_client: AssemblyAI error: Could not connect to the real-time service: server rejected WebSocket connection: HTTP 404
|
| 92 |
+
2026-04-19 10:56:14,681 [INFO] src.stt.assemblyai_client: AssemblyAI STT connected
|
| 93 |
+
INFO: connection open
|
| 94 |
+
2026-04-19 10:56:14,682 [INFO] src.stt.assemblyai_client: AssemblyAI STT closed
|
| 95 |
+
INFO: connection closed
|
src/__init__.py
ADDED
|
File without changes
|
src/config.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dotenv import load_dotenv
|
| 2 |
+
import os
|
| 3 |
+
|
| 4 |
+
load_dotenv()
|
| 5 |
+
|
| 6 |
+
DEEPGRAM_API_KEY: str = os.environ["DEEPGRAM_API_KEY"]
|
| 7 |
+
CARTESIA_API_KEY: str = os.environ["CARTESIA_API_KEY"]
|
| 8 |
+
CARTESIA_VOICE_ID: str = os.environ["CARTESIA_VOICE_ID"]
|
| 9 |
+
|
| 10 |
+
SAMPLE_RATE: int = int(os.getenv("SAMPLE_RATE", "16000"))
|
| 11 |
+
WAKE_WORDS: list[str] = [w.strip().lower() for w in os.getenv("WAKE_WORD", "Hai EMA").split(",") if w.strip()]
|
| 12 |
+
CARTESIA_MODEL: str = os.getenv("CARTESIA_MODEL", "sonic-3")
|
| 13 |
+
DEEPGRAM_LANGUAGE: str = os.getenv("DEEPGRAM_LANGUAGE", "id")
|
| 14 |
+
DEEPGRAM_ENDPOINTING_MS: int = int(os.getenv("DEEPGRAM_ENDPOINTING_MS", "300"))
|
| 15 |
+
DEEPGRAM_UTTERANCE_END_MS: int = int(os.getenv("DEEPGRAM_UTTERANCE_END_MS", "2000"))
|
src/knowledge/__init__.py
ADDED
|
File without changes
|
src/knowledge/loader.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Phase 2 placeholder: PDF knowledge base loader
|
| 2 |
+
# Will use: pypdf or pdfplumber for extraction, then chunk + embed for RAG
|
| 3 |
+
#
|
| 4 |
+
# Interface (to be implemented):
|
| 5 |
+
# async def load_pdf(path: str) -> list[str]: ...
|
| 6 |
+
# async def retrieve(query: str, top_k: int = 3) -> list[str]: ...
|
src/llm/__init__.py
ADDED
|
File without changes
|
src/llm/answerer.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Phase 2 placeholder: LLM answer generation
|
| 2 |
+
# Will use: Anthropic Claude API with retrieved PDF context
|
| 3 |
+
#
|
| 4 |
+
# Interface (to be implemented):
|
| 5 |
+
# async def answer(question: str, context_chunks: list[str]) -> str: ...
|
src/pipeline.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import logging
|
| 3 |
+
from typing import Callable, Awaitable
|
| 4 |
+
from src.config import WAKE_WORDS
|
| 5 |
+
from src.stt.deepgram_client import DeepgramStreamer
|
| 6 |
+
from src.tts.cartesia_client import synthesize_stream
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger(__name__)
|
| 9 |
+
|
| 10 |
+
SendAudioCallback = Callable[[bytes], Awaitable[None]]
|
| 11 |
+
SendEventCallback = Callable[[dict], Awaitable[None]]
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class EchoPipeline:
|
| 15 |
+
"""
|
| 16 |
+
MVP Phase 1: Echo pipeline.
|
| 17 |
+
Audio in → STT → wake word check → TTS (echo) → audio out.
|
| 18 |
+
|
| 19 |
+
Phase 2 will replace the echo step with LLM answer generation.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
def __init__(self, send_audio: SendAudioCallback, send_event: SendEventCallback):
|
| 23 |
+
self._send_audio = send_audio
|
| 24 |
+
self._send_event = send_event
|
| 25 |
+
self._loop = asyncio.get_event_loop()
|
| 26 |
+
self._stt = DeepgramStreamer(
|
| 27 |
+
on_final_transcript=self._on_final_transcript,
|
| 28 |
+
loop=self._loop,
|
| 29 |
+
)
|
| 30 |
+
self._tts_lock = asyncio.Lock()
|
| 31 |
+
self._tts_task: asyncio.Task | None = None
|
| 32 |
+
|
| 33 |
+
def start(self) -> None:
|
| 34 |
+
self._stt.start()
|
| 35 |
+
|
| 36 |
+
def feed_audio(self, chunk: bytes) -> None:
|
| 37 |
+
self._stt.send_audio(chunk)
|
| 38 |
+
|
| 39 |
+
async def _on_final_transcript(self, text: str) -> None:
|
| 40 |
+
await self._send_event({"event": "transcript", "text": text})
|
| 41 |
+
|
| 42 |
+
reply_text = self._extract_reply(text)
|
| 43 |
+
if reply_text is None:
|
| 44 |
+
logger.debug("No wake word detected, ignoring: %s", text)
|
| 45 |
+
return
|
| 46 |
+
|
| 47 |
+
logger.info("Wake word detected, replying with: %s", reply_text)
|
| 48 |
+
await self._send_event({"event": "reply", "text": reply_text})
|
| 49 |
+
|
| 50 |
+
# Run as a Task so it can be cancelled via interrupt()
|
| 51 |
+
self._tts_task = asyncio.create_task(self._speak(reply_text))
|
| 52 |
+
|
| 53 |
+
def _extract_reply(self, transcript: str) -> str | None:
|
| 54 |
+
lower = transcript.lower()
|
| 55 |
+
match = min(
|
| 56 |
+
((lower.index(w), w) for w in WAKE_WORDS if w in lower),
|
| 57 |
+
key=lambda x: x[0],
|
| 58 |
+
default=None,
|
| 59 |
+
)
|
| 60 |
+
if match is None:
|
| 61 |
+
return None
|
| 62 |
+
idx = match[0] + len(match[1])
|
| 63 |
+
reply = transcript[idx:].strip(" ,.")
|
| 64 |
+
return reply if reply else "Hello! How can I help you?"
|
| 65 |
+
|
| 66 |
+
async def _speak(self, text: str) -> None:
|
| 67 |
+
async with self._tts_lock:
|
| 68 |
+
try:
|
| 69 |
+
async for audio_chunk in synthesize_stream(text):
|
| 70 |
+
await self._send_audio(audio_chunk)
|
| 71 |
+
await self._send_event({"event": "tts_end"})
|
| 72 |
+
except asyncio.CancelledError:
|
| 73 |
+
# Interrupted mid-stream — do not send tts_end
|
| 74 |
+
raise
|
| 75 |
+
except Exception:
|
| 76 |
+
logger.exception("TTS error for text: %s", text)
|
| 77 |
+
await self._send_event({
|
| 78 |
+
"event": "error",
|
| 79 |
+
"code": "TTS_ERROR",
|
| 80 |
+
"message": "TTS generation failed",
|
| 81 |
+
})
|
| 82 |
+
|
| 83 |
+
async def interrupt(self) -> None:
|
| 84 |
+
if self._tts_task and not self._tts_task.done():
|
| 85 |
+
self._tts_task.cancel()
|
| 86 |
+
try:
|
| 87 |
+
await self._tts_task
|
| 88 |
+
except asyncio.CancelledError:
|
| 89 |
+
pass
|
| 90 |
+
await self._send_event({"event": "interrupted"})
|
| 91 |
+
logger.info("TTS interrupted by client")
|
| 92 |
+
|
| 93 |
+
def stop(self) -> None:
|
| 94 |
+
if self._tts_task and not self._tts_task.done():
|
| 95 |
+
self._tts_task.cancel()
|
| 96 |
+
self._stt.stop()
|
src/stt/__init__.py
ADDED
|
File without changes
|
src/stt/assemblyai_client.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import logging
|
| 3 |
+
from typing import Callable, Awaitable
|
| 4 |
+
import assemblyai as aai
|
| 5 |
+
from src.config import ASSEMBLYAI_API_KEY, SAMPLE_RATE
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger(__name__)
|
| 8 |
+
|
| 9 |
+
OnTranscriptCallback = Callable[[str], Awaitable[None]]
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class AssemblyAIStreamer:
|
| 13 |
+
"""
|
| 14 |
+
Wraps AssemblyAI real-time streaming STT.
|
| 15 |
+
Audio chunks (PCM 16kHz 16-bit mono bytes) are fed via `send_audio()`.
|
| 16 |
+
When a final transcript arrives, `on_final_transcript` callback is awaited.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
def __init__(self, on_final_transcript: OnTranscriptCallback, loop: asyncio.AbstractEventLoop):
|
| 20 |
+
self._on_final_transcript = on_final_transcript
|
| 21 |
+
self._loop = loop
|
| 22 |
+
self._transcriber: aai.RealtimeTranscriber | None = None
|
| 23 |
+
|
| 24 |
+
def start(self) -> None:
|
| 25 |
+
aai.settings.api_key = ASSEMBLYAI_API_KEY
|
| 26 |
+
|
| 27 |
+
self._transcriber = aai.RealtimeTranscriber(
|
| 28 |
+
sample_rate=SAMPLE_RATE,
|
| 29 |
+
on_data=self._on_data,
|
| 30 |
+
on_error=self._on_error,
|
| 31 |
+
)
|
| 32 |
+
self._transcriber.connect()
|
| 33 |
+
logger.info("AssemblyAI STT connected")
|
| 34 |
+
|
| 35 |
+
def _on_data(self, transcript: aai.RealtimeTranscript) -> None:
|
| 36 |
+
if not isinstance(transcript, aai.RealtimeFinalTranscript):
|
| 37 |
+
return
|
| 38 |
+
text = transcript.text.strip()
|
| 39 |
+
if not text:
|
| 40 |
+
return
|
| 41 |
+
logger.info("Final transcript: %s", text)
|
| 42 |
+
asyncio.run_coroutine_threadsafe(self._on_final_transcript(text), self._loop)
|
| 43 |
+
|
| 44 |
+
def _on_error(self, error: aai.RealtimeError) -> None:
|
| 45 |
+
logger.error("AssemblyAI error: %s", error)
|
| 46 |
+
|
| 47 |
+
def send_audio(self, chunk: bytes) -> None:
|
| 48 |
+
if self._transcriber:
|
| 49 |
+
self._transcriber.stream(chunk)
|
| 50 |
+
|
| 51 |
+
def stop(self) -> None:
|
| 52 |
+
if self._transcriber:
|
| 53 |
+
self._transcriber.close()
|
| 54 |
+
self._transcriber = None
|
| 55 |
+
logger.info("AssemblyAI STT closed")
|
src/stt/deepgram_client.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import logging
|
| 3 |
+
import threading
|
| 4 |
+
from typing import Callable, Awaitable
|
| 5 |
+
|
| 6 |
+
from deepgram import DeepgramClient
|
| 7 |
+
from deepgram.core.events import EventType
|
| 8 |
+
from src.config import DEEPGRAM_API_KEY, SAMPLE_RATE, DEEPGRAM_LANGUAGE, DEEPGRAM_ENDPOINTING_MS, DEEPGRAM_UTTERANCE_END_MS
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
OnTranscriptCallback = Callable[[str], Awaitable[None]]
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class DeepgramStreamer:
|
| 16 |
+
"""
|
| 17 |
+
Wraps Deepgram real-time streaming STT.
|
| 18 |
+
Audio chunks (PCM 16kHz 16-bit mono bytes) are fed via `send_audio()`.
|
| 19 |
+
|
| 20 |
+
Final transcript segments are buffered. A flush timer (DEEPGRAM_UTTERANCE_END_MS)
|
| 21 |
+
is reset on each new segment. When the timer fires, all buffered segments are
|
| 22 |
+
joined and emitted as one complete utterance.
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
def __init__(self, on_final_transcript: OnTranscriptCallback, loop: asyncio.AbstractEventLoop):
|
| 26 |
+
self._on_final_transcript = on_final_transcript
|
| 27 |
+
self._loop = loop
|
| 28 |
+
self._connection = None
|
| 29 |
+
self._cm = None
|
| 30 |
+
self._ready = threading.Event()
|
| 31 |
+
self._transcript_buffer: list[str] = []
|
| 32 |
+
self._buffer_lock = threading.Lock()
|
| 33 |
+
self._flush_handle: asyncio.TimerHandle | None = None
|
| 34 |
+
|
| 35 |
+
def start(self) -> None:
|
| 36 |
+
client = DeepgramClient(api_key=DEEPGRAM_API_KEY)
|
| 37 |
+
|
| 38 |
+
connect_kwargs: dict = dict(
|
| 39 |
+
model="nova-2",
|
| 40 |
+
encoding="linear16",
|
| 41 |
+
sample_rate=SAMPLE_RATE,
|
| 42 |
+
endpointing=DEEPGRAM_ENDPOINTING_MS,
|
| 43 |
+
)
|
| 44 |
+
if DEEPGRAM_LANGUAGE:
|
| 45 |
+
connect_kwargs["language"] = DEEPGRAM_LANGUAGE
|
| 46 |
+
|
| 47 |
+
self._cm = client.listen.v1.connect(**connect_kwargs)
|
| 48 |
+
self._connection = self._cm.__enter__()
|
| 49 |
+
|
| 50 |
+
self._connection.on(EventType.OPEN, self._on_open)
|
| 51 |
+
self._connection.on(EventType.MESSAGE, self._on_message)
|
| 52 |
+
self._connection.on(EventType.ERROR, self._on_error)
|
| 53 |
+
|
| 54 |
+
# start_listening() is blocking — run in background thread
|
| 55 |
+
thread = threading.Thread(target=self._connection.start_listening, daemon=True)
|
| 56 |
+
thread.start()
|
| 57 |
+
|
| 58 |
+
# Wait until connection is open before returning
|
| 59 |
+
self._ready.wait(timeout=5)
|
| 60 |
+
logger.info("Deepgram STT connected")
|
| 61 |
+
|
| 62 |
+
def _on_open(self, _result) -> None:
|
| 63 |
+
self._ready.set()
|
| 64 |
+
|
| 65 |
+
def _on_message(self, result) -> None:
|
| 66 |
+
try:
|
| 67 |
+
transcript = result.channel.alternatives[0].transcript.strip()
|
| 68 |
+
except (AttributeError, IndexError):
|
| 69 |
+
return
|
| 70 |
+
if not transcript or not result.is_final:
|
| 71 |
+
return
|
| 72 |
+
with self._buffer_lock:
|
| 73 |
+
self._transcript_buffer.append(transcript)
|
| 74 |
+
logger.debug("Buffered segment: %s", transcript)
|
| 75 |
+
# Reset flush timer on the event loop thread
|
| 76 |
+
self._loop.call_soon_threadsafe(self._reset_flush_timer)
|
| 77 |
+
|
| 78 |
+
def _reset_flush_timer(self) -> None:
|
| 79 |
+
if self._flush_handle is not None:
|
| 80 |
+
self._flush_handle.cancel()
|
| 81 |
+
delay = DEEPGRAM_UTTERANCE_END_MS / 1000.0
|
| 82 |
+
self._flush_handle = self._loop.call_later(delay, self._flush_buffer)
|
| 83 |
+
|
| 84 |
+
def _flush_buffer(self) -> None:
|
| 85 |
+
with self._buffer_lock:
|
| 86 |
+
if not self._transcript_buffer:
|
| 87 |
+
return
|
| 88 |
+
full_text = " ".join(self._transcript_buffer)
|
| 89 |
+
self._transcript_buffer.clear()
|
| 90 |
+
self._flush_handle = None
|
| 91 |
+
logger.info("Final transcript: %s", full_text)
|
| 92 |
+
asyncio.ensure_future(self._on_final_transcript(full_text), loop=self._loop)
|
| 93 |
+
|
| 94 |
+
def _on_error(self, error) -> None:
|
| 95 |
+
logger.error("Deepgram error: %s", error)
|
| 96 |
+
|
| 97 |
+
def send_audio(self, chunk: bytes) -> None:
|
| 98 |
+
if self._connection:
|
| 99 |
+
self._connection.send_media(chunk)
|
| 100 |
+
|
| 101 |
+
def stop(self) -> None:
|
| 102 |
+
if self._cm:
|
| 103 |
+
try:
|
| 104 |
+
self._cm.__exit__(None, None, None)
|
| 105 |
+
except Exception:
|
| 106 |
+
pass
|
| 107 |
+
self._connection = None
|
| 108 |
+
self._cm = None
|
| 109 |
+
logger.info("Deepgram STT closed")
|
src/tts/__init__.py
ADDED
|
File without changes
|
src/tts/cartesia_client.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from typing import AsyncIterator
|
| 3 |
+
import httpx
|
| 4 |
+
from src.config import CARTESIA_API_KEY, CARTESIA_VOICE_ID, CARTESIA_MODEL, SAMPLE_RATE
|
| 5 |
+
|
| 6 |
+
logger = logging.getLogger(__name__)
|
| 7 |
+
|
| 8 |
+
CARTESIA_TTS_URL = "https://api.cartesia.ai/tts/bytes"
|
| 9 |
+
|
| 10 |
+
# PCM 16-bit signed little-endian, matching our pipeline sample rate
|
| 11 |
+
OUTPUT_FORMAT = {
|
| 12 |
+
"container": "raw",
|
| 13 |
+
"encoding": "pcm_s16le",
|
| 14 |
+
"sample_rate": SAMPLE_RATE,
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
async def synthesize_stream(text: str) -> AsyncIterator[bytes]:
|
| 19 |
+
"""
|
| 20 |
+
Calls Cartesia TTS and yields raw PCM audio chunks as they arrive.
|
| 21 |
+
Yields bytes immediately for lowest possible latency.
|
| 22 |
+
"""
|
| 23 |
+
payload = {
|
| 24 |
+
"model_id": CARTESIA_MODEL,
|
| 25 |
+
"transcript": text,
|
| 26 |
+
"voice": {"mode": "id", "id": CARTESIA_VOICE_ID},
|
| 27 |
+
"output_format": OUTPUT_FORMAT,
|
| 28 |
+
}
|
| 29 |
+
headers = {
|
| 30 |
+
"X-API-Key": CARTESIA_API_KEY,
|
| 31 |
+
"Cartesia-Version": "2025-04-16",
|
| 32 |
+
"Content-Type": "application/json",
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
logger.info("Cartesia TTS: synthesizing '%s'", text[:60])
|
| 36 |
+
async with httpx.AsyncClient(timeout=30) as client:
|
| 37 |
+
async with client.stream("POST", CARTESIA_TTS_URL, json=payload, headers=headers) as response:
|
| 38 |
+
if response.status_code >= 400:
|
| 39 |
+
body = await response.aread()
|
| 40 |
+
logger.error("Cartesia TTS %d: %s", response.status_code, body.decode())
|
| 41 |
+
response.raise_for_status()
|
| 42 |
+
async for chunk in response.aiter_bytes(chunk_size=4096):
|
| 43 |
+
if chunk:
|
| 44 |
+
yield chunk
|
uv.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|