ishaq101 commited on
Commit
38a5904
·
1 Parent(s): e75bac4

TTS and STT using gemini model

Browse files
Files changed (6) hide show
  1. .env.example +14 -0
  2. README.md +6 -6
  3. main.py +38 -7
  4. src/config.py +10 -0
  5. src/pipeline.py +36 -12
  6. 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: false
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 7860
56
  ```
57
 
58
- Server akan berjalan di `http://localhost:7860`.
59
 
60
  ## Test
61
 
62
  **Health check:**
63
  ```bash
64
- curl http://localhost:7860/health
65
  ```
66
 
67
  Expected response:
@@ -111,7 +111,7 @@ docker build -t voice-agent .
111
 
112
  **Run:**
113
  ```bash
114
- docker run -p 7860:7860 --env-file .env voice-agent
115
  ```
116
 
117
  ## Wake Word
@@ -160,7 +160,7 @@ Client Audio Playback
160
 
161
  ## WebSocket Protocol
162
 
163
- **Endpoint:** `ws://localhost:7860/ws/voice`
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"] = "cartesia"
50
 
51
 
52
  @app.post("/stt")
53
- async def speech_to_text(audio: UploadFile = File(...)) -> JSONResponse:
 
 
 
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
- result = await transcribe_audio(data, mimetype=mimetype)
 
 
 
 
 
 
 
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("Client connected: %s (user_id=%s)", ws.client, user_id)
 
 
 
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
- self._stt = DeepgramStreamer(
57
- on_final_transcript=self._on_final_transcript,
58
- loop=self._loop,
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
- question = self._extract_question(text)
73
- if question is None:
74
- logger.debug("No wake word detected, ignoring: %s", text)
75
- return
 
 
 
 
 
 
 
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
- async for audio_chunk in synthesize_stream(text):
 
 
 
 
 
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)