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: def __init__(self, content: bytes = b"", json_payload: dict | None = None) -> None: self.content = content self._json_payload = json_payload or {} def raise_for_status(self) -> None: return None def json(self) -> dict: return self._json_payload def test_openai_tts_provider_persists_cache_on_disk(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], json: dict[str, str]) -> _DummyResponse: calls.append({"url": url, "headers": headers, "json": json, "timeout": self.timeout}) return _DummyResponse(b"\x01\x00\x02\x00") monkeypatch.setenv("AI_API_KEY", "test-key") monkeypatch.setenv("AI_API_BASE", "https://example.test/v1") monkeypatch.setenv("AI_VOICE_TTS_MODEL", "gpt-4o-mini-tts") monkeypatch.setenv("AI_VOICE_TTS_VOICE", "alloy") monkeypatch.setenv("AI_VOICE_TTS_CACHE_ENABLED", "1") monkeypatch.setenv("AI_VOICE_TTS_CACHE_DIR", str(tmp_path)) monkeypatch.setattr(tts_module.httpx, "Client", _DummyClient) provider = tts_module.OpenAITTSProvider() first = provider.synthesize("Hello from cache", language="ru") second = provider.synthesize("Hello from cache", language="ru") assert first.audio_bytes == b"\x01\x00\x02\x00" assert second.audio_bytes == first.audio_bytes assert len(calls) == 1 assert list(Path(tmp_path).rglob("*.pcm")) assert list(Path(tmp_path).rglob("*.json")) def test_openai_tts_provider_uses_cached_audio_without_api_key(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], json: dict[str, str]) -> _DummyResponse: calls.append({"url": url, "headers": headers, "json": json, "timeout": self.timeout}) return _DummyResponse(b"\x04\x00\x08\x00") monkeypatch.setenv("AI_API_KEY", "test-key") monkeypatch.setenv("AI_VOICE_TTS_CACHE_ENABLED", "1") monkeypatch.setenv("AI_VOICE_TTS_CACHE_DIR", str(tmp_path)) monkeypatch.setattr(tts_module.httpx, "Client", _DummyClient) provider = tts_module.OpenAITTSProvider() cached = provider.synthesize("Server-side cached prompt", language="ru") assert cached.audio_bytes == b"\x04\x00\x08\x00" assert len(calls) == 1 monkeypatch.delenv("AI_API_KEY", raising=False) def _unexpected_client(*args, **kwargs): raise AssertionError("OpenAI TTS should not be called when cached audio already exists") monkeypatch.setattr(tts_module.httpx, "Client", _unexpected_client) provider_without_key = tts_module.OpenAITTSProvider() replay = provider_without_key.synthesize("Server-side cached prompt", language="ru") assert replay.audio_bytes == cached.audio_bytes assert len(calls) == 1 def test_yandex_tts_provider_posts_lpcm_with_api_key(tmp_path, monkeypatch): calls: list[dict] = [] encoded_audio = base64.b64encode(b"\x10\x00\x20\x00").decode("ascii") 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], json: dict) -> _DummyResponse: calls.append({"url": url, "headers": headers, "json": json, "timeout": self.timeout}) return _DummyResponse(json_payload={"result": {"audioChunk": {"data": encoded_audio}}}) monkeypatch.setenv("AI_VOICE_TTS_YANDEX_API_KEY", "yandex-test-key") monkeypatch.setenv("AI_VOICE_TTS_YANDEX_API_BASE", "https://tts.example.test") monkeypatch.setenv("AI_VOICE_TTS_YANDEX_VOICE", "jane") monkeypatch.setenv("AI_VOICE_TTS_YANDEX_SAMPLE_RATE_HZ", "8000") monkeypatch.setenv("AI_VOICE_TTS_CACHE_ENABLED", "1") monkeypatch.setenv("AI_VOICE_TTS_CACHE_DIR", str(tmp_path)) monkeypatch.setattr(tts_module.httpx, "Client", _DummyClient) provider = tts_module.YandexTTSProvider() synthesis = provider.synthesize("Привет из Yandex TTS", language="ru") assert synthesis.audio_bytes == b"\x10\x00\x20\x00" assert synthesis.sample_rate_hz == 8000 assert len(calls) == 1 assert calls[0]["url"] == "https://tts.example.test/tts/v3/utteranceSynthesis" assert calls[0]["headers"]["Authorization"] == "Api-Key yandex-test-key" assert calls[0]["headers"]["Accept"] == "application/json" assert calls[0]["json"]["hints"][0]["voice"] == "jane" assert calls[0]["json"]["hints"][1]["speed"] == 1.1 assert calls[0]["json"]["outputAudioSpec"]["rawAudio"]["audioEncoding"] == "LINEAR16_PCM" assert calls[0]["json"]["outputAudioSpec"]["rawAudio"]["sampleRateHertz"] == 8000 assert list(Path(tmp_path).rglob("*.pcm")) assert list(Path(tmp_path).rglob("*.json")) def test_yandex_tts_provider_prefers_per_utterance_role_hint_over_global_role(tmp_path, monkeypatch): calls: list[dict] = [] encoded_audio = base64.b64encode(b"\x12\x00\x34\x00").decode("ascii") 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], json: dict) -> _DummyResponse: calls.append({"url": url, "headers": headers, "json": json, "timeout": self.timeout}) return _DummyResponse(json_payload={"result": {"audioChunk": {"data": encoded_audio}}}) monkeypatch.setenv("AI_VOICE_TTS_YANDEX_API_KEY", "yandex-test-key") monkeypatch.setenv("AI_VOICE_TTS_YANDEX_API_BASE", "https://tts.example.test") monkeypatch.setenv("AI_VOICE_TTS_YANDEX_VOICE", "jane") monkeypatch.setenv("AI_VOICE_TTS_YANDEX_ROLE", "neutral") monkeypatch.setenv("AI_VOICE_TTS_CACHE_ENABLED", "0") monkeypatch.setattr(tts_module.httpx, "Client", _DummyClient) provider = tts_module.YandexTTSProvider() synthesis = provider.synthesize("Эмоциональный быстрый отклик", language="ru", style_hints={"role": "good"}) assert synthesis.audio_bytes == b"\x12\x00\x34\x00" assert len(calls) == 1 assert calls[0]["json"]["hints"][0]["voice"] == "jane" assert calls[0]["json"]["hints"][2]["role"] == "good" def test_yandex_tts_provider_uses_iam_token_with_folder_id(tmp_path, monkeypatch): calls: list[dict] = [] encoded_audio = base64.b64encode(b"\x30\x00\x40\x00").decode("ascii") 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], json: dict) -> _DummyResponse: calls.append({"url": url, "headers": headers, "json": json, "timeout": self.timeout}) return _DummyResponse(json_payload={"audioChunk": {"data": encoded_audio}}) monkeypatch.delenv("AI_VOICE_TTS_YANDEX_API_KEY", raising=False) monkeypatch.setenv("AI_VOICE_TTS_YANDEX_IAM_TOKEN", "iam-test-token") monkeypatch.setenv("AI_VOICE_TTS_YANDEX_FOLDER_ID", "folder-test") monkeypatch.setenv("AI_VOICE_TTS_YANDEX_SAMPLE_RATE_HZ", "16000") monkeypatch.setenv("AI_VOICE_TTS_YANDEX_ROLE", "friendly") monkeypatch.setenv("AI_VOICE_TTS_CACHE_ENABLED", "0") monkeypatch.setattr(tts_module.httpx, "Client", _DummyClient) provider = tts_module.YandexTTSProvider() synthesis = provider.synthesize("Kazakh prompt", language="kk") assert synthesis.audio_bytes == b"\x30\x00\x40\x00" assert synthesis.sample_rate_hz == 16000 assert len(calls) == 1 assert calls[0]["headers"]["Authorization"] == "Bearer iam-test-token" assert calls[0]["headers"]["x-folder-id"] == "folder-test" assert calls[0]["json"]["hints"][0]["voice"] == "amira" assert calls[0]["json"]["hints"][2]["role"] == "friendly" assert calls[0]["json"]["outputAudioSpec"]["rawAudio"]["sampleRateHertz"] == 16000 def test_yandex_tts_provider_uses_cached_audio_without_credentials(tmp_path, monkeypatch): calls: list[dict] = [] encoded_audio = base64.b64encode(b"\x55\x00\x66\x00").decode("ascii") 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], json: dict) -> _DummyResponse: calls.append({"url": url, "headers": headers, "json": json, "timeout": self.timeout}) return _DummyResponse(json_payload={"result": {"audioChunk": {"data": encoded_audio}}}) monkeypatch.setenv("AI_VOICE_TTS_YANDEX_API_KEY", "yandex-test-key") monkeypatch.setenv("AI_VOICE_TTS_CACHE_ENABLED", "1") monkeypatch.setenv("AI_VOICE_TTS_CACHE_DIR", str(tmp_path)) monkeypatch.setattr(tts_module.httpx, "Client", _DummyClient) provider = tts_module.YandexTTSProvider() cached = provider.synthesize("Server-side cached yandex prompt", language="ru") assert cached.audio_bytes == b"\x55\x00\x66\x00" assert len(calls) == 1 monkeypatch.delenv("AI_VOICE_TTS_YANDEX_API_KEY", raising=False) monkeypatch.delenv("AI_VOICE_TTS_YANDEX_IAM_TOKEN", raising=False) def _unexpected_client(*args, **kwargs): raise AssertionError("Yandex TTS should not be called when cached audio already exists") monkeypatch.setattr(tts_module.httpx, "Client", _unexpected_client) provider_without_credentials = tts_module.YandexTTSProvider() replay = provider_without_credentials.synthesize("Server-side cached yandex prompt", language="ru") 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_elevenlabs_tts_provider_streams_chunks_and_caches_full_audio(tmp_path, monkeypatch): calls: list[dict] = [] class _DummyStreamResponse: def __init__(self, chunks: list[bytes]) -> None: self._chunks = chunks def raise_for_status(self) -> None: return None def iter_bytes(self): yield from self._chunks class _DummyStreamContext: def __init__(self, response: _DummyStreamResponse) -> None: self._response = response def __enter__(self) -> _DummyStreamResponse: return self._response def __exit__(self, exc_type, exc, tb) -> None: return None 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 stream(self, method: str, url: str, *, headers: dict[str, str], params: dict[str, str], json: dict): calls.append({"method": method, "url": url, "headers": headers, "params": params, "json": json}) # Split mid-sample on purpose to exercise the 2-byte alignment guard. return _DummyStreamContext(_DummyStreamResponse([b"\x01\x00\x02", b"\x00\x03\x00\x04\x00"])) monkeypatch.setenv("AI_VOICE_TTS_ELEVENLABS_API_KEY", "elevenlabs-key") monkeypatch.setenv("AI_VOICE_TTS_CACHE_ENABLED", "1") monkeypatch.setenv("AI_VOICE_TTS_CACHE_DIR", str(tmp_path)) monkeypatch.setattr(tts_module.httpx, "Client", _DummyClient) provider = tts_module.ElevenLabsTTSProvider( api_base="https://api.elevenlabs.example", ru_voice="nPczCjzI2devNBz1zQrb", output_format="pcm_16000", ) chunks = list(provider.synthesize_chunks("Привет из ElevenLabs", language="ru")) assert b"".join(chunk.audio_bytes for chunk in chunks) == b"\x01\x00\x02\x00\x03\x00\x04\x00" assert all(len(chunk.audio_bytes) % 2 == 0 for chunk in chunks) assert len(calls) == 1 assert calls[0]["url"] == "https://api.elevenlabs.example/v1/text-to-speech/nPczCjzI2devNBz1zQrb/stream" assert list(Path(tmp_path).rglob("*.pcm")) def _unexpected_stream(*args, **kwargs): raise AssertionError("ElevenLabs streaming TTS should not be called again once cached") monkeypatch.setattr(_DummyClient, "stream", _unexpected_stream) replay_provider = tts_module.ElevenLabsTTSProvider( api_base="https://api.elevenlabs.example", ru_voice="nPczCjzI2devNBz1zQrb", output_format="pcm_16000", ) replay_chunks = list(replay_provider.synthesize_chunks("Привет из ElevenLabs", language="ru")) assert len(replay_chunks) == 1 assert replay_chunks[0].audio_bytes == b"\x01\x00\x02\x00\x03\x00\x04\x00" assert len(calls) == 1 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"