"""TTSProvider interface (§4): Azure Neural + ElevenLabs Flash + test fake. Both return SLIN 8 kHz PCM ready for AudioSocket. Default provider/voice comes from settings; per-tenant voice ids look like "azure:sr-RS-SophieNeural" or "elevenlabs:". """ from __future__ import annotations import logging from typing import Protocol from xml.sax.saxutils import escape import httpx from gogo.config import get_settings from gogo.voice.audio import SAMPLE_RATE, resample, tone log = logging.getLogger("gogo.voice.tts") DEFAULT_AZURE_VOICE = "sr-RS-SophieNeural" class TTSProvider(Protocol): async def synthesize(self, text: str, voice: str = "") -> bytes: """Text → SLIN 8 kHz PCM.""" ... class AzureTTS: async def synthesize(self, text: str, voice: str = "") -> bytes: s = get_settings() voice = voice or DEFAULT_AZURE_VOICE ssml = ( f"" f"{escape(text)}" ) url = ( f"https://{s.azure_speech_region}.tts.speech.microsoft.com/" "cognitiveservices/v1" ) async with httpx.AsyncClient(timeout=15) as client: resp = await client.post( url, content=ssml.encode(), headers={ "Ocp-Apim-Subscription-Key": s.azure_speech_key, "Content-Type": "application/ssml+xml", "X-Microsoft-OutputFormat": "raw-8khz-16bit-mono-pcm", "User-Agent": "gogotelefon", }, ) resp.raise_for_status() return resp.content class ElevenLabsTTS: async def synthesize(self, text: str, voice: str = "") -> bytes: s = get_settings() voice_id = voice or "JBFqnCBsd6RMkjVDRZzb" async with httpx.AsyncClient(timeout=20) as client: resp = await client.post( f"https://api.elevenlabs.io/v1/text-to-speech/{voice_id}", params={"output_format": "pcm_16000"}, headers={"xi-api-key": s.elevenlabs_api_key}, json={"text": text, "model_id": "eleven_flash_v2_5"}, ) resp.raise_for_status() return resample(resp.content, 16000, SAMPLE_RATE) class FakeTTS: """Test fake: N ms of tone per character (deterministic, VAD-audible).""" def __init__(self, ms_per_char: int = 5): self.ms_per_char = ms_per_char self.spoken: list[str] = [] async def synthesize(self, text: str, voice: str = "") -> bytes: self.spoken.append(text) return tone(440, max(40, self.ms_per_char * len(text))) def make_tts(provider_and_voice: str = "") -> tuple[TTSProvider, str]: """Resolve a tenant voice id 'provider:voice' → (provider instance, voice).""" s = get_settings() provider_name, _, voice = (provider_and_voice or "").partition(":") if not voice: provider_name, voice = s.tts_provider, "" if provider_name == "elevenlabs": return ElevenLabsTTS(), voice return AzureTTS(), voice or DEFAULT_AZURE_VOICE _tts: TTSProvider | None = None def get_tts(tenant_voice: str = "") -> tuple[TTSProvider, str]: if _tts is not None: # test override _, _, voice = (tenant_voice or "").partition(":") return _tts, voice return make_tts(tenant_voice) def set_tts(provider: TTSProvider | None) -> None: global _tts _tts = provider