feat(voice): add admin-configurable elevenlabs tts

This commit is contained in:
Yera All
2026-04-11 16:46:43 +05:00
parent 9d0b76deca
commit fc976804e4
14 changed files with 1342 additions and 32 deletions
+26
View File
@@ -56,6 +56,8 @@ from services.shared.models import (
HealthResponse,
VoiceNameCollectionConfig,
VoiceNameCollectionConfigOut,
VoiceTTSConfig,
VoiceTTSConfigOut,
VoiceAIStartIn,
VoiceAIStartOut,
VoiceAITurnIn,
@@ -84,6 +86,7 @@ from services.ai_orchestrator_service.voice_name_config import (
load_voice_name_collection_config,
save_voice_name_collection_config,
)
from services.shared.voice_tts_config import load_voice_tts_config, save_voice_tts_config
app = FastAPI(title="ai-orchestrator-service", version="1.0.0")
@@ -3387,6 +3390,29 @@ def put_voice_name_collection_config(
session.close()
@app.get("/ai/voice/config/tts", response_model=VoiceTTSConfigOut)
def get_voice_tts_config(
_: dict = Depends(require_roles(Role.ADMIN)),
) -> VoiceTTSConfigOut:
session = get_session()
try:
return load_voice_tts_config(session)
finally:
session.close()
@app.put("/ai/voice/config/tts", response_model=VoiceTTSConfigOut)
def put_voice_tts_config(
payload: VoiceTTSConfig,
_: dict = Depends(require_roles(Role.ADMIN)),
) -> VoiceTTSConfigOut:
session = get_session()
try:
return save_voice_tts_config(session, payload)
finally:
session.close()
@app.post("/ai/voice/sessions/{session_id}/turns")
def turn_voice_ai_session(
session_id: str,
+9 -3
View File
@@ -17,7 +17,7 @@ from sqlalchemy.exc import OperationalError
from services.ai_voice_runtime_service.audiosocket import normalize_media_uuid
from services.ai_voice_runtime_service.media_runtime import AudioSocketMediaRuntime, MediaRegistration
from services.ai_voice_runtime_service.providers.asr import build_asr_provider
from services.ai_voice_runtime_service.providers.tts import build_tts_provider
from services.ai_voice_runtime_service.runtime_tts_provider import RuntimeConfiguredTTSProvider
from services.shared.core import Role, new_id, utc_now_iso
from services.shared.db import get_session
from services.shared.models import (
@@ -249,7 +249,11 @@ def _default_voice_greeting(language: str | None, *, agent_profile: str = "voice
_ASR_PROVIDER = build_asr_provider(_asr_provider_name())
_TTS_PROVIDER = build_tts_provider(_tts_provider_name())
_TTS_PROVIDER = RuntimeConfiguredTTSProvider(default_provider_name=_tts_provider_name())
def _current_tts_provider_name() -> str:
return _TTS_PROVIDER.current_provider_name()
def _load_voice_session(session, session_id: str) -> VoiceAISessionRow:
@@ -1185,6 +1189,7 @@ def create_voice_ai_session(
voice_session.language if voice_session is not None else None,
)
payload_metadata = payload.metadata if isinstance(payload.metadata, dict) else {}
effective_tts_provider_name = _current_tts_provider_name()
is_voice_start = (
str(payload.agent_profile or "").strip() == "voice_start"
or str(payload_metadata.get("stage") or "").strip() == "voice_start"
@@ -1201,7 +1206,7 @@ def create_voice_ai_session(
agent_profile=payload.agent_profile,
language=language,
asr_provider=_ASR_PROVIDER.name,
tts_provider=_TTS_PROVIDER.name,
tts_provider=effective_tts_provider_name,
status="greeting",
handoff_reason=None,
handoff_target_queue_id=payload.handoff_queue_id or payload.queue_id,
@@ -1229,6 +1234,7 @@ def create_voice_ai_session(
voice_session.queue_id = payload.queue_id
voice_session.agent_profile = payload.agent_profile
voice_session.language = language
voice_session.tts_provider = effective_tts_provider_name
voice_session.status = "greeting"
voice_session.handoff_reason = None
voice_session.handoff_target_queue_id = payload.handoff_queue_id or voice_session.handoff_target_queue_id or payload.queue_id
@@ -123,6 +123,55 @@ def _yandex_sample_rate_hz() -> int:
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)
@@ -175,22 +224,44 @@ class TTSProvider:
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._speed = _openai_tts_speed()
self._cache_enabled = _tts_cache_enabled()
self._cache_dir = _tts_cache_dir()
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,
"voice": self._voice,
"model": self._model(language),
"voice": self._voice(language),
"speed": self._speed,
"language": str(language or "").strip() or None,
"text": text,
@@ -228,8 +299,8 @@ class OpenAITTSProvider(TTSProvider):
pcm_path.parent.mkdir(parents=True, exist_ok=True)
metadata = {
"provider": self.name,
"model": self._model,
"voice": self._voice,
"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,
@@ -281,8 +352,8 @@ class OpenAITTSProvider(TTSProvider):
"Accept": "application/octet-stream",
},
json={
"model": self._model,
"voice": self._voice,
"model": self._model(language),
"voice": self._voice(language),
"speed": self._speed,
"input": text,
"response_format": "pcm",
@@ -301,21 +372,46 @@ class OpenAITTSProvider(TTSProvider):
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()
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 _yandex_voice_for_language(language)
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)
@@ -498,10 +594,196 @@ class YandexTTSProvider(TTSProvider):
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 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()
@@ -0,0 +1,123 @@
from __future__ import annotations
from threading import Lock
from services.ai_voice_runtime_service.providers.tts import (
ElevenLabsTTSProvider,
OpenAITTSProvider,
TTSProvider,
YandexTTSProvider,
build_tts_provider,
)
from services.shared.db import get_session
from services.shared.voice_tts_config import load_effective_voice_tts_config
class RuntimeConfiguredTTSProvider(TTSProvider):
name = "dynamic"
def __init__(self, *, default_provider_name: str = "yandex") -> None:
self._default_provider_name = str(default_provider_name or "yandex").strip().lower() or "yandex"
self._provider_cache: dict[tuple[str, tuple[str | None, ...]], TTSProvider] = {}
self._lock = Lock()
def current_provider_name(self) -> str:
session = get_session()
try:
config = load_effective_voice_tts_config(session)
normalized = str(config.provider or self._default_provider_name).strip().lower()
if normalized in {"yandex", "elevenlabs", "openai"}:
return normalized
return self._default_provider_name
except Exception:
return self._default_provider_name
finally:
session.close()
def _provider_for_language(self, language: str | None) -> TTSProvider:
session = get_session()
try:
config = load_effective_voice_tts_config(session)
except Exception:
return build_tts_provider(self._default_provider_name)
finally:
session.close()
provider_name = str(config.provider or self._default_provider_name).strip().lower()
if provider_name not in {"yandex", "elevenlabs", "openai"}:
provider_name = self._default_provider_name
cache_key: tuple[str, tuple[str | None, ...]]
provider: TTSProvider
if provider_name == "yandex":
cache_key = (
provider_name,
(
config.yandex.ru.voice,
config.yandex.kz.voice,
),
)
with self._lock:
provider = self._provider_cache.get(cache_key)
if provider is None:
provider = YandexTTSProvider(
ru_voice=config.yandex.ru.voice,
kz_voice=config.yandex.kz.voice,
)
self._provider_cache[cache_key] = provider
return provider
if provider_name == "elevenlabs":
cache_key = (
provider_name,
(
config.elevenlabs.ru.voice,
config.elevenlabs.kz.voice,
config.elevenlabs.ru.model_id,
config.elevenlabs.kz.model_id,
config.elevenlabs.ru.language_code,
config.elevenlabs.kz.language_code,
),
)
with self._lock:
provider = self._provider_cache.get(cache_key)
if provider is None:
provider = ElevenLabsTTSProvider(
ru_voice=config.elevenlabs.ru.voice,
kz_voice=config.elevenlabs.kz.voice,
ru_model=config.elevenlabs.ru.model_id,
kz_model=config.elevenlabs.kz.model_id,
ru_language_code=config.elevenlabs.ru.language_code,
kz_language_code=config.elevenlabs.kz.language_code,
)
self._provider_cache[cache_key] = provider
return provider
cache_key = (
"openai",
(
config.openai.ru.voice,
config.openai.kz.voice,
config.openai.ru.model_id,
config.openai.kz.model_id,
),
)
with self._lock:
provider = self._provider_cache.get(cache_key)
if provider is None:
provider = OpenAITTSProvider(
ru_voice=config.openai.ru.voice,
kz_voice=config.openai.kz.voice,
ru_model=config.openai.ru.model_id,
kz_model=config.openai.kz.model_id,
)
self._provider_cache[cache_key] = provider
return provider
def synthesize(self, text: str, *, language: str | None = None, style_hints: dict[str, object] | None = None):
provider = self._provider_for_language(language)
return provider.synthesize(text, language=language, style_hints=style_hints)
def synthesize_chunks(self, text: str, *, language: str | None = None, style_hints: dict[str, object] | None = None):
provider = self._provider_for_language(language)
yield from provider.synthesize_chunks(text, language=language, style_hints=style_hints)
+54 -1
View File
@@ -2,7 +2,7 @@
from typing import Literal
from pydantic import BaseModel, Field, field_validator, model_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from services.shared.core import Role
@@ -98,6 +98,59 @@ class VoiceNameCollectionConfigOut(BaseModel):
source: Literal["defaults", "database"] = "defaults"
VoiceTTSProviderName = Literal["yandex", "elevenlabs", "openai"]
VoiceTTSLanguage = Literal["ru", "kz"]
class VoiceTTSLanguageConfig(BaseModel):
model_config = ConfigDict(protected_namespaces=())
voice: str | None = None
model_id: str | None = None
language_code: str | None = None
@field_validator("voice", "model_id", "language_code", mode="before")
@classmethod
def _trim_optional_text(cls, value: str | None) -> str | None:
if value is None:
return None
normalized = str(value).strip()
return normalized or None
class VoiceTTSProviderConfig(BaseModel):
ru: VoiceTTSLanguageConfig = Field(default_factory=VoiceTTSLanguageConfig)
kz: VoiceTTSLanguageConfig = Field(default_factory=VoiceTTSLanguageConfig)
class VoiceTTSVoiceOption(BaseModel):
value: str = Field(min_length=1)
label: str = Field(min_length=1)
@field_validator("value", "label", mode="before")
@classmethod
def _trim_required_option_text(cls, value: str) -> str:
normalized = str(value or "").strip()
if not normalized:
raise ValueError("Option value is required")
return normalized
class VoiceTTSConfig(BaseModel):
provider: VoiceTTSProviderName = "yandex"
yandex: VoiceTTSProviderConfig = Field(default_factory=VoiceTTSProviderConfig)
elevenlabs: VoiceTTSProviderConfig = Field(default_factory=VoiceTTSProviderConfig)
openai: VoiceTTSProviderConfig = Field(default_factory=VoiceTTSProviderConfig)
class VoiceTTSConfigOut(BaseModel):
config: VoiceTTSConfig
updated_at: str | None = None
source: Literal["defaults", "database"] = "defaults"
provider_options: list[VoiceTTSProviderName] = Field(default_factory=list)
voice_options: dict[str, dict[str, list[VoiceTTSVoiceOption]]] = Field(default_factory=dict)
class HealthResponse(BaseModel):
status: str
service: str
+9
View File
@@ -129,6 +129,15 @@ class VoiceNameCollectionSettingsRow(Base):
updated_at: Mapped[str] = mapped_column(String(64), index=True)
class VoiceTTSSettingsRow(Base):
__tablename__ = "voice_tts_settings"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
settings_key: Mapped[str] = mapped_column(String(64), unique=True, index=True)
config_json: Mapped[str] = mapped_column(Text, default="{}")
updated_at: Mapped[str] = mapped_column(String(64), index=True)
class RoutingCounter(Base):
__tablename__ = "routing_counters"
+230
View File
@@ -0,0 +1,230 @@
from __future__ import annotations
import json
import os
from typing import Any
from sqlalchemy import select
from services.shared.core import utc_now_iso
from services.shared.models import (
VoiceTTSConfig,
VoiceTTSConfigOut,
VoiceTTSLanguageConfig,
VoiceTTSProviderConfig,
VoiceTTSVoiceOption,
)
from services.shared.sql_models import VoiceTTSSettingsRow
VOICE_TTS_SETTINGS_KEY = "global"
def _normalize_provider_name(value: str | None) -> str:
normalized = str(value or "yandex").strip().lower()
if normalized in {"speechkit", "yandex_speechkit"}:
return "yandex"
if normalized in {"11labs"}:
return "elevenlabs"
if normalized not in {"yandex", "elevenlabs", "openai"}:
return "yandex"
return normalized
def _openai_default_model() -> str:
return str(os.getenv("AI_VOICE_TTS_MODEL", "gpt-4o-mini-tts") or "gpt-4o-mini-tts").strip() or "gpt-4o-mini-tts"
def _openai_default_voice() -> str:
return str(os.getenv("AI_VOICE_TTS_VOICE", "alloy") or "alloy").strip() or "alloy"
def _yandex_default_voice(language: str) -> str:
normalized = str(language or "ru").strip().lower()
if normalized == "kz":
return str(os.getenv("AI_VOICE_TTS_YANDEX_KK_VOICE", "amira") or "amira").strip() or "amira"
return str(os.getenv("AI_VOICE_TTS_YANDEX_VOICE", "jane") or "jane").strip() or "jane"
def _elevenlabs_default_model() -> str:
return str(os.getenv("AI_VOICE_TTS_ELEVENLABS_MODEL_ID", "eleven_v3") or "eleven_v3").strip() or "eleven_v3"
def _elevenlabs_default_voice(language: str) -> str:
normalized = str(language or "ru").strip().lower()
if normalized == "kz":
return (
str(os.getenv("AI_VOICE_TTS_ELEVENLABS_KK_VOICE_ID", "nPczCjzI2devNBz1zQrb") or "nPczCjzI2devNBz1zQrb").strip()
or "nPczCjzI2devNBz1zQrb"
)
return (
str(os.getenv("AI_VOICE_TTS_ELEVENLABS_RU_VOICE_ID", "nPczCjzI2devNBz1zQrb") or "nPczCjzI2devNBz1zQrb").strip()
or "nPczCjzI2devNBz1zQrb"
)
def voice_tts_default_config() -> VoiceTTSConfig:
return VoiceTTSConfig(
provider=_normalize_provider_name(os.getenv("AI_VOICE_TTS_PROVIDER", "yandex")),
yandex=VoiceTTSProviderConfig(
ru=VoiceTTSLanguageConfig(voice=_yandex_default_voice("ru")),
kz=VoiceTTSLanguageConfig(voice=_yandex_default_voice("kz")),
),
elevenlabs=VoiceTTSProviderConfig(
ru=VoiceTTSLanguageConfig(
voice=_elevenlabs_default_voice("ru"),
model_id=_elevenlabs_default_model(),
language_code="ru",
),
kz=VoiceTTSLanguageConfig(
voice=_elevenlabs_default_voice("kz"),
model_id=_elevenlabs_default_model(),
language_code="kk",
),
),
openai=VoiceTTSProviderConfig(
ru=VoiceTTSLanguageConfig(
voice=_openai_default_voice(),
model_id=_openai_default_model(),
language_code="ru",
),
kz=VoiceTTSLanguageConfig(
voice=_openai_default_voice(),
model_id=_openai_default_model(),
language_code="kz",
),
),
)
def voice_tts_provider_options() -> list[str]:
return ["yandex", "elevenlabs", "openai"]
def voice_tts_voice_options() -> dict[str, dict[str, list[VoiceTTSVoiceOption]]]:
return {
"yandex": {
"ru": [
VoiceTTSVoiceOption(value="jane", label="Jane"),
VoiceTTSVoiceOption(value="oksana", label="Oksana"),
VoiceTTSVoiceOption(value="ermil", label="Ermil"),
],
"kz": [
VoiceTTSVoiceOption(value="amira", label="Amira"),
VoiceTTSVoiceOption(value="madi", label="Madi"),
VoiceTTSVoiceOption(value="saule", label="Saule"),
VoiceTTSVoiceOption(value="zhanar", label="Zhanar"),
],
},
"elevenlabs": {
"ru": [
VoiceTTSVoiceOption(value="nPczCjzI2devNBz1zQrb", label="Brian"),
],
"kz": [
VoiceTTSVoiceOption(value="nPczCjzI2devNBz1zQrb", label="Brian"),
],
},
"openai": {
"ru": [
VoiceTTSVoiceOption(value="alloy", label="Alloy"),
VoiceTTSVoiceOption(value="echo", label="Echo"),
VoiceTTSVoiceOption(value="fable", label="Fable"),
VoiceTTSVoiceOption(value="onyx", label="Onyx"),
VoiceTTSVoiceOption(value="nova", label="Nova"),
VoiceTTSVoiceOption(value="shimmer", label="Shimmer"),
],
"kz": [
VoiceTTSVoiceOption(value="alloy", label="Alloy"),
VoiceTTSVoiceOption(value="echo", label="Echo"),
VoiceTTSVoiceOption(value="fable", label="Fable"),
VoiceTTSVoiceOption(value="onyx", label="Onyx"),
VoiceTTSVoiceOption(value="nova", label="Nova"),
VoiceTTSVoiceOption(value="shimmer", label="Shimmer"),
],
},
}
def _settings_row(session) -> VoiceTTSSettingsRow | None:
return session.execute(
select(VoiceTTSSettingsRow).where(VoiceTTSSettingsRow.settings_key == VOICE_TTS_SETTINGS_KEY)
).scalar_one_or_none()
def _normalize_config_payload(raw: Any) -> VoiceTTSConfig:
if isinstance(raw, VoiceTTSConfig):
return raw
if not isinstance(raw, dict):
raise ValueError("Voice TTS config payload must be an object")
return VoiceTTSConfig.model_validate(raw)
def load_voice_tts_config(session) -> VoiceTTSConfigOut:
default_config = voice_tts_default_config()
row = _settings_row(session)
if row is None:
return VoiceTTSConfigOut(
config=default_config,
updated_at=None,
source="defaults",
provider_options=voice_tts_provider_options(),
voice_options=voice_tts_voice_options(),
)
try:
parsed = json.loads(row.config_json or "{}")
config = _normalize_config_payload(parsed)
return VoiceTTSConfigOut(
config=config,
updated_at=row.updated_at,
source="database",
provider_options=voice_tts_provider_options(),
voice_options=voice_tts_voice_options(),
)
except Exception:
return VoiceTTSConfigOut(
config=default_config,
updated_at=row.updated_at,
source="defaults",
provider_options=voice_tts_provider_options(),
voice_options=voice_tts_voice_options(),
)
def load_effective_voice_tts_config(session) -> VoiceTTSConfig:
config = load_voice_tts_config(session).config
config.provider = _normalize_provider_name(config.provider)
return config
def save_voice_tts_config(session, config_payload: VoiceTTSConfig | dict[str, Any]) -> VoiceTTSConfigOut:
config = _normalize_config_payload(config_payload)
config.provider = _normalize_provider_name(config.provider)
row = _settings_row(session)
now = utc_now_iso()
if row is None:
row = VoiceTTSSettingsRow(
settings_key=VOICE_TTS_SETTINGS_KEY,
config_json="{}",
updated_at=now,
)
session.add(row)
row.config_json = json.dumps(config.model_dump(), ensure_ascii=False)
row.updated_at = now
session.commit()
session.refresh(row)
return VoiceTTSConfigOut(
config=config,
updated_at=row.updated_at,
source="database",
provider_options=voice_tts_provider_options(),
voice_options=voice_tts_voice_options(),
)
def voice_tts_language_config(config: VoiceTTSConfig, provider: str, language: str) -> VoiceTTSLanguageConfig:
normalized_provider = str(provider or config.provider or "yandex").strip().lower()
provider_config = getattr(config, normalized_provider, None)
if not isinstance(provider_config, VoiceTTSProviderConfig):
provider_config = getattr(voice_tts_default_config(), normalized_provider)
normalized_language = "kz" if str(language or "").strip().lower() == "kz" else "ru"
return provider_config.kz if normalized_language == "kz" else provider_config.ru