feat(voice): add admin-configurable elevenlabs tts
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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=<elevenlabs-api-key-optional>
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
+178
@@ -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 = ['<option value="">Свой вариант</option>'].concat(
|
||||
(options || []).map((item) => `<option value="${item.value}">${item.label}</option>`),
|
||||
);
|
||||
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(),
|
||||
]);
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
<a class="nav-link" href="#routes"><span class="nav-bullet"></span>Маршрутизация</a>
|
||||
<a class="nav-link" href="#ivr"><span class="nav-bullet"></span>IVR</a>
|
||||
<a class="nav-link" href="#voiceNameCollection"><span class="nav-bullet"></span>Voice AI</a>
|
||||
<a class="nav-link" href="#voiceTtsConfig"><span class="nav-bullet"></span>Voice TTS</a>
|
||||
<a class="nav-link" href="#asterisk"><span class="nav-bullet"></span>Asterisk</a>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -326,6 +327,143 @@
|
||||
<pre id="voiceNameConfigOutput" class="output">Raw payload voice name collection появится здесь.</pre>
|
||||
</article>
|
||||
|
||||
<article class="panel reveal" id="voiceTtsConfig">
|
||||
<h2>Voice AI: TTS</h2>
|
||||
<p class="hint">Выберите провайдера и голоса для RU и KZ. Для ElevenLabs в поле голоса хранится voice ID, для Brian уже есть готовый preset.</p>
|
||||
<pre id="voiceTtsSummary" class="output">Сводка по voice TTS появится здесь.</pre>
|
||||
|
||||
<div class="inline-form split-two">
|
||||
<label>
|
||||
Активный провайдер
|
||||
<select id="voiceTtsProvider">
|
||||
<option value="yandex">Yandex SpeechKit</option>
|
||||
<option value="elevenlabs">ElevenLabs</option>
|
||||
<option value="openai">OpenAI</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="hint">RU и KZ можно настроить отдельно, а провайдер переключать из админки без правки env.</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label>Yandex</label>
|
||||
<div class="inline-form split-two">
|
||||
<label>
|
||||
RU preset
|
||||
<select id="voiceTtsYandexRuVoicePreset"></select>
|
||||
</label>
|
||||
<label>
|
||||
RU voice
|
||||
<input id="voiceTtsYandexRuVoice" placeholder="jane" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="inline-form split-two">
|
||||
<label>
|
||||
KZ preset
|
||||
<select id="voiceTtsYandexKzVoicePreset"></select>
|
||||
</label>
|
||||
<label>
|
||||
KZ voice
|
||||
<input id="voiceTtsYandexKzVoice" placeholder="amira" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label>ElevenLabs</label>
|
||||
<div class="inline-form split-two">
|
||||
<label>
|
||||
RU preset
|
||||
<select id="voiceTtsElevenRuVoicePreset"></select>
|
||||
</label>
|
||||
<label>
|
||||
RU voice ID
|
||||
<input id="voiceTtsElevenRuVoice" placeholder="nPczCjzI2devNBz1zQrb" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="inline-form split-two">
|
||||
<label>
|
||||
RU model
|
||||
<input id="voiceTtsElevenRuModel" placeholder="eleven_v3" />
|
||||
</label>
|
||||
<label>
|
||||
RU language_code
|
||||
<input id="voiceTtsElevenRuLanguageCode" placeholder="ru" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="inline-form split-two">
|
||||
<label>
|
||||
KZ preset
|
||||
<select id="voiceTtsElevenKzVoicePreset"></select>
|
||||
</label>
|
||||
<label>
|
||||
KZ voice ID
|
||||
<input id="voiceTtsElevenKzVoice" placeholder="nPczCjzI2devNBz1zQrb" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="inline-form split-two">
|
||||
<label>
|
||||
KZ model
|
||||
<input id="voiceTtsElevenKzModel" placeholder="eleven_v3" />
|
||||
</label>
|
||||
<label>
|
||||
KZ language_code
|
||||
<input id="voiceTtsElevenKzLanguageCode" placeholder="kk" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label>OpenAI</label>
|
||||
<div class="inline-form split-two">
|
||||
<label>
|
||||
RU preset
|
||||
<select id="voiceTtsOpenAiRuVoicePreset"></select>
|
||||
</label>
|
||||
<label>
|
||||
RU voice
|
||||
<input id="voiceTtsOpenAiRuVoice" placeholder="alloy" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="inline-form split-two">
|
||||
<label>
|
||||
RU model
|
||||
<input id="voiceTtsOpenAiRuModel" placeholder="gpt-4o-mini-tts" />
|
||||
</label>
|
||||
<label>
|
||||
RU language_code
|
||||
<input id="voiceTtsOpenAiRuLanguageCode" placeholder="ru" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="inline-form split-two">
|
||||
<label>
|
||||
KZ preset
|
||||
<select id="voiceTtsOpenAiKzVoicePreset"></select>
|
||||
</label>
|
||||
<label>
|
||||
KZ voice
|
||||
<input id="voiceTtsOpenAiKzVoice" placeholder="alloy" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="inline-form split-two">
|
||||
<label>
|
||||
KZ model
|
||||
<input id="voiceTtsOpenAiKzModel" placeholder="gpt-4o-mini-tts" />
|
||||
</label>
|
||||
<label>
|
||||
KZ language_code
|
||||
<input id="voiceTtsOpenAiKzLanguageCode" placeholder="kz" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions compact-actions">
|
||||
<button id="loadVoiceTtsConfigBtn" class="btn ghost">Загрузить настройки</button>
|
||||
<button id="saveVoiceTtsConfigBtn" class="btn">Сохранить настройки</button>
|
||||
<button id="resetVoiceTtsConfigBtn" class="btn ghost">Сбросить форму к текущим</button>
|
||||
</div>
|
||||
<pre id="voiceTtsConfigOutput" class="output">Raw payload voice TTS появится здесь.</pre>
|
||||
</article>
|
||||
|
||||
<article class="panel reveal" id="asterisk">
|
||||
<h2>Мост Asterisk</h2>
|
||||
<p class="hint">Диагностика AMI-подключения, пересланных bridge-событий и ручного повтора.</p>
|
||||
|
||||
Reference in New Issue
Block a user