Files
call-center/services/ai_voice_runtime_service/providers/tts.py
T

433 lines
15 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 _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.0").strip()
try:
value = float(raw)
except ValueError:
value = 1.0
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 _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) -> TTSSynthesis:
del language
return TTSSynthesis(text=text, audio_bytes=b"", sample_rate_hz=8000)
class OpenAITTSProvider(TTSProvider):
name = "openai"
def __init__(self) -> None:
self._api_base = _api_base()
self._api_key = _api_key()
self._timeout_seconds = _timeout_seconds()
self._model = _openai_tts_model()
self._voice = _openai_tts_voice()
self._cache_enabled = _tts_cache_enabled()
self._cache_dir = _tts_cache_dir()
self._cache_lock = Lock()
def _cache_key(self, text: str, *, language: str | None) -> str:
payload = {
"provider": self.name,
"model": self._model,
"voice": self._voice,
"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,
"voice": self._voice,
"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) -> TTSSynthesis:
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,
"voice": self._voice,
"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) -> None:
self._api_base = _yandex_api_base()
self._api_key = _yandex_api_key()
self._iam_token = _yandex_iam_token()
self._folder_id = _yandex_folder_id()
self._timeout_seconds = _timeout_seconds()
self._speed = _yandex_speed()
self._role = _yandex_role()
self._sample_rate_hz = _yandex_sample_rate_hz()
self._cache_enabled = _tts_cache_enabled()
self._cache_dir = _tts_cache_dir()
self._cache_lock = Lock()
def _voice(self, language: str | None) -> str:
return _yandex_voice_for_language(language)
def _lang(self, language: str | None) -> str:
return _normalize_yandex_language(language)
def _cache_key(self, text: str, *, language: str | None) -> str:
payload = {
"provider": self.name,
"voice": self._voice(language),
"language": self._lang(language),
"role": self._role 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) -> 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 = 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) -> 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,
"voice": self._voice(language),
"language": self._lang(language),
"role": self._role 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) -> 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)
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)},
]
if self._role:
hints.append({"role": self._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)
if cached is not None:
return cached
self._write_cached_synthesis(synthesis, language=language)
return synthesis
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()
return TTSProvider()