Add creds, Update used TTS model
Browse files- .env.example +4 -1
- .gitignore +3 -1
- Dockerfile +3 -0
- src/config.py +7 -0
- src/google_auth.py +34 -0
- src/stt/chirp3_client.py +9 -0
- src/tts/gemini_client.py +67 -20
.env.example
CHANGED
|
@@ -11,10 +11,13 @@ WAKE_WORD_ENABLED=false
|
|
| 11 |
|
| 12 |
# Gemini TTS (optional — hanya dibutuhkan jika provider=gemini)
|
| 13 |
GOOGLE_API_KEY=
|
| 14 |
-
|
|
|
|
|
|
|
| 15 |
GEMINI_TTS_VOICE=Autonoe
|
| 16 |
GEMINI_TTS_LANGUAGE=id-ID
|
| 17 |
|
|
|
|
| 18 |
|
| 19 |
# Google Cloud Project
|
| 20 |
GOOGLE_PROJECT_NAME=
|
|
|
|
| 11 |
|
| 12 |
# Gemini TTS (optional — hanya dibutuhkan jika provider=gemini)
|
| 13 |
GOOGLE_API_KEY=
|
| 14 |
+
# Comma-separated list — dirotasi round-robin per request untuk distribusi quota RPM
|
| 15 |
+
GEMINI_TTS_MODELS=gemini-2.5-flash-tts
|
| 16 |
+
# GEMINI_TTS_MODELS=gemini-2.5-flash-tts,gemini-3.1-flash-tts-preview,gemini-2.5-pro-tts
|
| 17 |
GEMINI_TTS_VOICE=Autonoe
|
| 18 |
GEMINI_TTS_LANGUAGE=id-ID
|
| 19 |
|
| 20 |
+
GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json
|
| 21 |
|
| 22 |
# Google Cloud Project
|
| 23 |
GOOGLE_PROJECT_NAME=
|
.gitignore
CHANGED
|
@@ -25,4 +25,6 @@ convert_audio.py
|
|
| 25 |
API_CONTRACT_CHATBOT.md
|
| 26 |
API_CONTRACT_VOICE.md
|
| 27 |
HIGHLIGHT_VOICE.md
|
| 28 |
-
HIGHLIGHT_STT_TTS.md
|
|
|
|
|
|
|
|
|
| 25 |
API_CONTRACT_CHATBOT.md
|
| 26 |
API_CONTRACT_VOICE.md
|
| 27 |
HIGHLIGHT_VOICE.md
|
| 28 |
+
HIGHLIGHT_STT_TTS.md
|
| 29 |
+
|
| 30 |
+
credentials/
|
Dockerfile
CHANGED
|
@@ -12,7 +12,10 @@ RUN uv sync --no-dev --no-install-project
|
|
| 12 |
# Copy source
|
| 13 |
COPY . .
|
| 14 |
|
|
|
|
|
|
|
| 15 |
ENV PATH="/app/.venv/bin:$PATH"
|
|
|
|
| 16 |
|
| 17 |
EXPOSE 7860
|
| 18 |
|
|
|
|
| 12 |
# Copy source
|
| 13 |
COPY . .
|
| 14 |
|
| 15 |
+
COPY credentials/creds.json /app/credentials/creds.json
|
| 16 |
+
|
| 17 |
ENV PATH="/app/.venv/bin:$PATH"
|
| 18 |
+
ENV GOOGLE_APPLICATION_CREDENTIALS=/app/credentials/creds.json
|
| 19 |
|
| 20 |
EXPOSE 7860
|
| 21 |
|
src/config.py
CHANGED
|
@@ -19,6 +19,13 @@ GOOGLE_PROJECT_NUMBER: str = os.getenv("GOOGLE_PROJECT_NUMBER", "")
|
|
| 19 |
GOOGLE_CLOUD_LOCATION: str = os.getenv("GOOGLE_CLOUD_LOCATION", "us-central1")
|
| 20 |
|
| 21 |
GEMINI_TTS_MODEL: str = os.getenv("GEMINI_TTS_MODEL", "gemini-2.5-flash-tts")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
GEMINI_TTS_VOICE: str = os.getenv("GEMINI_TTS_VOICE", "Autonoe")
|
| 23 |
GEMINI_TTS_LANGUAGE: str = os.getenv("GEMINI_TTS_LANGUAGE", "id-ID")
|
| 24 |
|
|
|
|
| 19 |
GOOGLE_CLOUD_LOCATION: str = os.getenv("GOOGLE_CLOUD_LOCATION", "us-central1")
|
| 20 |
|
| 21 |
GEMINI_TTS_MODEL: str = os.getenv("GEMINI_TTS_MODEL", "gemini-2.5-flash-tts")
|
| 22 |
+
GEMINI_TTS_MODELS: list[str] = [
|
| 23 |
+
m.strip() for m in os.getenv(
|
| 24 |
+
"GEMINI_TTS_MODELS",
|
| 25 |
+
"gemini-2.5-flash-tts",
|
| 26 |
+
# "gemini-2.5-flash-tts,gemini-3.1-flash-tts-preview,gemini-2.5-pro-tts",
|
| 27 |
+
).split(",") if m.strip()
|
| 28 |
+
]
|
| 29 |
GEMINI_TTS_VOICE: str = os.getenv("GEMINI_TTS_VOICE", "Autonoe")
|
| 30 |
GEMINI_TTS_LANGUAGE: str = os.getenv("GEMINI_TTS_LANGUAGE", "id-ID")
|
| 31 |
|
src/google_auth.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import logging
|
| 3 |
+
from typing import Optional
|
| 4 |
+
|
| 5 |
+
import google.auth
|
| 6 |
+
import google.auth.credentials
|
| 7 |
+
from google.oauth2 import service_account
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
_SCOPES = ["https://www.googleapis.com/auth/cloud-platform"]
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def get_google_credentials() -> google.auth.credentials.Credentials:
|
| 15 |
+
"""
|
| 16 |
+
Resolution order:
|
| 17 |
+
1. GOOGLE_APPLICATION_CREDENTIALS env var set & file exists → service account JSON
|
| 18 |
+
2. Fallback → ADC (gcloud auth application-default login)
|
| 19 |
+
"""
|
| 20 |
+
creds_path: Optional[str] = os.environ.get("GOOGLE_APPLICATION_CREDENTIALS")
|
| 21 |
+
|
| 22 |
+
if creds_path:
|
| 23 |
+
if not os.path.isfile(creds_path):
|
| 24 |
+
raise FileNotFoundError(
|
| 25 |
+
f"GOOGLE_APPLICATION_CREDENTIALS='{creds_path}' tapi file tidak ditemukan."
|
| 26 |
+
)
|
| 27 |
+
logger.info("Google auth: service account dari %s", creds_path)
|
| 28 |
+
return service_account.Credentials.from_service_account_file(
|
| 29 |
+
creds_path, scopes=_SCOPES
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
logger.info("Google auth: menggunakan ADC (gcloud login)")
|
| 33 |
+
credentials, _ = google.auth.default(scopes=_SCOPES)
|
| 34 |
+
return credentials
|
src/stt/chirp3_client.py
CHANGED
|
@@ -9,9 +9,12 @@ from google.cloud.speech_v2.types import cloud_speech as cloud_speech_types
|
|
| 9 |
from google.api_core.client_options import ClientOptions
|
| 10 |
|
| 11 |
from src.config import GOOGLE_PROJECT_ID, CHIRP3_REGION, CHIRP3_LANGUAGE
|
|
|
|
| 12 |
|
| 13 |
logger = logging.getLogger(__name__)
|
| 14 |
|
|
|
|
|
|
|
| 15 |
OnTranscriptCallback = Callable[[str], Awaitable[None]]
|
| 16 |
|
| 17 |
_CHUNK_SIZE = 24 * 1024 # stay under the 25 KB gRPC streaming limit
|
|
@@ -20,7 +23,9 @@ _CHUNK_SIZE = 24 * 1024 # stay under the 25 KB gRPC streaming limit
|
|
| 20 |
async def transcribe_audio(data: bytes, mimetype: str = "audio/wav") -> dict:
|
| 21 |
"""Transcribes a full audio file using Chirp 3 (Google Cloud Speech-to-Text V2)."""
|
| 22 |
def _run() -> str:
|
|
|
|
| 23 |
client = SpeechClient(
|
|
|
|
| 24 |
client_options=ClientOptions(
|
| 25 |
api_endpoint=f"{CHIRP3_REGION}-speech.googleapis.com"
|
| 26 |
)
|
|
@@ -48,10 +53,13 @@ async def transcribe_audio(data: bytes, mimetype: str = "audio/wav") -> dict:
|
|
| 48 |
yield from audio_requests
|
| 49 |
|
| 50 |
transcripts: list[str] = []
|
|
|
|
| 51 |
for response in client.streaming_recognize(requests=requests()):
|
|
|
|
| 52 |
for result in response.results:
|
| 53 |
if result.alternatives:
|
| 54 |
transcripts.append(result.alternatives[0].transcript)
|
|
|
|
| 55 |
|
| 56 |
transcript = " ".join(transcripts).strip()
|
| 57 |
if transcript:
|
|
@@ -111,6 +119,7 @@ class Chirp3STTStreamer:
|
|
| 111 |
|
| 112 |
def _recognize(self, wav_data: bytes) -> str:
|
| 113 |
client = SpeechClient(
|
|
|
|
| 114 |
client_options=ClientOptions(
|
| 115 |
api_endpoint=f"{CHIRP3_REGION}-speech.googleapis.com"
|
| 116 |
)
|
|
|
|
| 9 |
from google.api_core.client_options import ClientOptions
|
| 10 |
|
| 11 |
from src.config import GOOGLE_PROJECT_ID, CHIRP3_REGION, CHIRP3_LANGUAGE
|
| 12 |
+
from src.google_auth import get_google_credentials
|
| 13 |
|
| 14 |
logger = logging.getLogger(__name__)
|
| 15 |
|
| 16 |
+
_credentials = get_google_credentials()
|
| 17 |
+
|
| 18 |
OnTranscriptCallback = Callable[[str], Awaitable[None]]
|
| 19 |
|
| 20 |
_CHUNK_SIZE = 24 * 1024 # stay under the 25 KB gRPC streaming limit
|
|
|
|
| 23 |
async def transcribe_audio(data: bytes, mimetype: str = "audio/wav") -> dict:
|
| 24 |
"""Transcribes a full audio file using Chirp 3 (Google Cloud Speech-to-Text V2)."""
|
| 25 |
def _run() -> str:
|
| 26 |
+
logger.info("chirp3 transcribe_audio: data=%d bytes, region=%s, project=%s", len(data), CHIRP3_REGION, GOOGLE_PROJECT_ID)
|
| 27 |
client = SpeechClient(
|
| 28 |
+
credentials=_credentials,
|
| 29 |
client_options=ClientOptions(
|
| 30 |
api_endpoint=f"{CHIRP3_REGION}-speech.googleapis.com"
|
| 31 |
)
|
|
|
|
| 53 |
yield from audio_requests
|
| 54 |
|
| 55 |
transcripts: list[str] = []
|
| 56 |
+
response_count = 0
|
| 57 |
for response in client.streaming_recognize(requests=requests()):
|
| 58 |
+
response_count += 1
|
| 59 |
for result in response.results:
|
| 60 |
if result.alternatives:
|
| 61 |
transcripts.append(result.alternatives[0].transcript)
|
| 62 |
+
logger.info("chirp3 transcribe_audio: %d responses, %d transcripts", response_count, len(transcripts))
|
| 63 |
|
| 64 |
transcript = " ".join(transcripts).strip()
|
| 65 |
if transcript:
|
|
|
|
| 119 |
|
| 120 |
def _recognize(self, wav_data: bytes) -> str:
|
| 121 |
client = SpeechClient(
|
| 122 |
+
credentials=_credentials,
|
| 123 |
client_options=ClientOptions(
|
| 124 |
api_endpoint=f"{CHIRP3_REGION}-speech.googleapis.com"
|
| 125 |
)
|
src/tts/gemini_client.py
CHANGED
|
@@ -1,39 +1,86 @@
|
|
| 1 |
import asyncio
|
| 2 |
import logging
|
|
|
|
| 3 |
from typing import AsyncIterator
|
| 4 |
|
|
|
|
| 5 |
from google.cloud import texttospeech
|
| 6 |
|
| 7 |
-
from src.config import
|
|
|
|
| 8 |
|
| 9 |
logger = logging.getLogger(__name__)
|
| 10 |
|
|
|
|
|
|
|
| 11 |
GEMINI_SAMPLE_RATE = 24000 # Cloud TTS Gemini model outputs PCM Linear16 at 24kHz
|
| 12 |
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
async def synthesize_stream(text: str) -> AsyncIterator[bytes]:
|
| 15 |
-
"""Calls Cloud TTS Gemini model and yields raw PCM Linear16 chunks at 24kHz.
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
)
|
| 27 |
)
|
| 28 |
-
)
|
| 29 |
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
)
|
|
|
|
| 35 |
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
-
|
| 39 |
-
yield chunk
|
|
|
|
| 1 |
import asyncio
|
| 2 |
import logging
|
| 3 |
+
import threading
|
| 4 |
from typing import AsyncIterator
|
| 5 |
|
| 6 |
+
from google.api_core import exceptions as gcp_exceptions
|
| 7 |
from google.cloud import texttospeech
|
| 8 |
|
| 9 |
+
from src.config import GEMINI_TTS_MODELS, GEMINI_TTS_VOICE, GEMINI_TTS_LANGUAGE
|
| 10 |
+
from src.google_auth import get_google_credentials
|
| 11 |
|
| 12 |
logger = logging.getLogger(__name__)
|
| 13 |
|
| 14 |
+
_credentials = get_google_credentials()
|
| 15 |
+
|
| 16 |
GEMINI_SAMPLE_RATE = 24000 # Cloud TTS Gemini model outputs PCM Linear16 at 24kHz
|
| 17 |
|
| 18 |
|
| 19 |
+
class _ModelRotator:
|
| 20 |
+
def __init__(self, models: list[str]) -> None:
|
| 21 |
+
self._models = models
|
| 22 |
+
self._index = 0
|
| 23 |
+
self._lock = threading.Lock()
|
| 24 |
+
|
| 25 |
+
def get_rotation(self) -> list[str]:
|
| 26 |
+
"""Returns models starting from current index, then advances for next call."""
|
| 27 |
+
with self._lock:
|
| 28 |
+
n = len(self._models)
|
| 29 |
+
start = self._index % n
|
| 30 |
+
self._index += 1
|
| 31 |
+
return self._models[start:] + self._models[:start]
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
_rotator = _ModelRotator(GEMINI_TTS_MODELS)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
async def synthesize_stream(text: str) -> AsyncIterator[bytes]:
|
| 38 |
+
"""Calls Cloud TTS Gemini model and yields raw PCM Linear16 chunks at 24kHz.
|
| 39 |
+
|
| 40 |
+
Rotates across GEMINI_TTS_MODELS round-robin and falls back to the next model
|
| 41 |
+
on ResourceExhausted (quota exceeded).
|
| 42 |
+
"""
|
| 43 |
+
models_to_try = _rotator.get_rotation()
|
| 44 |
+
last_exc: Exception | None = None
|
| 45 |
+
|
| 46 |
+
for model in models_to_try:
|
| 47 |
+
logger.info("Gemini TTS [%s]: synthesizing '%s'", model, text[:60])
|
| 48 |
+
|
| 49 |
+
def _collect(model_name: str = model) -> list[bytes]:
|
| 50 |
+
client = texttospeech.TextToSpeechClient(credentials=_credentials)
|
| 51 |
+
config_req = texttospeech.StreamingSynthesizeRequest(
|
| 52 |
+
streaming_config=texttospeech.StreamingSynthesizeConfig(
|
| 53 |
+
voice=texttospeech.VoiceSelectionParams(
|
| 54 |
+
name=GEMINI_TTS_VOICE,
|
| 55 |
+
language_code=GEMINI_TTS_LANGUAGE,
|
| 56 |
+
model_name=model_name,
|
| 57 |
+
)
|
| 58 |
)
|
| 59 |
)
|
|
|
|
| 60 |
|
| 61 |
+
def _gen():
|
| 62 |
+
yield config_req
|
| 63 |
+
yield texttospeech.StreamingSynthesizeRequest(
|
| 64 |
+
input=texttospeech.StreamingSynthesisInput(text=text)
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
chunks = [r.audio_content for r in client.streaming_synthesize(_gen())]
|
| 68 |
+
logger.info(
|
| 69 |
+
"Gemini TTS [%s]: received %d chunks, %d bytes total",
|
| 70 |
+
model_name, len(chunks), sum(len(c) for c in chunks),
|
| 71 |
)
|
| 72 |
+
return chunks
|
| 73 |
|
| 74 |
+
try:
|
| 75 |
+
chunks = await asyncio.to_thread(_collect)
|
| 76 |
+
for chunk in chunks:
|
| 77 |
+
yield chunk
|
| 78 |
+
return
|
| 79 |
+
except gcp_exceptions.ResourceExhausted:
|
| 80 |
+
logger.warning("Gemini TTS quota exhausted for model '%s', trying next model", model)
|
| 81 |
+
last_exc = gcp_exceptions.ResourceExhausted(f"Quota exhausted on {model}")
|
| 82 |
+
continue
|
| 83 |
+
except Exception:
|
| 84 |
+
raise
|
| 85 |
|
| 86 |
+
raise last_exc # type: ignore[misc]
|
|
|