TTS and STT using gemini model
Browse files- .env.example +14 -0
- README.md +6 -6
- main.py +38 -7
- src/config.py +10 -0
- src/pipeline.py +36 -12
- src/stt/gemini_stt.py +137 -0
.env.example
CHANGED
|
@@ -7,6 +7,7 @@ DEEPGRAM_UTTERANCE_END_MS=
|
|
| 7 |
|
| 8 |
SAMPLE_RATE=
|
| 9 |
WAKE_WORD="Hai <agent name>"
|
|
|
|
| 10 |
|
| 11 |
# Gemini TTS (optional — hanya dibutuhkan jika provider=gemini)
|
| 12 |
GOOGLE_API_KEY=
|
|
@@ -17,3 +18,16 @@ GEMINI_TTS_LANGUAGE=id-ID
|
|
| 17 |
# Chatbot agent service
|
| 18 |
CHATBOT_BASE_URL=http://localhost:8000/chatbot-knowledgemanagement
|
| 19 |
CHATBOT_TIMEOUT=30
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
SAMPLE_RATE=
|
| 9 |
WAKE_WORD="Hai <agent name>"
|
| 10 |
+
WAKE_WORD_ENABLED=false
|
| 11 |
|
| 12 |
# Gemini TTS (optional — hanya dibutuhkan jika provider=gemini)
|
| 13 |
GOOGLE_API_KEY=
|
|
|
|
| 18 |
# Chatbot agent service
|
| 19 |
CHATBOT_BASE_URL=http://localhost:8000/chatbot-knowledgemanagement
|
| 20 |
CHATBOT_TIMEOUT=30
|
| 21 |
+
|
| 22 |
+
# Google Cloud Project (untuk referensi Vertex AI di masa depan)
|
| 23 |
+
GOOGLE_PROJECT_NAME=
|
| 24 |
+
GOOGLE_PROJECT_NUMBER=
|
| 25 |
+
|
| 26 |
+
# Gemini STT
|
| 27 |
+
GEMINI_STT_MODEL=gemini-2.0-flash
|
| 28 |
+
GEMINI_LIVE_MODEL=gemini-live-2.5-flash-preview
|
| 29 |
+
GEMINI_STT_LANGUAGE=id-ID
|
| 30 |
+
|
| 31 |
+
# Provider default untuk WebSocket /ws/voice: "deepgram" atau "gemini"
|
| 32 |
+
STT_PROVIDER=gemini
|
| 33 |
+
TTS_PROVIDER=gemini
|
README.md
CHANGED
|
@@ -4,7 +4,7 @@ emoji: 🌍
|
|
| 4 |
colorFrom: pink
|
| 5 |
colorTo: pink
|
| 6 |
sdk: docker
|
| 7 |
-
pinned:
|
| 8 |
---
|
| 9 |
|
| 10 |
# Voice Agent Service
|
|
@@ -52,16 +52,16 @@ WAKE_WORD=Hai EMA # Default: "Hai EMA"
|
|
| 52 |
## Run
|
| 53 |
|
| 54 |
```bash
|
| 55 |
-
uv run uvicorn main:app --host 0.0.0.0 --port
|
| 56 |
```
|
| 57 |
|
| 58 |
-
Server akan berjalan di `http://localhost:
|
| 59 |
|
| 60 |
## Test
|
| 61 |
|
| 62 |
**Health check:**
|
| 63 |
```bash
|
| 64 |
-
curl http://localhost:
|
| 65 |
```
|
| 66 |
|
| 67 |
Expected response:
|
|
@@ -111,7 +111,7 @@ docker build -t voice-agent .
|
|
| 111 |
|
| 112 |
**Run:**
|
| 113 |
```bash
|
| 114 |
-
docker run -p
|
| 115 |
```
|
| 116 |
|
| 117 |
## Wake Word
|
|
@@ -160,7 +160,7 @@ Client Audio Playback
|
|
| 160 |
|
| 161 |
## WebSocket Protocol
|
| 162 |
|
| 163 |
-
**Endpoint:** `ws://localhost:
|
| 164 |
|
| 165 |
**Client → Server:**
|
| 166 |
|
|
|
|
| 4 |
colorFrom: pink
|
| 5 |
colorTo: pink
|
| 6 |
sdk: docker
|
| 7 |
+
pinned: true
|
| 8 |
---
|
| 9 |
|
| 10 |
# Voice Agent Service
|
|
|
|
| 52 |
## Run
|
| 53 |
|
| 54 |
```bash
|
| 55 |
+
uv run uvicorn main:app --host 0.0.0.0 --port 7861
|
| 56 |
```
|
| 57 |
|
| 58 |
+
Server akan berjalan di `http://localhost:7861`.
|
| 59 |
|
| 60 |
## Test
|
| 61 |
|
| 62 |
**Health check:**
|
| 63 |
```bash
|
| 64 |
+
curl http://localhost:7861/health
|
| 65 |
```
|
| 66 |
|
| 67 |
Expected response:
|
|
|
|
| 111 |
|
| 112 |
**Run:**
|
| 113 |
```bash
|
| 114 |
+
docker run -p 7861:7861 --env-file .env voice-agent
|
| 115 |
```
|
| 116 |
|
| 117 |
## Wake Word
|
|
|
|
| 160 |
|
| 161 |
## WebSocket Protocol
|
| 162 |
|
| 163 |
+
**Endpoint:** `ws://localhost:7861/ws/voice`
|
| 164 |
|
| 165 |
**Client → Server:**
|
| 166 |
|
main.py
CHANGED
|
@@ -1,16 +1,17 @@
|
|
| 1 |
import json
|
| 2 |
import logging
|
| 3 |
import uvicorn
|
| 4 |
-
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, File, UploadFile, HTTPException, Query
|
| 5 |
from fastapi.responses import JSONResponse, StreamingResponse
|
| 6 |
from pydantic import BaseModel
|
| 7 |
from src.pipeline import VoicePipeline
|
| 8 |
from typing import Literal
|
| 9 |
from src.config import (
|
| 10 |
DEEPGRAM_API_KEY, CARTESIA_API_KEY, CARTESIA_VOICE_ID,
|
| 11 |
-
SAMPLE_RATE, GOOGLE_API_KEY, CHATBOT_BASE_URL,
|
| 12 |
)
|
| 13 |
-
from src.stt.deepgram_rest import transcribe_audio
|
|
|
|
| 14 |
from src.tts.cartesia_client import synthesize_stream as cartesia_synthesize
|
| 15 |
from src.tts.gemini_client import synthesize_stream as gemini_synthesize, GEMINI_SAMPLE_RATE
|
| 16 |
|
|
@@ -36,6 +37,7 @@ async def health() -> JSONResponse:
|
|
| 36 |
"stt_ready": stt_ready,
|
| 37 |
"tts_ready": tts_ready,
|
| 38 |
"gemini_tts_ready": gemini_ready,
|
|
|
|
| 39 |
"chatbot_ready": chatbot_ready,
|
| 40 |
}
|
| 41 |
if not all_ready:
|
|
@@ -46,16 +48,26 @@ async def health() -> JSONResponse:
|
|
| 46 |
|
| 47 |
class TTSRequest(BaseModel):
|
| 48 |
text: str
|
| 49 |
-
provider: Literal["cartesia", "gemini"] = "
|
| 50 |
|
| 51 |
|
| 52 |
@app.post("/stt")
|
| 53 |
-
async def speech_to_text(
|
|
|
|
|
|
|
|
|
|
| 54 |
data = await audio.read()
|
| 55 |
if not data:
|
| 56 |
raise HTTPException(status_code=400, detail="Audio file is empty.")
|
| 57 |
mimetype = audio.content_type or "audio/wav"
|
| 58 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
return JSONResponse(content=result)
|
| 60 |
|
| 61 |
|
|
@@ -94,9 +106,15 @@ async def voice_ws(
|
|
| 94 |
site: str = Query(default="HO"),
|
| 95 |
role: str = Query(default="engineer"),
|
| 96 |
agent: str = Query(default="analysis"),
|
|
|
|
|
|
|
|
|
|
| 97 |
) -> None:
|
| 98 |
await ws.accept()
|
| 99 |
-
logger.info(
|
|
|
|
|
|
|
|
|
|
| 100 |
|
| 101 |
async def send_audio(chunk: bytes) -> None:
|
| 102 |
await ws.send_bytes(chunk)
|
|
@@ -104,6 +122,16 @@ async def voice_ws(
|
|
| 104 |
async def send_event(event: dict) -> None:
|
| 105 |
await ws.send_text(json.dumps(event))
|
| 106 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
pipeline = VoicePipeline(
|
| 108 |
send_audio=send_audio,
|
| 109 |
send_event=send_event,
|
|
@@ -114,6 +142,9 @@ async def voice_ws(
|
|
| 114 |
site=site,
|
| 115 |
role=role,
|
| 116 |
agent=agent,
|
|
|
|
|
|
|
|
|
|
| 117 |
)
|
| 118 |
pipeline.start()
|
| 119 |
|
|
|
|
| 1 |
import json
|
| 2 |
import logging
|
| 3 |
import uvicorn
|
| 4 |
+
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, File, Form, UploadFile, HTTPException, Query
|
| 5 |
from fastapi.responses import JSONResponse, StreamingResponse
|
| 6 |
from pydantic import BaseModel
|
| 7 |
from src.pipeline import VoicePipeline
|
| 8 |
from typing import Literal
|
| 9 |
from src.config import (
|
| 10 |
DEEPGRAM_API_KEY, CARTESIA_API_KEY, CARTESIA_VOICE_ID,
|
| 11 |
+
SAMPLE_RATE, GOOGLE_API_KEY, CHATBOT_BASE_URL, STT_PROVIDER, TTS_PROVIDER, WAKE_WORD_ENABLED,
|
| 12 |
)
|
| 13 |
+
from src.stt.deepgram_rest import transcribe_audio as deepgram_transcribe
|
| 14 |
+
from src.stt.gemini_stt import transcribe_audio as gemini_stt_transcribe
|
| 15 |
from src.tts.cartesia_client import synthesize_stream as cartesia_synthesize
|
| 16 |
from src.tts.gemini_client import synthesize_stream as gemini_synthesize, GEMINI_SAMPLE_RATE
|
| 17 |
|
|
|
|
| 37 |
"stt_ready": stt_ready,
|
| 38 |
"tts_ready": tts_ready,
|
| 39 |
"gemini_tts_ready": gemini_ready,
|
| 40 |
+
"gemini_stt_ready": gemini_ready,
|
| 41 |
"chatbot_ready": chatbot_ready,
|
| 42 |
}
|
| 43 |
if not all_ready:
|
|
|
|
| 48 |
|
| 49 |
class TTSRequest(BaseModel):
|
| 50 |
text: str
|
| 51 |
+
provider: Literal["cartesia", "gemini"] = "gemini"
|
| 52 |
|
| 53 |
|
| 54 |
@app.post("/stt")
|
| 55 |
+
async def speech_to_text(
|
| 56 |
+
audio: UploadFile = File(...),
|
| 57 |
+
provider: str = Form(default="gemini"),
|
| 58 |
+
) -> JSONResponse:
|
| 59 |
data = await audio.read()
|
| 60 |
if not data:
|
| 61 |
raise HTTPException(status_code=400, detail="Audio file is empty.")
|
| 62 |
mimetype = audio.content_type or "audio/wav"
|
| 63 |
+
|
| 64 |
+
if provider == "gemini":
|
| 65 |
+
if not GOOGLE_API_KEY:
|
| 66 |
+
raise HTTPException(status_code=503, detail="Gemini STT not configured: missing GOOGLE_API_KEY.")
|
| 67 |
+
result = await gemini_stt_transcribe(data, mimetype=mimetype)
|
| 68 |
+
else:
|
| 69 |
+
result = await deepgram_transcribe(data, mimetype=mimetype)
|
| 70 |
+
|
| 71 |
return JSONResponse(content=result)
|
| 72 |
|
| 73 |
|
|
|
|
| 106 |
site: str = Query(default="HO"),
|
| 107 |
role: str = Query(default="engineer"),
|
| 108 |
agent: str = Query(default="analysis"),
|
| 109 |
+
stt_provider: str = Query(default=STT_PROVIDER),
|
| 110 |
+
tts_provider: str = Query(default=TTS_PROVIDER),
|
| 111 |
+
wake_word_enabled: bool = Query(default=WAKE_WORD_ENABLED),
|
| 112 |
) -> None:
|
| 113 |
await ws.accept()
|
| 114 |
+
logger.info(
|
| 115 |
+
"Client connected: %s (user_id=%s, stt=%s, tts=%s)",
|
| 116 |
+
ws.client, user_id, stt_provider, tts_provider,
|
| 117 |
+
)
|
| 118 |
|
| 119 |
async def send_audio(chunk: bytes) -> None:
|
| 120 |
await ws.send_bytes(chunk)
|
|
|
|
| 122 |
async def send_event(event: dict) -> None:
|
| 123 |
await ws.send_text(json.dumps(event))
|
| 124 |
|
| 125 |
+
tts_sample_rate = GEMINI_SAMPLE_RATE if tts_provider == "gemini" else SAMPLE_RATE
|
| 126 |
+
await send_event({
|
| 127 |
+
"event": "tts_config",
|
| 128 |
+
"tts_provider": tts_provider,
|
| 129 |
+
"stt_provider": stt_provider,
|
| 130 |
+
"sample_rate": tts_sample_rate,
|
| 131 |
+
"encoding": "pcm_s16le",
|
| 132 |
+
"channels": 1,
|
| 133 |
+
})
|
| 134 |
+
|
| 135 |
pipeline = VoicePipeline(
|
| 136 |
send_audio=send_audio,
|
| 137 |
send_event=send_event,
|
|
|
|
| 142 |
site=site,
|
| 143 |
role=role,
|
| 144 |
agent=agent,
|
| 145 |
+
stt_provider=stt_provider,
|
| 146 |
+
tts_provider=tts_provider,
|
| 147 |
+
wake_word_enabled=wake_word_enabled,
|
| 148 |
)
|
| 149 |
pipeline.start()
|
| 150 |
|
src/config.py
CHANGED
|
@@ -9,12 +9,22 @@ 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 |
|
| 14 |
GOOGLE_API_KEY: str = os.getenv("GOOGLE_API_KEY", "")
|
|
|
|
|
|
|
|
|
|
| 15 |
GEMINI_TTS_MODEL: str = os.getenv("GEMINI_TTS_MODEL", "gemini-2.5-flash-preview-tts")
|
| 16 |
GEMINI_TTS_VOICE: str = os.getenv("GEMINI_TTS_VOICE", "Autonoe")
|
| 17 |
GEMINI_TTS_LANGUAGE: str = os.getenv("GEMINI_TTS_LANGUAGE", "id-ID")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
DEEPGRAM_LANGUAGE: str = os.getenv("DEEPGRAM_LANGUAGE", "id")
|
| 19 |
DEEPGRAM_ENDPOINTING_MS: int = int(os.getenv("DEEPGRAM_ENDPOINTING_MS", "300"))
|
| 20 |
DEEPGRAM_UTTERANCE_END_MS: int = int(os.getenv("DEEPGRAM_UTTERANCE_END_MS", "2000"))
|
|
|
|
| 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 |
+
WAKE_WORD_ENABLED: bool = os.getenv("WAKE_WORD_ENABLED", "false").lower() in ("1", "true", "yes")
|
| 13 |
CARTESIA_MODEL: str = os.getenv("CARTESIA_MODEL", "sonic-3")
|
| 14 |
|
| 15 |
GOOGLE_API_KEY: str = os.getenv("GOOGLE_API_KEY", "")
|
| 16 |
+
GOOGLE_PROJECT_NAME: str = os.getenv("GOOGLE_PROJECT_NAME", "")
|
| 17 |
+
GOOGLE_PROJECT_NUMBER: str = os.getenv("GOOGLE_PROJECT_NUMBER", "")
|
| 18 |
+
|
| 19 |
GEMINI_TTS_MODEL: str = os.getenv("GEMINI_TTS_MODEL", "gemini-2.5-flash-preview-tts")
|
| 20 |
GEMINI_TTS_VOICE: str = os.getenv("GEMINI_TTS_VOICE", "Autonoe")
|
| 21 |
GEMINI_TTS_LANGUAGE: str = os.getenv("GEMINI_TTS_LANGUAGE", "id-ID")
|
| 22 |
+
|
| 23 |
+
GEMINI_STT_MODEL: str = os.getenv("GEMINI_STT_MODEL", "gemini-2.0-flash")
|
| 24 |
+
GEMINI_LIVE_MODEL: str = os.getenv("GEMINI_LIVE_MODEL", "gemini-live-2.5-flash-preview")
|
| 25 |
+
GEMINI_STT_LANGUAGE: str = os.getenv("GEMINI_STT_LANGUAGE", "id-ID")
|
| 26 |
+
STT_PROVIDER: str = os.getenv("STT_PROVIDER", "deepgram")
|
| 27 |
+
TTS_PROVIDER: str = os.getenv("TTS_PROVIDER", "cartesia")
|
| 28 |
DEEPGRAM_LANGUAGE: str = os.getenv("DEEPGRAM_LANGUAGE", "id")
|
| 29 |
DEEPGRAM_ENDPOINTING_MS: int = int(os.getenv("DEEPGRAM_ENDPOINTING_MS", "300"))
|
| 30 |
DEEPGRAM_UTTERANCE_END_MS: int = int(os.getenv("DEEPGRAM_UTTERANCE_END_MS", "2000"))
|
src/pipeline.py
CHANGED
|
@@ -2,9 +2,10 @@ import uuid
|
|
| 2 |
import asyncio
|
| 3 |
import logging
|
| 4 |
from typing import Callable, Awaitable
|
| 5 |
-
from src.config import WAKE_WORDS
|
| 6 |
from src.stt.deepgram_client import DeepgramStreamer
|
| 7 |
-
from src.tts.cartesia_client import synthesize_stream
|
|
|
|
| 8 |
from src.chatbot.client import call_agent
|
| 9 |
|
| 10 |
logger = logging.getLogger(__name__)
|
|
@@ -33,10 +34,15 @@ class VoicePipeline:
|
|
| 33 |
site: str = "HO",
|
| 34 |
role: str = "engineer",
|
| 35 |
agent: str = "analysis",
|
|
|
|
|
|
|
|
|
|
| 36 |
):
|
| 37 |
self._send_audio = send_audio
|
| 38 |
self._send_event = send_event
|
| 39 |
self._loop = asyncio.get_event_loop()
|
|
|
|
|
|
|
| 40 |
|
| 41 |
self._user_id = user_id
|
| 42 |
self._fullname = fullname
|
|
@@ -53,10 +59,17 @@ class VoicePipeline:
|
|
| 53 |
# Running chat history for multi-turn context
|
| 54 |
self._chat_history: list[dict] = []
|
| 55 |
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
self._tts_lock = asyncio.Lock()
|
| 61 |
self._tts_task: asyncio.Task | None = None
|
| 62 |
|
|
@@ -69,12 +82,18 @@ class VoicePipeline:
|
|
| 69 |
async def _on_final_transcript(self, text: str) -> None:
|
| 70 |
await self._send_event({"event": "transcript", "text": text})
|
| 71 |
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
|
| 77 |
-
logger.info("Wake word detected, question: %s", question)
|
| 78 |
self._tts_task = asyncio.create_task(self._answer_and_speak(question))
|
| 79 |
|
| 80 |
def _extract_question(self, transcript: str) -> str | None:
|
|
@@ -124,7 +143,12 @@ class VoicePipeline:
|
|
| 124 |
async def _speak(self, text: str) -> None:
|
| 125 |
async with self._tts_lock:
|
| 126 |
try:
|
| 127 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
await self._send_audio(audio_chunk)
|
| 129 |
await self._send_event({"event": "tts_end"})
|
| 130 |
except asyncio.CancelledError:
|
|
|
|
| 2 |
import asyncio
|
| 3 |
import logging
|
| 4 |
from typing import Callable, Awaitable
|
| 5 |
+
from src.config import WAKE_WORDS, WAKE_WORD_ENABLED
|
| 6 |
from src.stt.deepgram_client import DeepgramStreamer
|
| 7 |
+
from src.tts.cartesia_client import synthesize_stream as cartesia_synthesize_stream
|
| 8 |
+
from src.tts.gemini_client import synthesize_stream as gemini_synthesize_stream, GEMINI_SAMPLE_RATE
|
| 9 |
from src.chatbot.client import call_agent
|
| 10 |
|
| 11 |
logger = logging.getLogger(__name__)
|
|
|
|
| 34 |
site: str = "HO",
|
| 35 |
role: str = "engineer",
|
| 36 |
agent: str = "analysis",
|
| 37 |
+
stt_provider: str = "deepgram",
|
| 38 |
+
tts_provider: str = "cartesia",
|
| 39 |
+
wake_word_enabled: bool = WAKE_WORD_ENABLED,
|
| 40 |
):
|
| 41 |
self._send_audio = send_audio
|
| 42 |
self._send_event = send_event
|
| 43 |
self._loop = asyncio.get_event_loop()
|
| 44 |
+
self._tts_provider = tts_provider
|
| 45 |
+
self._wake_word_enabled = wake_word_enabled
|
| 46 |
|
| 47 |
self._user_id = user_id
|
| 48 |
self._fullname = fullname
|
|
|
|
| 59 |
# Running chat history for multi-turn context
|
| 60 |
self._chat_history: list[dict] = []
|
| 61 |
|
| 62 |
+
if stt_provider == "gemini":
|
| 63 |
+
from src.stt.gemini_stt import GeminiSTTStreamer
|
| 64 |
+
self._stt = GeminiSTTStreamer(
|
| 65 |
+
on_final_transcript=self._on_final_transcript,
|
| 66 |
+
loop=self._loop,
|
| 67 |
+
)
|
| 68 |
+
else:
|
| 69 |
+
self._stt = DeepgramStreamer(
|
| 70 |
+
on_final_transcript=self._on_final_transcript,
|
| 71 |
+
loop=self._loop,
|
| 72 |
+
)
|
| 73 |
self._tts_lock = asyncio.Lock()
|
| 74 |
self._tts_task: asyncio.Task | None = None
|
| 75 |
|
|
|
|
| 82 |
async def _on_final_transcript(self, text: str) -> None:
|
| 83 |
await self._send_event({"event": "transcript", "text": text})
|
| 84 |
|
| 85 |
+
if self._wake_word_enabled:
|
| 86 |
+
question = self._extract_question(text)
|
| 87 |
+
if question is None:
|
| 88 |
+
logger.debug("No wake word detected, ignoring: %s", text)
|
| 89 |
+
return
|
| 90 |
+
logger.info("Wake word detected, question: %s", question)
|
| 91 |
+
else:
|
| 92 |
+
question = text.strip()
|
| 93 |
+
if not question:
|
| 94 |
+
return
|
| 95 |
+
logger.info("Processing transcript (wake word disabled): %s", question)
|
| 96 |
|
|
|
|
| 97 |
self._tts_task = asyncio.create_task(self._answer_and_speak(question))
|
| 98 |
|
| 99 |
def _extract_question(self, transcript: str) -> str | None:
|
|
|
|
| 143 |
async def _speak(self, text: str) -> None:
|
| 144 |
async with self._tts_lock:
|
| 145 |
try:
|
| 146 |
+
stream = (
|
| 147 |
+
gemini_synthesize_stream(text)
|
| 148 |
+
if self._tts_provider == "gemini"
|
| 149 |
+
else cartesia_synthesize_stream(text)
|
| 150 |
+
)
|
| 151 |
+
async for audio_chunk in stream:
|
| 152 |
await self._send_audio(audio_chunk)
|
| 153 |
await self._send_event({"event": "tts_end"})
|
| 154 |
except asyncio.CancelledError:
|
src/stt/gemini_stt.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import logging
|
| 3 |
+
from typing import Callable, Awaitable
|
| 4 |
+
|
| 5 |
+
from google import genai
|
| 6 |
+
from google.genai import types
|
| 7 |
+
|
| 8 |
+
from src.config import (
|
| 9 |
+
GOOGLE_API_KEY,
|
| 10 |
+
GEMINI_STT_MODEL,
|
| 11 |
+
GEMINI_LIVE_MODEL,
|
| 12 |
+
GEMINI_STT_LANGUAGE,
|
| 13 |
+
DEEPGRAM_UTTERANCE_END_MS,
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
OnTranscriptCallback = Callable[[str], Awaitable[None]]
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
async def transcribe_audio(data: bytes, mimetype: str = "audio/wav") -> dict:
|
| 22 |
+
"""
|
| 23 |
+
Transcribes a full audio file using Gemini multimodal API.
|
| 24 |
+
Returns dict with 'text', 'language', and 'duration'.
|
| 25 |
+
"""
|
| 26 |
+
client = genai.Client(api_key=GOOGLE_API_KEY)
|
| 27 |
+
response = await client.aio.models.generate_content(
|
| 28 |
+
model=GEMINI_STT_MODEL,
|
| 29 |
+
contents=[
|
| 30 |
+
types.Part.from_bytes(data=data, mime_type=mimetype),
|
| 31 |
+
f"Transcribe this audio accurately in {GEMINI_STT_LANGUAGE}. "
|
| 32 |
+
"Return only the transcription text, no commentary or explanation.",
|
| 33 |
+
],
|
| 34 |
+
)
|
| 35 |
+
transcript = (response.text or "").strip()
|
| 36 |
+
logger.info("Gemini STT transcript: %s", transcript[:80])
|
| 37 |
+
return {"text": transcript, "language": GEMINI_STT_LANGUAGE, "duration": None}
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class GeminiSTTStreamer:
|
| 41 |
+
"""
|
| 42 |
+
Real-time streaming STT using the Gemini Live API.
|
| 43 |
+
|
| 44 |
+
Interface matches DeepgramStreamer: start(), send_audio(chunk), stop().
|
| 45 |
+
Audio chunks must be PCM Linear16 16kHz mono bytes.
|
| 46 |
+
|
| 47 |
+
Final transcript segments are buffered and flushed after DEEPGRAM_UTTERANCE_END_MS
|
| 48 |
+
of silence (reusing the same configurable delay).
|
| 49 |
+
"""
|
| 50 |
+
|
| 51 |
+
def __init__(self, on_final_transcript: OnTranscriptCallback, loop: asyncio.AbstractEventLoop):
|
| 52 |
+
self._on_final_transcript = on_final_transcript
|
| 53 |
+
self._loop = loop
|
| 54 |
+
self._audio_queue: asyncio.Queue[bytes | None] = None # type: ignore[assignment]
|
| 55 |
+
self._session_task: asyncio.Task | None = None
|
| 56 |
+
self._transcript_buffer: list[str] = []
|
| 57 |
+
self._flush_handle: asyncio.TimerHandle | None = None
|
| 58 |
+
|
| 59 |
+
def start(self) -> None:
|
| 60 |
+
self._audio_queue = asyncio.Queue()
|
| 61 |
+
self._session_task = asyncio.ensure_future(self._run_session(), loop=self._loop)
|
| 62 |
+
logger.info("Gemini Live STT starting...")
|
| 63 |
+
|
| 64 |
+
def send_audio(self, chunk: bytes) -> None:
|
| 65 |
+
if self._audio_queue is not None:
|
| 66 |
+
self._audio_queue.put_nowait(chunk)
|
| 67 |
+
|
| 68 |
+
def stop(self) -> None:
|
| 69 |
+
if self._session_task and not self._session_task.done():
|
| 70 |
+
self._session_task.cancel()
|
| 71 |
+
if self._audio_queue is not None:
|
| 72 |
+
# Unblock the send loop if it's waiting on the queue
|
| 73 |
+
self._audio_queue.put_nowait(None)
|
| 74 |
+
if self._flush_handle is not None:
|
| 75 |
+
self._flush_handle.cancel()
|
| 76 |
+
self._flush_handle = None
|
| 77 |
+
logger.info("Gemini Live STT stopped")
|
| 78 |
+
|
| 79 |
+
async def _run_session(self) -> None:
|
| 80 |
+
client = genai.Client(api_key=GOOGLE_API_KEY)
|
| 81 |
+
config = types.LiveConnectConfig(
|
| 82 |
+
response_modalities=["TEXT"],
|
| 83 |
+
input_audio_transcription=types.AudioTranscriptionConfig(),
|
| 84 |
+
)
|
| 85 |
+
try:
|
| 86 |
+
async with client.aio.live.connect(model=GEMINI_LIVE_MODEL, config=config) as session:
|
| 87 |
+
logger.info("Gemini Live STT connected")
|
| 88 |
+
await asyncio.gather(
|
| 89 |
+
self._send_loop(session),
|
| 90 |
+
self._receive_loop(session),
|
| 91 |
+
)
|
| 92 |
+
except asyncio.CancelledError:
|
| 93 |
+
pass
|
| 94 |
+
except Exception:
|
| 95 |
+
logger.exception("Gemini Live session error")
|
| 96 |
+
|
| 97 |
+
async def _send_loop(self, session) -> None:
|
| 98 |
+
try:
|
| 99 |
+
while True:
|
| 100 |
+
chunk = await self._audio_queue.get()
|
| 101 |
+
if chunk is None:
|
| 102 |
+
break
|
| 103 |
+
await session.send_realtime_input(
|
| 104 |
+
audio=types.Blob(data=chunk, mime_type="audio/pcm;rate=16000")
|
| 105 |
+
)
|
| 106 |
+
except asyncio.CancelledError:
|
| 107 |
+
raise
|
| 108 |
+
|
| 109 |
+
async def _receive_loop(self, session) -> None:
|
| 110 |
+
try:
|
| 111 |
+
async for response in session.receive():
|
| 112 |
+
if response.server_content is None:
|
| 113 |
+
continue
|
| 114 |
+
transcription = response.server_content.input_transcription
|
| 115 |
+
if transcription and transcription.text:
|
| 116 |
+
self._buffer_transcript(transcription.text)
|
| 117 |
+
except asyncio.CancelledError:
|
| 118 |
+
raise
|
| 119 |
+
except Exception:
|
| 120 |
+
logger.exception("Gemini Live receive error")
|
| 121 |
+
|
| 122 |
+
def _buffer_transcript(self, text: str) -> None:
|
| 123 |
+
self._transcript_buffer.append(text)
|
| 124 |
+
logger.debug("Buffered segment: %s", text)
|
| 125 |
+
if self._flush_handle is not None:
|
| 126 |
+
self._flush_handle.cancel()
|
| 127 |
+
delay = DEEPGRAM_UTTERANCE_END_MS / 1000.0
|
| 128 |
+
self._flush_handle = self._loop.call_later(delay, self._flush_buffer)
|
| 129 |
+
|
| 130 |
+
def _flush_buffer(self) -> None:
|
| 131 |
+
if not self._transcript_buffer:
|
| 132 |
+
return
|
| 133 |
+
full_text = " ".join(self._transcript_buffer)
|
| 134 |
+
self._transcript_buffer.clear()
|
| 135 |
+
self._flush_handle = None
|
| 136 |
+
logger.info("Final transcript: %s", full_text)
|
| 137 |
+
asyncio.ensure_future(self._on_final_transcript(full_text), loop=self._loop)
|