Files
call-center/services/ai_voice_runtime_service/providers/tts.py
T
Didar KozhikovandClaude Sonnet 5 a9be976845 Implement real streaming TTS for ElevenLabs to cut voice-assistant reply latency
The AudioSocket/media_runtime playback pipeline already supports chunked
TTS streaming (voice_v2_streaming_tts), but every provider inherited the
base TTSProvider.synthesize_chunks(), which just called the blocking
synthesize() and yielded the entire finished audio as a single "chunk" -
so the caller waited for full-utterance synthesis before any playback
could start regardless of the flag.

ElevenLabs is the production default (AI_VOICE_TTS_PROVIDER=elevenlabs in
deployment/docker-compose.server.yml), so give it a real implementation
that POSTs to the /stream endpoint and yields audio as network chunks
arrive, instead of waiting for the whole response body. Chunk boundaries
are re-aligned to whole 16-bit PCM samples so a split sample at a network
read boundary can't corrupt playback. The full synthesized audio is still
written to the on-disk cache afterwards so repeat phrases stay fast and
skip the vendor call entirely, matching the existing synthesize() cache
behavior.

Added test_elevenlabs_tts_provider_streams_chunks_and_caches_full_audio to
cover: chunk splitting mid-sample gets re-aligned, all yielded chunks are
sample-aligned, the full audio round-trips through the cache, and a
cached synthesis is replayed without invoking the streaming endpoint
again.

Verified via tests/test_ai_voice_tts_provider.py (9/9 pass) and a wider
voice/tts-filtered run across the suite: the only failures present are
the same pre-existing, already-documented ones (sales_service test
cross-file isolation ordering, one known persona-prompt assertion) -
identical set to before this change, no new failures.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 21:40:35 +05:00

851 lines
32 KiB
Python

from __future__ import annotations
import base64
import hashlib
import json
import os
from dataclasses import dataclass
from pathlib import Path
import tempfile
from threading import Lock
import httpx
def _bool_env(name: str, default: bool) -> bool:
raw = os.getenv(name)
if raw is None:
return default
return raw.strip().lower() in {"1", "true", "yes", "on"}
def _api_base() -> str:
return (os.getenv("AI_API_BASE", "https://api.openai.com/v1").strip() or "https://api.openai.com/v1").rstrip("/")
def _api_key() -> str:
return os.getenv("AI_API_KEY", "").strip()
def _timeout_seconds() -> float:
raw = os.getenv("AI_TIMEOUT_SECONDS", "20").strip()
try:
value = float(raw)
except ValueError:
value = 20.0
return max(value, 3.0)
def _openai_tts_model() -> str:
return os.getenv("AI_VOICE_TTS_MODEL", "gpt-4o-mini-tts").strip() or "gpt-4o-mini-tts"
def _openai_tts_voice() -> str:
return os.getenv("AI_VOICE_TTS_VOICE", "alloy").strip() or "alloy"
def _openai_tts_speed() -> float:
raw = os.getenv("AI_VOICE_TTS_OPENAI_SPEED", "1.1").strip()
try:
value = float(raw)
except ValueError:
value = 1.1
return max(0.25, min(value, 4.0))
def _yandex_api_base() -> str:
return (
os.getenv("AI_VOICE_TTS_YANDEX_API_BASE", "https://tts.api.ml.yandexcloud.kz").strip()
or "https://tts.api.ml.yandexcloud.kz"
).rstrip("/")
def _yandex_api_key() -> str:
return os.getenv("AI_VOICE_TTS_YANDEX_API_KEY", "").strip()
def _yandex_iam_token() -> str:
return os.getenv("AI_VOICE_TTS_YANDEX_IAM_TOKEN", "").strip()
def _yandex_folder_id() -> str:
return os.getenv("AI_VOICE_TTS_YANDEX_FOLDER_ID", "").strip()
def _yandex_default_language() -> str:
return os.getenv("AI_VOICE_TTS_YANDEX_LANGUAGE", "ru-RU").strip() or "ru-RU"
def _normalize_yandex_language(language: str | None) -> str:
raw = str(language or "").strip()
if not raw:
return _yandex_default_language()
lowered = raw.lower()
if lowered == "ru":
return "ru-RU"
if lowered in {"kk", "kz"}:
return "kk-KZ"
return raw
def _yandex_voice_for_language(language: str | None) -> str:
normalized = _normalize_yandex_language(language).lower()
if normalized.startswith("kk"):
return os.getenv("AI_VOICE_TTS_YANDEX_KK_VOICE", "amira").strip() or "amira"
return os.getenv("AI_VOICE_TTS_YANDEX_VOICE", "jane").strip() or "jane"
def _yandex_role() -> str:
explicit = os.getenv("AI_VOICE_TTS_YANDEX_ROLE", "").strip()
if explicit:
return explicit
# Backward compatibility with the old v1-style env.
return os.getenv("AI_VOICE_TTS_YANDEX_EMOTION", "").strip()
def _yandex_speed() -> str:
raw = os.getenv("AI_VOICE_TTS_YANDEX_SPEED", "1.1").strip()
try:
value = float(raw)
except ValueError:
value = 1.1
return f"{max(0.1, min(value, 3.0)):g}"
def _yandex_sample_rate_hz() -> int:
raw = os.getenv("AI_VOICE_TTS_YANDEX_SAMPLE_RATE_HZ", "8000").strip()
try:
value = int(raw)
except ValueError:
value = 8000
if value < 8000 or value > 48000:
value = 8000
return value
def _normalize_voice_language(language: str | None) -> str:
lowered = str(language or "").strip().lower()
if lowered in {"kz", "kk", "kk-kz", "kk_kz"}:
return "kz"
return "ru"
def _elevenlabs_api_base() -> str:
return (os.getenv("AI_VOICE_TTS_ELEVENLABS_API_BASE", "https://api.elevenlabs.io").strip() or "https://api.elevenlabs.io").rstrip("/")
def _elevenlabs_api_key() -> str:
return os.getenv("AI_VOICE_TTS_ELEVENLABS_API_KEY", "").strip()
def _elevenlabs_model_id() -> str:
return os.getenv("AI_VOICE_TTS_ELEVENLABS_MODEL_ID", "eleven_v3").strip() or "eleven_v3"
def _elevenlabs_output_format() -> str:
return os.getenv("AI_VOICE_TTS_ELEVENLABS_OUTPUT_FORMAT", "pcm_16000").strip() or "pcm_16000"
def _elevenlabs_voice_for_language(language: str | None) -> str:
normalized = _normalize_voice_language(language)
if normalized == "kz":
return os.getenv("AI_VOICE_TTS_ELEVENLABS_KK_VOICE_ID", "nPczCjzI2devNBz1zQrb").strip() or "nPczCjzI2devNBz1zQrb"
return os.getenv("AI_VOICE_TTS_ELEVENLABS_RU_VOICE_ID", "nPczCjzI2devNBz1zQrb").strip() or "nPczCjzI2devNBz1zQrb"
def _elevenlabs_language_code_for_language(language: str | None) -> str:
normalized = _normalize_voice_language(language)
if normalized == "kz":
return os.getenv("AI_VOICE_TTS_ELEVENLABS_KK_LANGUAGE_CODE", "kk").strip() or "kk"
return os.getenv("AI_VOICE_TTS_ELEVENLABS_RU_LANGUAGE_CODE", "ru").strip() or "ru"
def _elevenlabs_sample_rate_hz(output_format: str | None = None) -> int:
normalized = str(output_format or _elevenlabs_output_format()).strip().lower()
for prefix in ("pcm_", "ulaw_"):
if normalized.startswith(prefix):
suffix = normalized.split("_", 1)[1]
try:
return max(int(suffix), 8000)
except ValueError:
break
return 16000
def _tts_cache_enabled() -> bool:
return _bool_env("AI_VOICE_TTS_CACHE_ENABLED", True)
def _tts_cache_dir() -> Path:
explicit = os.getenv("AI_VOICE_TTS_CACHE_DIR", "").strip()
if explicit:
return Path(explicit).expanduser()
data_dir = os.getenv("CC_DATA_DIR", "").strip()
if data_dir:
return Path(data_dir).expanduser() / "ai_voice_tts_cache"
local_data_dir = Path(".data_local")
if local_data_dir.exists():
return local_data_dir / "ai_voice_tts_cache"
return Path(".data") / "ai_voice_tts_cache"
@dataclass(slots=True)
class TTSSynthesis:
text: str
audio_bytes: bytes = b""
sample_rate_hz: int = 24000
class TTSProvider:
name = "stub"
def synthesize(
self,
text: str,
*,
language: str | None = None,
style_hints: dict[str, object] | None = None,
) -> TTSSynthesis:
del language, style_hints
return TTSSynthesis(text=text, audio_bytes=b"", sample_rate_hz=8000)
def synthesize_chunks(
self,
text: str,
*,
language: str | None = None,
style_hints: dict[str, object] | None = None,
):
synthesis = self.synthesize(text, language=language, style_hints=style_hints)
if synthesis.audio_bytes:
yield synthesis
class OpenAITTSProvider(TTSProvider):
name = "openai"
def __init__(
self,
*,
api_base: str | None = None,
api_key: str | None = None,
timeout_seconds: float | None = None,
ru_model: str | None = None,
kz_model: str | None = None,
ru_voice: str | None = None,
kz_voice: str | None = None,
speed: float | None = None,
cache_enabled: bool | None = None,
cache_dir: Path | None = None,
) -> None:
self._api_base = str(api_base or _api_base()).strip().rstrip("/")
self._api_key = str(api_key if api_key is not None else _api_key()).strip()
self._timeout_seconds = max(float(timeout_seconds if timeout_seconds is not None else _timeout_seconds()), 3.0)
self._ru_model = str(ru_model or _openai_tts_model()).strip() or _openai_tts_model()
self._kz_model = str(kz_model or self._ru_model).strip() or self._ru_model
self._ru_voice = str(ru_voice or _openai_tts_voice()).strip() or _openai_tts_voice()
self._kz_voice = str(kz_voice or self._ru_voice).strip() or self._ru_voice
configured_speed = float(speed if speed is not None else _openai_tts_speed())
self._speed = max(0.25, min(configured_speed, 4.0))
self._cache_enabled = _tts_cache_enabled() if cache_enabled is None else bool(cache_enabled)
self._cache_dir = cache_dir or _tts_cache_dir()
self._cache_lock = Lock()
def _model(self, language: str | None) -> str:
return self._kz_model if _normalize_voice_language(language) == "kz" else self._ru_model
def _voice(self, language: str | None) -> str:
return self._kz_voice if _normalize_voice_language(language) == "kz" else self._ru_voice
def _cache_key(self, text: str, *, language: str | None) -> str:
payload = {
"provider": self.name,
"model": self._model(language),
"voice": self._voice(language),
"speed": self._speed,
"language": str(language or "").strip() or None,
"text": text,
}
encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
def _cache_paths(self, cache_key: str) -> tuple[Path, Path]:
prefix = self._cache_dir / cache_key[:2] / cache_key[2:4]
return prefix / f"{cache_key}.pcm", prefix / f"{cache_key}.json"
def _load_cached_synthesis(self, text: str, *, language: str | None) -> TTSSynthesis | None:
if not self._cache_enabled:
return None
pcm_path, meta_path = self._cache_paths(self._cache_key(text, language=language))
if not pcm_path.exists():
return None
try:
audio_bytes = pcm_path.read_bytes()
sample_rate_hz = 24000
if meta_path.exists():
metadata = json.loads(meta_path.read_text(encoding="utf-8"))
sample_rate_hz = max(int(metadata.get("sample_rate_hz") or 24000), 1)
except (OSError, ValueError, TypeError, json.JSONDecodeError):
return None
return TTSSynthesis(text=text, audio_bytes=audio_bytes, sample_rate_hz=sample_rate_hz)
def _write_cached_synthesis(self, synthesis: TTSSynthesis, *, language: str | None) -> None:
if not self._cache_enabled or not synthesis.audio_bytes:
return
pcm_path, meta_path = self._cache_paths(self._cache_key(synthesis.text, language=language))
pcm_path.parent.mkdir(parents=True, exist_ok=True)
metadata = {
"provider": self.name,
"model": self._model(language),
"voice": self._voice(language),
"speed": self._speed,
"language": str(language or "").strip() or None,
"sample_rate_hz": synthesis.sample_rate_hz,
"text": synthesis.text,
}
pcm_tmp: str | None = None
meta_tmp: str | None = None
try:
with tempfile.NamedTemporaryFile(dir=pcm_path.parent, delete=False, suffix=".pcm.tmp") as handle:
handle.write(synthesis.audio_bytes)
pcm_tmp = handle.name
with tempfile.NamedTemporaryFile(dir=meta_path.parent, delete=False, suffix=".json.tmp", mode="w", encoding="utf-8") as handle:
json.dump(metadata, handle, ensure_ascii=False, sort_keys=True)
meta_tmp = handle.name
os.replace(pcm_tmp, pcm_path)
os.replace(meta_tmp, meta_path)
finally:
for temp_path in (pcm_tmp, meta_tmp):
if not temp_path:
continue
try:
if os.path.exists(temp_path):
os.remove(temp_path)
except OSError:
pass
def synthesize(
self,
text: str,
*,
language: str | None = None,
style_hints: dict[str, object] | None = None,
) -> TTSSynthesis:
del style_hints
if not text:
return TTSSynthesis(text=text, audio_bytes=b"", sample_rate_hz=24000)
cached = self._load_cached_synthesis(text, language=language)
if cached is not None:
return cached
if not self._api_key:
raise RuntimeError("AI_API_KEY is required for OpenAI TTS")
with httpx.Client(timeout=self._timeout_seconds) as client:
response = client.post(
f"{self._api_base}/audio/speech",
headers={
"Authorization": f"Bearer {self._api_key}",
"Accept": "application/octet-stream",
},
json={
"model": self._model(language),
"voice": self._voice(language),
"speed": self._speed,
"input": text,
"response_format": "pcm",
},
)
response.raise_for_status()
synthesis = TTSSynthesis(text=text, audio_bytes=response.content, sample_rate_hz=24000)
with self._cache_lock:
cached = self._load_cached_synthesis(text, language=language)
if cached is not None:
return cached
self._write_cached_synthesis(synthesis, language=language)
return synthesis
class YandexTTSProvider(TTSProvider):
name = "yandex"
def __init__(
self,
*,
api_base: str | None = None,
api_key: str | None = None,
iam_token: str | None = None,
folder_id: str | None = None,
timeout_seconds: float | None = None,
ru_voice: str | None = None,
kz_voice: str | None = None,
speed: str | float | None = None,
role: str | None = None,
sample_rate_hz: int | None = None,
cache_enabled: bool | None = None,
cache_dir: Path | None = None,
) -> None:
self._api_base = str(api_base or _yandex_api_base()).strip().rstrip("/")
self._api_key = str(api_key if api_key is not None else _yandex_api_key()).strip()
self._iam_token = str(iam_token if iam_token is not None else _yandex_iam_token()).strip()
self._folder_id = str(folder_id if folder_id is not None else _yandex_folder_id()).strip()
self._timeout_seconds = max(float(timeout_seconds if timeout_seconds is not None else _timeout_seconds()), 3.0)
if speed is None:
self._speed = _yandex_speed()
else:
try:
speed_value = float(speed)
except (TypeError, ValueError):
speed_value = float(_yandex_speed())
self._speed = f"{max(0.1, min(speed_value, 3.0)):g}"
self._role = str(role if role is not None else _yandex_role()).strip()
resolved_sample_rate = int(sample_rate_hz if sample_rate_hz is not None else _yandex_sample_rate_hz())
self._sample_rate_hz = max(min(resolved_sample_rate, 48000), 8000)
self._cache_enabled = _tts_cache_enabled() if cache_enabled is None else bool(cache_enabled)
self._cache_dir = cache_dir or _tts_cache_dir()
self._ru_voice = str(ru_voice or _yandex_voice_for_language("ru")).strip() or _yandex_voice_for_language("ru")
self._kz_voice = str(kz_voice or _yandex_voice_for_language("kz")).strip() or _yandex_voice_for_language("kz")
self._cache_lock = Lock()
def _voice(self, language: str | None) -> str:
return self._kz_voice if _normalize_voice_language(language) == "kz" else self._ru_voice
def _lang(self, language: str | None) -> str:
return _normalize_yandex_language(language)
@staticmethod
def _normalize_style_hints(style_hints: dict[str, object] | None) -> dict[str, object]:
if not isinstance(style_hints, dict):
return {}
normalized: dict[str, object] = {}
role = str(style_hints.get("role") or "").strip()
if role:
normalized["role"] = role
return normalized
def _effective_role(self, *, style_hints: dict[str, object] | None = None) -> str:
normalized_hints = self._normalize_style_hints(style_hints)
explicit_role = str(normalized_hints.get("role") or "").strip()
if explicit_role:
return explicit_role
return self._role
def _cache_key(self, text: str, *, language: str | None, style_hints: dict[str, object] | None = None) -> str:
payload = {
"provider": self.name,
"voice": self._voice(language),
"language": self._lang(language),
"role": self._effective_role(style_hints=style_hints) or None,
"speed": self._speed,
"sample_rate_hz": self._sample_rate_hz,
"text": text,
}
encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
def _cache_paths(self, cache_key: str) -> tuple[Path, Path]:
prefix = self._cache_dir / cache_key[:2] / cache_key[2:4]
return prefix / f"{cache_key}.pcm", prefix / f"{cache_key}.json"
def _load_cached_synthesis(
self,
text: str,
*,
language: str | None,
style_hints: dict[str, object] | None = None,
) -> TTSSynthesis | None:
if not self._cache_enabled:
return None
pcm_path, meta_path = self._cache_paths(self._cache_key(text, language=language, style_hints=style_hints))
if not pcm_path.exists():
return None
try:
audio_bytes = pcm_path.read_bytes()
sample_rate_hz = self._sample_rate_hz
if meta_path.exists():
metadata = json.loads(meta_path.read_text(encoding="utf-8"))
sample_rate_hz = max(int(metadata.get("sample_rate_hz") or self._sample_rate_hz), 1)
except (OSError, ValueError, TypeError, json.JSONDecodeError):
return None
return TTSSynthesis(text=text, audio_bytes=audio_bytes, sample_rate_hz=sample_rate_hz)
def _write_cached_synthesis(
self,
synthesis: TTSSynthesis,
*,
language: str | None,
style_hints: dict[str, object] | None = None,
) -> None:
if not self._cache_enabled or not synthesis.audio_bytes:
return
pcm_path, meta_path = self._cache_paths(
self._cache_key(synthesis.text, language=language, style_hints=style_hints)
)
pcm_path.parent.mkdir(parents=True, exist_ok=True)
metadata = {
"provider": self.name,
"voice": self._voice(language),
"language": self._lang(language),
"role": self._effective_role(style_hints=style_hints) or None,
"speed": self._speed,
"sample_rate_hz": synthesis.sample_rate_hz,
"text": synthesis.text,
}
pcm_tmp: str | None = None
meta_tmp: str | None = None
try:
with tempfile.NamedTemporaryFile(dir=pcm_path.parent, delete=False, suffix=".pcm.tmp") as handle:
handle.write(synthesis.audio_bytes)
pcm_tmp = handle.name
with tempfile.NamedTemporaryFile(dir=meta_path.parent, delete=False, suffix=".json.tmp", mode="w", encoding="utf-8") as handle:
json.dump(metadata, handle, ensure_ascii=False, sort_keys=True)
meta_tmp = handle.name
os.replace(pcm_tmp, pcm_path)
os.replace(meta_tmp, meta_path)
finally:
for temp_path in (pcm_tmp, meta_tmp):
if not temp_path:
continue
try:
if os.path.exists(temp_path):
os.remove(temp_path)
except OSError:
pass
def synthesize(
self,
text: str,
*,
language: str | None = None,
style_hints: dict[str, object] | None = None,
) -> TTSSynthesis:
if not text:
return TTSSynthesis(text=text, audio_bytes=b"", sample_rate_hz=self._sample_rate_hz)
cached = self._load_cached_synthesis(text, language=language, style_hints=style_hints)
if cached is not None:
return cached
if not self._api_key and not self._iam_token:
raise RuntimeError(
"AI_VOICE_TTS_YANDEX_API_KEY or AI_VOICE_TTS_YANDEX_IAM_TOKEN is required for Yandex TTS"
)
headers = {"Accept": "application/json"}
if self._api_key:
headers["Authorization"] = f"Api-Key {self._api_key}"
else:
headers["Authorization"] = f"Bearer {self._iam_token}"
if self._folder_id:
headers["x-folder-id"] = self._folder_id
hints: list[dict[str, object]] = [
{"voice": self._voice(language)},
{"speed": float(self._speed)},
]
effective_role = self._effective_role(style_hints=style_hints)
if effective_role:
hints.append({"role": effective_role})
payload = {
"text": text,
"hints": hints,
"outputAudioSpec": {
"rawAudio": {
"audioEncoding": "LINEAR16_PCM",
"sampleRateHertz": self._sample_rate_hz,
}
},
"loudnessNormalizationType": "LUFS",
}
with httpx.Client(timeout=self._timeout_seconds) as client:
response = client.post(
f"{self._api_base}/tts/v3/utteranceSynthesis",
headers=headers,
json=payload,
)
response.raise_for_status()
response_payload = response.json()
result = response_payload.get("result")
if isinstance(result, dict):
response_payload = result
audio_chunk = response_payload.get("audioChunk")
if not isinstance(audio_chunk, dict):
raise RuntimeError("Yandex TTS response is missing audioChunk")
audio_data = audio_chunk.get("data")
if not isinstance(audio_data, str) or not audio_data:
raise RuntimeError("Yandex TTS response is missing audioChunk.data")
try:
audio_bytes = base64.b64decode(audio_data)
except (ValueError, TypeError) as exc:
raise RuntimeError("Yandex TTS returned invalid base64 audio data") from exc
synthesis = TTSSynthesis(text=text, audio_bytes=audio_bytes, sample_rate_hz=self._sample_rate_hz)
with self._cache_lock:
cached = self._load_cached_synthesis(text, language=language, style_hints=style_hints)
if cached is not None:
return cached
self._write_cached_synthesis(synthesis, language=language, style_hints=style_hints)
return synthesis
class ElevenLabsTTSProvider(TTSProvider):
name = "elevenlabs"
def __init__(
self,
*,
api_base: str | None = None,
api_key: str | None = None,
timeout_seconds: float | None = None,
ru_voice: str | None = None,
kz_voice: str | None = None,
ru_model: str | None = None,
kz_model: str | None = None,
ru_language_code: str | None = None,
kz_language_code: str | None = None,
output_format: str | None = None,
cache_enabled: bool | None = None,
cache_dir: Path | None = None,
) -> None:
self._api_base = str(api_base or _elevenlabs_api_base()).strip().rstrip("/")
self._api_key = str(api_key if api_key is not None else _elevenlabs_api_key()).strip()
self._timeout_seconds = max(float(timeout_seconds if timeout_seconds is not None else _timeout_seconds()), 3.0)
self._ru_voice = str(ru_voice or _elevenlabs_voice_for_language("ru")).strip() or _elevenlabs_voice_for_language("ru")
self._kz_voice = str(kz_voice or _elevenlabs_voice_for_language("kz")).strip() or _elevenlabs_voice_for_language("kz")
self._ru_model = str(ru_model or _elevenlabs_model_id()).strip() or _elevenlabs_model_id()
self._kz_model = str(kz_model or self._ru_model).strip() or self._ru_model
self._ru_language_code = (
str(ru_language_code or _elevenlabs_language_code_for_language("ru")).strip()
or _elevenlabs_language_code_for_language("ru")
)
self._kz_language_code = (
str(kz_language_code or _elevenlabs_language_code_for_language("kz")).strip()
or _elevenlabs_language_code_for_language("kz")
)
self._output_format = str(output_format or _elevenlabs_output_format()).strip() or _elevenlabs_output_format()
self._sample_rate_hz = _elevenlabs_sample_rate_hz(self._output_format)
self._cache_enabled = _tts_cache_enabled() if cache_enabled is None else bool(cache_enabled)
self._cache_dir = cache_dir or _tts_cache_dir()
self._cache_lock = Lock()
def _voice(self, language: str | None) -> str:
return self._kz_voice if _normalize_voice_language(language) == "kz" else self._ru_voice
def _model(self, language: str | None) -> str:
return self._kz_model if _normalize_voice_language(language) == "kz" else self._ru_model
def _language_code(self, language: str | None) -> str:
return self._kz_language_code if _normalize_voice_language(language) == "kz" else self._ru_language_code
def _cache_key(
self,
text: str,
*,
language: str | None,
style_hints: dict[str, object] | None = None,
) -> str:
del style_hints
payload = {
"provider": self.name,
"voice": self._voice(language),
"model": self._model(language),
"language_code": self._language_code(language),
"output_format": self._output_format,
"text": text,
}
encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
def _cache_paths(self, cache_key: str) -> tuple[Path, Path]:
prefix = self._cache_dir / cache_key[:2] / cache_key[2:4]
return prefix / f"{cache_key}.pcm", prefix / f"{cache_key}.json"
def _load_cached_synthesis(
self,
text: str,
*,
language: str | None,
style_hints: dict[str, object] | None = None,
) -> TTSSynthesis | None:
if not self._cache_enabled:
return None
pcm_path, meta_path = self._cache_paths(self._cache_key(text, language=language, style_hints=style_hints))
if not pcm_path.exists():
return None
try:
audio_bytes = pcm_path.read_bytes()
sample_rate_hz = self._sample_rate_hz
if meta_path.exists():
metadata = json.loads(meta_path.read_text(encoding="utf-8"))
sample_rate_hz = max(int(metadata.get("sample_rate_hz") or self._sample_rate_hz), 1)
except (OSError, ValueError, TypeError, json.JSONDecodeError):
return None
return TTSSynthesis(text=text, audio_bytes=audio_bytes, sample_rate_hz=sample_rate_hz)
def _write_cached_synthesis(
self,
synthesis: TTSSynthesis,
*,
language: str | None,
style_hints: dict[str, object] | None = None,
) -> None:
if not self._cache_enabled or not synthesis.audio_bytes:
return
pcm_path, meta_path = self._cache_paths(
self._cache_key(synthesis.text, language=language, style_hints=style_hints)
)
pcm_path.parent.mkdir(parents=True, exist_ok=True)
metadata = {
"provider": self.name,
"voice": self._voice(language),
"model": self._model(language),
"language_code": self._language_code(language),
"output_format": self._output_format,
"sample_rate_hz": synthesis.sample_rate_hz,
"text": synthesis.text,
}
pcm_tmp: str | None = None
meta_tmp: str | None = None
try:
with tempfile.NamedTemporaryFile(dir=pcm_path.parent, delete=False, suffix=".pcm.tmp") as handle:
handle.write(synthesis.audio_bytes)
pcm_tmp = handle.name
with tempfile.NamedTemporaryFile(
dir=meta_path.parent,
delete=False,
suffix=".json.tmp",
mode="w",
encoding="utf-8",
) as handle:
json.dump(metadata, handle, ensure_ascii=False, sort_keys=True)
meta_tmp = handle.name
os.replace(pcm_tmp, pcm_path)
os.replace(meta_tmp, meta_path)
finally:
for temp_path in (pcm_tmp, meta_tmp):
if not temp_path:
continue
try:
if os.path.exists(temp_path):
os.remove(temp_path)
except OSError:
pass
def synthesize(
self,
text: str,
*,
language: str | None = None,
style_hints: dict[str, object] | None = None,
) -> TTSSynthesis:
del style_hints
if not text:
return TTSSynthesis(text=text, audio_bytes=b"", sample_rate_hz=self._sample_rate_hz)
cached = self._load_cached_synthesis(text, language=language)
if cached is not None:
return cached
if not self._api_key:
raise RuntimeError("AI_VOICE_TTS_ELEVENLABS_API_KEY is required for ElevenLabs TTS")
with httpx.Client(timeout=self._timeout_seconds) as client:
response = client.post(
f"{self._api_base}/v1/text-to-speech/{self._voice(language)}",
headers={
"xi-api-key": self._api_key,
"Accept": "application/octet-stream",
"Content-Type": "application/json",
},
params={"output_format": self._output_format},
json={
"text": text,
"model_id": self._model(language),
"language_code": self._language_code(language),
},
)
response.raise_for_status()
synthesis = TTSSynthesis(text=text, audio_bytes=response.content, sample_rate_hz=self._sample_rate_hz)
with self._cache_lock:
cached = self._load_cached_synthesis(text, language=language)
if cached is not None:
return cached
self._write_cached_synthesis(synthesis, language=language)
return synthesis
def synthesize_chunks(
self,
text: str,
*,
language: str | None = None,
style_hints: dict[str, object] | None = None,
):
del style_hints
if not text:
return
cached = self._load_cached_synthesis(text, language=language)
if cached is not None:
if cached.audio_bytes:
yield cached
return
if not self._api_key:
raise RuntimeError("AI_VOICE_TTS_ELEVENLABS_API_KEY is required for ElevenLabs TTS")
# Raw PCM16 samples must stay 2-byte aligned across network chunk
# boundaries, or a split sample corrupts playback at that boundary.
collected = bytearray()
leftover = b""
with httpx.Client(timeout=self._timeout_seconds) as client:
with client.stream(
"POST",
f"{self._api_base}/v1/text-to-speech/{self._voice(language)}/stream",
headers={
"xi-api-key": self._api_key,
"Accept": "application/octet-stream",
"Content-Type": "application/json",
},
params={"output_format": self._output_format},
json={
"text": text,
"model_id": self._model(language),
"language_code": self._language_code(language),
},
) as response:
response.raise_for_status()
for raw_chunk in response.iter_bytes():
if not raw_chunk:
continue
data = leftover + raw_chunk
if len(data) % 2:
leftover = data[-1:]
data = data[:-1]
else:
leftover = b""
if not data:
continue
collected.extend(data)
yield TTSSynthesis(text=text, audio_bytes=data, sample_rate_hz=self._sample_rate_hz)
if leftover:
collected.extend(leftover)
if collected:
full_synthesis = TTSSynthesis(text=text, audio_bytes=bytes(collected), sample_rate_hz=self._sample_rate_hz)
with self._cache_lock:
if self._load_cached_synthesis(text, language=language) is None:
self._write_cached_synthesis(full_synthesis, language=language)
def build_tts_provider(name: str) -> TTSProvider:
normalized = str(name or "stub").strip().lower()
if normalized == "openai":
return OpenAITTSProvider()
if normalized in {"yandex", "yandex_speechkit", "speechkit"}:
return YandexTTSProvider()
if normalized in {"elevenlabs", "11labs"}:
return ElevenLabsTTSProvider()
return TTSProvider()