Spaces:
Running
Running
| """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) | |