Files

108 lines
3.4 KiB
Python
Raw Permalink Normal View History

"""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:<voice_id>".
"""
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"<speak version='1.0' xml:lang='sr-RS'>"
f"<voice name='{voice}'>{escape(text)}</voice></speak>"
)
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