| """Google Cloud Text-to-Speech backend using an API key (REST API). |
| |
| Uses the public REST endpoints so the only requirement is an API key, |
| which is the simplest credential to provide inside a Hugging Face Space |
| (store it as a Space secret named ``GCP_TTS_API_KEY``): |
| |
| * ``GET /v1/voices`` — list available voices |
| * ``POST /v1/text:synthesize`` — synthesize speech |
| |
| Requesting ``LINEAR16`` at 16 kHz returns ready-to-use PCM WAV bytes. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import base64 |
| from typing import List, Optional |
|
|
| import requests |
|
|
| from ..audio import read_wav_bytes |
| from .base import SynthesisResult, TTSBackend, Voice |
|
|
| _BASE_URL = "https://texttospeech.googleapis.com/v1" |
| _TIMEOUT = 30 |
|
|
|
|
| class GCPTTSBackend(TTSBackend): |
| source = "google_cloud_tts" |
|
|
| def __init__( |
| self, |
| api_key: str, |
| language_prefixes: List[str], |
| max_voices_per_locale: int, |
| sample_rate_hz: int, |
| prefer_voice_types: Optional[List[str]] = None, |
| ) -> None: |
| self.api_key = api_key |
| self.language_prefixes = [p.lower() for p in language_prefixes] |
| self.max_voices_per_locale = max_voices_per_locale |
| self.sample_rate_hz = sample_rate_hz |
| self.prefer_voice_types = prefer_voice_types or [ |
| "chirp", |
| "neural", |
| "wavenet", |
| "studio", |
| "journey", |
| "standard", |
| ] |
| self._voices: List[Voice] = [] |
|
|
| |
|
|
| def prepare(self) -> None: |
| response = requests.get( |
| f"{_BASE_URL}/voices", |
| params={"key": self.api_key}, |
| timeout=_TIMEOUT, |
| ) |
| if response.status_code != 200: |
| raise RuntimeError( |
| f"Google TTS voices request failed ({response.status_code}): " |
| f"{response.text[:200]}" |
| ) |
|
|
| raw_voices = response.json().get("voices", []) |
| by_locale: dict[str, List[Voice]] = {} |
| for item in raw_voices: |
| name = item.get("name", "") |
| for locale in item.get("languageCodes", []): |
| prefix = locale.split("-")[0].lower() |
| if prefix not in self.language_prefixes: |
| continue |
| by_locale.setdefault(locale, []).append( |
| Voice(name=name, language_code=locale, description="google_cloud_tts") |
| ) |
|
|
| def rank(voice: Voice) -> tuple[int, str]: |
| lower = voice.name.lower() |
| for idx, preferred in enumerate(self.prefer_voice_types): |
| if preferred in lower: |
| return idx, voice.name |
| return 999, voice.name |
|
|
| selected: List[Voice] = [] |
| for locale in sorted(by_locale): |
| ordered = sorted(by_locale[locale], key=rank) |
| selected.extend(ordered[: self.max_voices_per_locale]) |
|
|
| if not selected: |
| raise RuntimeError("Google TTS returned no voices matching the requested locales.") |
| self._voices = selected |
|
|
| def voices(self) -> List[Voice]: |
| return list(self._voices) |
|
|
| def synthesize(self, text: str, voice: Voice) -> SynthesisResult: |
| payload = { |
| "input": {"text": text}, |
| "voice": { |
| "languageCode": voice.language_code, |
| "name": voice.name, |
| }, |
| "audioConfig": { |
| "audioEncoding": "LINEAR16", |
| "sampleRateHertz": self.sample_rate_hz, |
| }, |
| } |
| response = requests.post( |
| f"{_BASE_URL}/text:synthesize", |
| params={"key": self.api_key}, |
| json=payload, |
| timeout=_TIMEOUT, |
| ) |
| if response.status_code != 200: |
| raise RuntimeError( |
| f"Google TTS synthesize failed ({response.status_code}): " |
| f"{response.text[:200]}" |
| ) |
|
|
| audio_b64 = response.json().get("audioContent") |
| if not audio_b64: |
| raise RuntimeError("Google TTS response contained no audioContent.") |
|
|
| audio, sample_rate = read_wav_bytes(base64.b64decode(audio_b64)) |
| return SynthesisResult(audio=audio, sample_rate_hz=sample_rate) |
|
|