From fc976804e45291afbd970a4bcb7649a54f9fa7dd Mon Sep 17 00:00:00 2001 From: Yera All Date: Sat, 11 Apr 2026 16:46:43 +0500 Subject: [PATCH] feat(voice): add admin-configurable elevenlabs tts --- .env.example | 8 + .env.production.template | 8 + services/ai_orchestrator_service/app.py | 26 ++ services/ai_voice_runtime_service/app.py | 12 +- .../ai_voice_runtime_service/providers/tts.py | 336 ++++++++++++++++-- .../runtime_tts_provider.py | 123 +++++++ services/shared/models.py | 55 ++- services/shared/sql_models.py | 9 + services/shared/voice_tts_config.py | 230 ++++++++++++ tests/test_ai_orchestrator_service.py | 67 ++++ tests/test_ai_voice_runtime_service.py | 68 +++- tests/test_ai_voice_tts_provider.py | 116 ++++++ ui/admin/app.js | 178 ++++++++++ ui/admin/index.html | 138 +++++++ 14 files changed, 1342 insertions(+), 32 deletions(-) create mode 100644 services/ai_voice_runtime_service/runtime_tts_provider.py create mode 100644 services/shared/voice_tts_config.py diff --git a/.env.example b/.env.example index 87490aa..3b104b5 100644 --- a/.env.example +++ b/.env.example @@ -77,6 +77,14 @@ AI_VOICE_TTS_YANDEX_SPEED=1.0 AI_VOICE_TTS_YANDEX_ROLE= AI_VOICE_TTS_YANDEX_EMOTION= AI_VOICE_TTS_YANDEX_SAMPLE_RATE_HZ=8000 +AI_VOICE_TTS_ELEVENLABS_API_BASE=https://api.elevenlabs.io +AI_VOICE_TTS_ELEVENLABS_API_KEY= +AI_VOICE_TTS_ELEVENLABS_MODEL_ID=eleven_v3 +AI_VOICE_TTS_ELEVENLABS_OUTPUT_FORMAT=pcm_16000 +AI_VOICE_TTS_ELEVENLABS_RU_VOICE_ID=nPczCjzI2devNBz1zQrb +AI_VOICE_TTS_ELEVENLABS_RU_LANGUAGE_CODE=ru +AI_VOICE_TTS_ELEVENLABS_KK_VOICE_ID=nPczCjzI2devNBz1zQrb +AI_VOICE_TTS_ELEVENLABS_KK_LANGUAGE_CODE=kk AI_VOICE_TTS_CACHE_ENABLED=1 AI_VOICE_TTS_CACHE_DIR=.data/ai_voice_tts_cache AI_VOICE_VAD_MIN_SPEECH_MS=300 diff --git a/.env.production.template b/.env.production.template index d1705fb..0b09249 100644 --- a/.env.production.template +++ b/.env.production.template @@ -73,6 +73,14 @@ AI_VOICE_TTS_YANDEX_ROLE= # backward-compatible alias for old v1-style config AI_VOICE_TTS_YANDEX_EMOTION= AI_VOICE_TTS_YANDEX_SAMPLE_RATE_HZ=8000 +AI_VOICE_TTS_ELEVENLABS_API_BASE=https://api.elevenlabs.io +AI_VOICE_TTS_ELEVENLABS_API_KEY= +AI_VOICE_TTS_ELEVENLABS_MODEL_ID=eleven_v3 +AI_VOICE_TTS_ELEVENLABS_OUTPUT_FORMAT=pcm_16000 +AI_VOICE_TTS_ELEVENLABS_RU_VOICE_ID=nPczCjzI2devNBz1zQrb +AI_VOICE_TTS_ELEVENLABS_RU_LANGUAGE_CODE=ru +AI_VOICE_TTS_ELEVENLABS_KK_VOICE_ID=nPczCjzI2devNBz1zQrb +AI_VOICE_TTS_ELEVENLABS_KK_LANGUAGE_CODE=kk AI_VOICE_TTS_CACHE_ENABLED=1 AI_VOICE_TTS_CACHE_DIR=/app/.data_local/ai_voice_tts_cache AI_VOICE_VAD_MIN_SPEECH_MS=300 diff --git a/services/ai_orchestrator_service/app.py b/services/ai_orchestrator_service/app.py index fbbb239..ee20bb7 100644 --- a/services/ai_orchestrator_service/app.py +++ b/services/ai_orchestrator_service/app.py @@ -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, diff --git a/services/ai_voice_runtime_service/app.py b/services/ai_voice_runtime_service/app.py index 10e848c..38cc760 100644 --- a/services/ai_voice_runtime_service/app.py +++ b/services/ai_voice_runtime_service/app.py @@ -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 diff --git a/services/ai_voice_runtime_service/providers/tts.py b/services/ai_voice_runtime_service/providers/tts.py index ef2c5bf..c62c3e9 100644 --- a/services/ai_voice_runtime_service/providers/tts.py +++ b/services/ai_voice_runtime_service/providers/tts.py @@ -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() diff --git a/services/ai_voice_runtime_service/runtime_tts_provider.py b/services/ai_voice_runtime_service/runtime_tts_provider.py new file mode 100644 index 0000000..8f2e7a2 --- /dev/null +++ b/services/ai_voice_runtime_service/runtime_tts_provider.py @@ -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) diff --git a/services/shared/models.py b/services/shared/models.py index 95304af..b6739f8 100644 --- a/services/shared/models.py +++ b/services/shared/models.py @@ -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 diff --git a/services/shared/sql_models.py b/services/shared/sql_models.py index e15eee1..6baf172 100644 --- a/services/shared/sql_models.py +++ b/services/shared/sql_models.py @@ -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" diff --git a/services/shared/voice_tts_config.py b/services/shared/voice_tts_config.py new file mode 100644 index 0000000..e253577 --- /dev/null +++ b/services/shared/voice_tts_config.py @@ -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 diff --git a/tests/test_ai_orchestrator_service.py b/tests/test_ai_orchestrator_service.py index ba45d78..ea7ba7d 100644 --- a/tests/test_ai_orchestrator_service.py +++ b/tests/test_ai_orchestrator_service.py @@ -12,6 +12,7 @@ ai_module = importlib.import_module("services.ai_orchestrator_service.app") ai_app = ai_module.app voice_module = importlib.import_module("services.ai_orchestrator_service.voice") voice_config_module = importlib.import_module("services.ai_orchestrator_service.voice_name_config") +voice_tts_config_module = importlib.import_module("services.shared.voice_tts_config") from services.interaction_service.app import app as interaction_app from services.shared.core import new_id, utc_now_iso from services.shared.db import get_session @@ -30,6 +31,7 @@ from services.shared.sql_models import ( TelegramMessageRow, TelegramThreadRow, VoiceNameCollectionSettingsRow, + VoiceTTSSettingsRow, VoiceAISessionRow, WhatsAppThreadRow, ) @@ -66,6 +68,27 @@ def reset_voice_name_collection_settings(): session.close() +@pytest.fixture(autouse=True) +def reset_voice_tts_settings(): + session = get_session() + try: + row = session.execute(select(VoiceTTSSettingsRow)).scalar_one_or_none() + if row is not None: + session.delete(row) + session.commit() + finally: + session.close() + yield + session = get_session() + try: + row = session.execute(select(VoiceTTSSettingsRow)).scalar_one_or_none() + if row is not None: + session.delete(row) + session.commit() + finally: + session.close() + + def _u(value: str) -> str: return value.encode("ascii").decode("unicode_escape") @@ -2453,6 +2476,50 @@ def test_voice_name_collection_config_put_rejects_invalid_name_template(): assert response.status_code == 422 +def test_voice_tts_config_get_returns_defaults_when_not_persisted(): + ai_client = TestClient(ai_app) + + response = ai_client.get("/ai/voice/config/tts", headers=admin_headers()) + + assert response.status_code == 200 + payload = response.json() + assert payload["source"] == "defaults" + assert payload["updated_at"] is None + assert payload["config"]["provider"] == "yandex" + assert "elevenlabs" in payload["provider_options"] + assert payload["voice_options"]["elevenlabs"]["ru"][0]["label"] == "Brian" + + +def test_voice_tts_config_put_persists_custom_payload(): + ai_client = TestClient(ai_app) + payload = voice_tts_config_module.voice_tts_default_config().model_dump() + payload["provider"] = "elevenlabs" + payload["elevenlabs"]["ru"]["voice"] = "nPczCjzI2devNBz1zQrb" + payload["elevenlabs"]["ru"]["model_id"] = "eleven_v3" + payload["elevenlabs"]["kz"]["language_code"] = "kk" + + response = ai_client.put( + "/ai/voice/config/tts", + headers=admin_headers(), + json=payload, + ) + + assert response.status_code == 200 + saved = response.json() + assert saved["source"] == "database" + assert saved["config"]["provider"] == "elevenlabs" + assert saved["config"]["elevenlabs"]["ru"]["voice"] == "nPczCjzI2devNBz1zQrb" + assert saved["config"]["elevenlabs"]["ru"]["model_id"] == "eleven_v3" + + session = get_session() + try: + row = session.execute(select(VoiceTTSSettingsRow)).scalar_one() + assert "elevenlabs" in row.config_json + assert "nPczCjzI2devNBz1zQrb" in row.config_json + finally: + session.close() + + def test_voice_start_disabled_hands_off_without_prompt(): session = get_session() try: diff --git a/tests/test_ai_voice_runtime_service.py b/tests/test_ai_voice_runtime_service.py index bceb96a..440e8d7 100644 --- a/tests/test_ai_voice_runtime_service.py +++ b/tests/test_ai_voice_runtime_service.py @@ -9,13 +9,25 @@ import services.ai_voice_runtime_service.app as runtime_module from services.shared.core import utc_now_iso from services.shared.db import get_session from services.shared.models import VoiceAIStartIn -from services.shared.sql_models import VoiceAISessionRow, VoiceTranscriptSegmentRow +from services.shared.sql_models import VoiceAISessionRow, VoiceTranscriptSegmentRow, VoiceTTSSettingsRow +from services.shared.voice_tts_config import save_voice_tts_config, voice_tts_default_config def _admin_headers() -> dict[str, str]: return {"X-User": "admin", "X-Role": "admin"} +def _reset_voice_tts_settings() -> None: + session = get_session() + try: + row = session.execute(select(VoiceTTSSettingsRow)).scalar_one_or_none() + if row is not None: + session.delete(row) + session.commit() + finally: + session.close() + + def test_create_voice_ai_session_records_greeting_without_blocking_on_orchestrator(monkeypatch): allow_start = threading.Event() @@ -194,6 +206,60 @@ def test_create_voice_ai_session_records_greeting_without_blocking_on_orchestrat raise AssertionError("background voice-session start did not finish in time") +def test_create_voice_ai_session_uses_database_selected_tts_provider(monkeypatch): + _reset_voice_tts_settings() + + session = get_session() + try: + payload = voice_tts_default_config().model_dump() + payload["provider"] = "elevenlabs" + save_voice_tts_config(session, payload) + finally: + session.close() + + def _fake_orchestrator_request(method: str, path: str, *, payload=None, timeout=10.0): + assert method == "POST" + assert path.endswith("/start") + return { + "session_id": "ais_voice_tts_runtime", + "language": "ru", + "greeting_text": "Здравствуйте. Чем помочь?", + "disclosure_required": False, + } + + monkeypatch.setattr(runtime_module, "_orchestrator_request", _fake_orchestrator_request) + + client = TestClient(runtime_module.app) + response = client.post( + "/internal/voice-ai/sessions", + headers=_admin_headers(), + json={ + "call_id": "call_voice_runtime_tts_provider", + "linked_id": "linked_voice_runtime_tts_provider", + "interaction_id": "int_voice_runtime_tts_provider", + "queue_id": "que_voice_runtime_tts_provider", + "caller_number": "+77010000032", + "caller_name": "Runtime Caller", + "agent_profile": "voice_support", + "language_hint": "ru", + "handoff_queue_id": "que_voice_runtime_tts_provider", + "metadata": {"queue_code": "voice_lab_ai"}, + }, + ) + + assert response.status_code == 200 + + session = get_session() + try: + voice_session = session.execute( + select(VoiceAISessionRow).where(VoiceAISessionRow.call_id == "call_voice_runtime_tts_provider") + ).scalar_one() + assert voice_session.tts_provider == "elevenlabs" + finally: + session.close() + _reset_voice_tts_settings() + + def test_mark_reply_delivered_finalizes_only_matching_pending_assistant_segment(): now = utc_now_iso() session = get_session() diff --git a/tests/test_ai_voice_tts_provider.py b/tests/test_ai_voice_tts_provider.py index 4d231c2..d7fdabb 100644 --- a/tests/test_ai_voice_tts_provider.py +++ b/tests/test_ai_voice_tts_provider.py @@ -3,7 +3,38 @@ from __future__ import annotations import base64 from pathlib import Path +import pytest +from sqlalchemy import select + +from services.ai_voice_runtime_service.runtime_tts_provider import RuntimeConfiguredTTSProvider from services.ai_voice_runtime_service.providers import tts as tts_module +from services.shared.db import get_session +from services.shared.sql_init import init_sql_schema +from services.shared.sql_models import VoiceTTSSettingsRow +from services.shared.voice_tts_config import save_voice_tts_config, voice_tts_default_config + +init_sql_schema() + + +@pytest.fixture(autouse=True) +def reset_voice_tts_settings(): + session = get_session() + try: + row = session.execute(select(VoiceTTSSettingsRow)).scalar_one_or_none() + if row is not None: + session.delete(row) + session.commit() + finally: + session.close() + yield + session = get_session() + try: + row = session.execute(select(VoiceTTSSettingsRow)).scalar_one_or_none() + if row is not None: + session.delete(row) + session.commit() + finally: + session.close() class _DummyResponse: @@ -252,3 +283,88 @@ def test_yandex_tts_provider_uses_cached_audio_without_credentials(tmp_path, mon assert replay.audio_bytes == cached.audio_bytes assert len(calls) == 1 + + +def test_elevenlabs_tts_provider_posts_voice_id_model_and_language_code(tmp_path, monkeypatch): + calls: list[dict] = [] + + class _DummyClient: + def __init__(self, *, timeout: float) -> None: + self.timeout = timeout + + def __enter__(self) -> _DummyClient: + return self + + def __exit__(self, exc_type, exc, tb) -> None: + return None + + def post(self, url: str, *, headers: dict[str, str], params: dict[str, str], json: dict) -> _DummyResponse: + calls.append({"url": url, "headers": headers, "params": params, "json": json, "timeout": self.timeout}) + return _DummyResponse(b"\x01\x00\x02\x00") + + monkeypatch.setenv("AI_VOICE_TTS_ELEVENLABS_API_KEY", "elevenlabs-key") + monkeypatch.setenv("AI_VOICE_TTS_CACHE_ENABLED", "0") + monkeypatch.setattr(tts_module.httpx, "Client", _DummyClient) + + provider = tts_module.ElevenLabsTTSProvider( + api_base="https://api.elevenlabs.example", + ru_voice="nPczCjzI2devNBz1zQrb", + kz_voice="nPczCjzI2devNBz1zQrb", + ru_model="eleven_v3", + kz_model="eleven_v3", + ru_language_code="ru", + kz_language_code="kk", + output_format="pcm_16000", + ) + synthesis = provider.synthesize("Привет из ElevenLabs", language="kz") + + assert synthesis.audio_bytes == b"\x01\x00\x02\x00" + assert synthesis.sample_rate_hz == 16000 + assert len(calls) == 1 + assert calls[0]["url"] == "https://api.elevenlabs.example/v1/text-to-speech/nPczCjzI2devNBz1zQrb" + assert calls[0]["headers"]["xi-api-key"] == "elevenlabs-key" + assert calls[0]["params"]["output_format"] == "pcm_16000" + assert calls[0]["json"]["model_id"] == "eleven_v3" + assert calls[0]["json"]["language_code"] == "kk" + + +def test_runtime_configured_tts_provider_uses_database_selected_provider(monkeypatch): + calls: list[dict] = [] + + class _DummyClient: + def __init__(self, *, timeout: float) -> None: + self.timeout = timeout + + def __enter__(self) -> _DummyClient: + return self + + def __exit__(self, exc_type, exc, tb) -> None: + return None + + def post(self, url: str, *, headers: dict[str, str], params: dict[str, str], json: dict) -> _DummyResponse: + calls.append({"url": url, "headers": headers, "params": params, "json": json, "timeout": self.timeout}) + return _DummyResponse(b"\x10\x00\x20\x00") + + monkeypatch.setenv("AI_VOICE_TTS_ELEVENLABS_API_KEY", "elevenlabs-key") + monkeypatch.setenv("AI_VOICE_TTS_CACHE_ENABLED", "0") + monkeypatch.setattr(tts_module.httpx, "Client", _DummyClient) + + session = get_session() + try: + payload = voice_tts_default_config().model_dump() + payload["provider"] = "elevenlabs" + payload["elevenlabs"]["ru"]["voice"] = "nPczCjzI2devNBz1zQrb" + payload["elevenlabs"]["kz"]["voice"] = "nPczCjzI2devNBz1zQrb" + payload["elevenlabs"]["ru"]["model_id"] = "eleven_v3" + payload["elevenlabs"]["kz"]["model_id"] = "eleven_v3" + save_voice_tts_config(session, payload) + finally: + session.close() + + provider = RuntimeConfiguredTTSProvider(default_provider_name="yandex") + synthesis = provider.synthesize("Сәлем", language="kz") + + assert provider.current_provider_name() == "elevenlabs" + assert synthesis.audio_bytes == b"\x10\x00\x20\x00" + assert len(calls) == 1 + assert calls[0]["json"]["language_code"] == "kk" diff --git a/ui/admin/app.js b/ui/admin/app.js index 12bc4b6..93f6a6d 100644 --- a/ui/admin/app.js +++ b/ui/admin/app.js @@ -11,6 +11,7 @@ const state = { providerLabel: 'Keycloak', }, voiceNameConfig: null, + voiceTtsConfig: null, }; const $ = (id) => document.getElementById(id); @@ -650,6 +651,172 @@ function resetVoiceNameConfigForm() { log('Форма voice name collection сброшена к текущим настройкам'); } +function voiceTtsProviderConfig(config, provider) { + return config?.[provider] || { ru: {}, kz: {} }; +} + +function voiceTtsOptionsFor(provider, language) { + return state.voiceTtsConfig?.voice_options?.[provider]?.[language] || []; +} + +function fillVoiceTtsPresetSelect(selectId, options, selectedValue) { + const select = $(selectId); + if (!select) { + return; + } + const rows = [''].concat( + (options || []).map((item) => ``), + ); + select.innerHTML = rows.join(''); + const hasSelectedValue = (options || []).some((item) => item.value === selectedValue); + select.value = hasSelectedValue ? selectedValue : ''; +} + +function syncVoiceTtsPresetOptions(payload) { + const config = payload?.config || payload || state.voiceTtsConfig?.config; + if (!config) { + return; + } + fillVoiceTtsPresetSelect('voiceTtsYandexRuVoicePreset', voiceTtsOptionsFor('yandex', 'ru'), config.yandex?.ru?.voice || ''); + fillVoiceTtsPresetSelect('voiceTtsYandexKzVoicePreset', voiceTtsOptionsFor('yandex', 'kz'), config.yandex?.kz?.voice || ''); + fillVoiceTtsPresetSelect('voiceTtsElevenRuVoicePreset', voiceTtsOptionsFor('elevenlabs', 'ru'), config.elevenlabs?.ru?.voice || ''); + fillVoiceTtsPresetSelect('voiceTtsElevenKzVoicePreset', voiceTtsOptionsFor('elevenlabs', 'kz'), config.elevenlabs?.kz?.voice || ''); + fillVoiceTtsPresetSelect('voiceTtsOpenAiRuVoicePreset', voiceTtsOptionsFor('openai', 'ru'), config.openai?.ru?.voice || ''); + fillVoiceTtsPresetSelect('voiceTtsOpenAiKzVoicePreset', voiceTtsOptionsFor('openai', 'kz'), config.openai?.kz?.voice || ''); +} + +function serializeVoiceTtsConfigForm() { + return { + provider: $('voiceTtsProvider').value || 'yandex', + yandex: { + ru: { voice: $('voiceTtsYandexRuVoice').value.trim() || null }, + kz: { voice: $('voiceTtsYandexKzVoice').value.trim() || null }, + }, + elevenlabs: { + ru: { + voice: $('voiceTtsElevenRuVoice').value.trim() || null, + model_id: $('voiceTtsElevenRuModel').value.trim() || null, + language_code: $('voiceTtsElevenRuLanguageCode').value.trim() || null, + }, + kz: { + voice: $('voiceTtsElevenKzVoice').value.trim() || null, + model_id: $('voiceTtsElevenKzModel').value.trim() || null, + language_code: $('voiceTtsElevenKzLanguageCode').value.trim() || null, + }, + }, + openai: { + ru: { + voice: $('voiceTtsOpenAiRuVoice').value.trim() || null, + model_id: $('voiceTtsOpenAiRuModel').value.trim() || null, + language_code: $('voiceTtsOpenAiRuLanguageCode').value.trim() || null, + }, + kz: { + voice: $('voiceTtsOpenAiKzVoice').value.trim() || null, + model_id: $('voiceTtsOpenAiKzModel').value.trim() || null, + language_code: $('voiceTtsOpenAiKzLanguageCode').value.trim() || null, + }, + }, + }; +} + +function syncVoiceTtsConfigFields(payload) { + const config = payload?.config || payload; + if (!config) { + return; + } + $('voiceTtsProvider').value = config.provider || 'yandex'; + $('voiceTtsYandexRuVoice').value = config.yandex?.ru?.voice || ''; + $('voiceTtsYandexKzVoice').value = config.yandex?.kz?.voice || ''; + $('voiceTtsElevenRuVoice').value = config.elevenlabs?.ru?.voice || ''; + $('voiceTtsElevenRuModel').value = config.elevenlabs?.ru?.model_id || ''; + $('voiceTtsElevenRuLanguageCode').value = config.elevenlabs?.ru?.language_code || ''; + $('voiceTtsElevenKzVoice').value = config.elevenlabs?.kz?.voice || ''; + $('voiceTtsElevenKzModel').value = config.elevenlabs?.kz?.model_id || ''; + $('voiceTtsElevenKzLanguageCode').value = config.elevenlabs?.kz?.language_code || ''; + $('voiceTtsOpenAiRuVoice').value = config.openai?.ru?.voice || ''; + $('voiceTtsOpenAiRuModel').value = config.openai?.ru?.model_id || ''; + $('voiceTtsOpenAiRuLanguageCode').value = config.openai?.ru?.language_code || ''; + $('voiceTtsOpenAiKzVoice').value = config.openai?.kz?.voice || ''; + $('voiceTtsOpenAiKzModel').value = config.openai?.kz?.model_id || ''; + $('voiceTtsOpenAiKzLanguageCode').value = config.openai?.kz?.language_code || ''; + syncVoiceTtsPresetOptions(payload); +} + +function renderVoiceTtsConfigSummary(payload) { + const config = payload?.config || payload; + const source = payload?.source || 'defaults'; + const updatedAt = payload?.updated_at || 'не сохранялось'; + const activeProvider = config?.provider || 'yandex'; + const activeSettings = voiceTtsProviderConfig(config, activeProvider); + $('voiceTtsSummary').textContent = [ + `Активный провайдер: ${activeProvider}`, + `RU голос: ${activeSettings?.ru?.voice || 'не задан'}`, + `RU модель: ${activeSettings?.ru?.model_id || 'по умолчанию'}`, + `RU language_code: ${activeSettings?.ru?.language_code || 'по умолчанию'}`, + `KZ голос: ${activeSettings?.kz?.voice || 'не задан'}`, + `KZ модель: ${activeSettings?.kz?.model_id || 'по умолчанию'}`, + `KZ language_code: ${activeSettings?.kz?.language_code || 'по умолчанию'}`, + `Источник конфигурации: ${source}`, + `Последнее обновление: ${updatedAt}`, + ].join('\n'); +} + +async function loadVoiceTtsConfig() { + try { + const data = await api('ai', 'ai/voice/config/tts'); + state.voiceTtsConfig = data; + syncVoiceTtsConfigFields(data); + renderVoiceTtsConfigSummary(data); + $('voiceTtsConfigOutput').textContent = JSON.stringify(data, null, 2); + log('Настройки voice TTS загружены', { source: data.source, updated_at: data.updated_at }); + } catch (err) { + $('voiceTtsConfigOutput').textContent = err.message; + log('Не удалось загрузить настройки voice TTS', { error: err.message }); + } +} + +async function saveVoiceTtsConfig() { + try { + const payload = serializeVoiceTtsConfigForm(); + const data = await api('ai', 'ai/voice/config/tts', { + method: 'PUT', + body: JSON.stringify(payload), + }); + state.voiceTtsConfig = data; + syncVoiceTtsConfigFields(data); + renderVoiceTtsConfigSummary(data); + $('voiceTtsConfigOutput').textContent = JSON.stringify(data, null, 2); + log('Настройки voice TTS сохранены', { provider: data?.config?.provider, updated_at: data.updated_at }); + } catch (err) { + $('voiceTtsConfigOutput').textContent = err.message; + log('Не удалось сохранить настройки voice TTS', { error: err.message }); + } +} + +function resetVoiceTtsConfigForm() { + const snapshot = state.voiceTtsConfig; + if (!snapshot) { + $('voiceTtsConfigOutput').textContent = 'Сначала загрузите текущие настройки voice TTS.'; + return; + } + syncVoiceTtsConfigFields(snapshot); + renderVoiceTtsConfigSummary(snapshot); + $('voiceTtsConfigOutput').textContent = JSON.stringify(snapshot, null, 2); + log('Форма voice TTS сброшена к текущим настройкам'); +} + +function bindVoiceTtsPreset(selectId, inputId) { + const select = $(selectId); + if (!select) { + return; + } + select.addEventListener('change', () => { + if (select.value) { + $(inputId).value = select.value; + } + }); +} + function parseIvrFlowJson() { ensureDefaultIvrFlowJson(); return JSON.parse($('ivrFlowJson').value || '{}'); @@ -1177,6 +1344,7 @@ function wire() { loadQueues(), loadIvrFlows(), loadVoiceNameConfig(), + loadVoiceTtsConfig(), loadAsteriskStatus(), loadAsteriskEvents(), ]); @@ -1204,6 +1372,15 @@ function wire() { $('loadVoiceNameConfigBtn').addEventListener('click', loadVoiceNameConfig); $('saveVoiceNameConfigBtn').addEventListener('click', saveVoiceNameConfig); $('resetVoiceNameConfigBtn').addEventListener('click', resetVoiceNameConfigForm); + $('loadVoiceTtsConfigBtn').addEventListener('click', loadVoiceTtsConfig); + $('saveVoiceTtsConfigBtn').addEventListener('click', saveVoiceTtsConfig); + $('resetVoiceTtsConfigBtn').addEventListener('click', resetVoiceTtsConfigForm); + bindVoiceTtsPreset('voiceTtsYandexRuVoicePreset', 'voiceTtsYandexRuVoice'); + bindVoiceTtsPreset('voiceTtsYandexKzVoicePreset', 'voiceTtsYandexKzVoice'); + bindVoiceTtsPreset('voiceTtsElevenRuVoicePreset', 'voiceTtsElevenRuVoice'); + bindVoiceTtsPreset('voiceTtsElevenKzVoicePreset', 'voiceTtsElevenKzVoice'); + bindVoiceTtsPreset('voiceTtsOpenAiRuVoicePreset', 'voiceTtsOpenAiRuVoice'); + bindVoiceTtsPreset('voiceTtsOpenAiKzVoicePreset', 'voiceTtsOpenAiKzVoice'); $('loadAsteriskStatusBtn').addEventListener('click', loadAsteriskStatus); $('loadAsteriskEventsBtn').addEventListener('click', loadAsteriskEvents); $('loadAsteriskEventDetailBtn').addEventListener('click', loadAsteriskEventDetail); @@ -1231,6 +1408,7 @@ async function init() { loadQueues(), loadIvrFlows(), loadVoiceNameConfig(), + loadVoiceTtsConfig(), loadAsteriskStatus(), loadAsteriskEvents(), ]); diff --git a/ui/admin/index.html b/ui/admin/index.html index 68742f3..dd12ad9 100644 --- a/ui/admin/index.html +++ b/ui/admin/index.html @@ -36,6 +36,7 @@ Маршрутизация IVR Voice AI + Voice TTS Asterisk @@ -326,6 +327,143 @@
Raw payload voice name collection появится здесь.
+
+

Voice AI: TTS

+

Выберите провайдера и голоса для RU и KZ. Для ElevenLabs в поле голоса хранится voice ID, для Brian уже есть готовый preset.

+
Сводка по voice TTS появится здесь.
+ +
+ +
RU и KZ можно настроить отдельно, а провайдер переключать из админки без правки env.
+
+ +
+ +
+ + +
+
+ + +
+
+ +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + + +
+
Raw payload voice TTS появится здесь.
+
+

Мост Asterisk

Диагностика AMI-подключения, пересланных bridge-событий и ручного повтора.