ishaq101 commited on
Commit
226ff5d
·
1 Parent(s): 7f09335

[KM-501, KM-498, KM-499] Web Socket, STS and TTS

Browse files

- develop STS module
- develop TTS module
- web socket protocol implementation

.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: Demo Voice Agent Service
3
- emoji: 🏃
4
- colorFrom: red
5
- colorTo: gray
6
  sdk: docker
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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