433 lines
15 KiB
Python
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()
|