diff --git a/.env.example b/.env.example index a1532db..ee93410 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,12 @@ WHISPER_COMPUTE_TYPE=int8 # TTS voice TTS_VOICE=es-ES-AlvaroNeural +# TTS Configuration +TTS_PRIMARY_PROVIDER=microsoft # Set to "elevenlabs" for ElevenLabs (requires API key + voice ID) +ELEVENLABS_API_KEY= # ElevenLabs API key (required if TTS_PRIMARY_PROVIDER=elevenlabs) +ELEVENLABS_VOICE_ID= # ElevenLabs voice ID (required if TTS_PRIMARY_PROVIDER=elevenlabs) +TTS_ELEVENLABS_TIMEOUT=15 # Timeout for ElevenLabs API calls in seconds + # CORS origins (comma-separated for production) CORS_ORIGINS=http://localhost:8000,https://tudominio.com diff --git a/backend/config.py b/backend/config.py index c1d3b7a..0ed9f7f 100644 --- a/backend/config.py +++ b/backend/config.py @@ -23,6 +23,10 @@ def __init__(self): # TTS settings self.TTS_VOICE: str = os.getenv("TTS_VOICE", "es-ES-AlvaroNeural") + self.TTS_PRIMARY_PROVIDER: str = os.getenv("TTS_PRIMARY_PROVIDER", "microsoft") + self.ELEVENLABS_API_KEY: str = os.getenv("ELEVENLABS_API_KEY", "") + self.ELEVENLABS_VOICE_ID: str = os.getenv("ELEVENLABS_VOICE_ID", "") + self.TTS_ELEVENLABS_TIMEOUT: float = float(os.getenv("TTS_ELEVENLABS_TIMEOUT", "15")) # LLM settings self.LLM_MODEL: str = os.getenv("LLM_MODEL", "openrouter/owl-alpha") diff --git a/backend/services/tts.py b/backend/services/tts.py index f3a73f1..09dc3b9 100644 --- a/backend/services/tts.py +++ b/backend/services/tts.py @@ -1,35 +1,94 @@ -"""Text-to-Speech service using a pluggable TTS client (Edge TTS).""" +"""Text-to-Speech orchestration with per-conversation provider fallback. +The service owns two TTS client implementations behind one ``TTSClient`` +protocol: Microsoft Edge TTS (default) and ElevenLabs (opt-in). Provider +selection is decided per conversation on its first synthesis; a failed primary +provider pins that conversation to Microsoft for its lifetime. +""" + +import asyncio import logging import uuid from pathlib import Path +from typing import Protocol from backend.services.edge_tts_client import EdgeTTSClient +from backend.services.elevenlabs_client import ElevenLabsClient, ElevenLabsError logger = logging.getLogger(__name__) -class TTSService: - """TTS wrapper that delegates synthesis to an EdgeTTSClient.""" +class TTSClient(Protocol): + """Structural type for a TTS backend client.""" + + async def synthesize(self, text: str, output_path: Path) -> Path: + """Synthesize text to an audio file at ``output_path``.""" + ... + - def __init__(self, voice: str = "en-US-GuyNeural", output_dir: str | Path = "audio"): +class TTSService: + """TTS orchestrator with provider selection, pinning, and fallback. + + Provider selection: + - ``primary_provider="microsoft"`` (default): Microsoft only; ElevenLabs + is never called even when credentials are present. + - ``primary_provider="elevenlabs"`` with both credentials set: ElevenLabs + is attempted first for each conversation. + - ``primary_provider="elevenlabs"`` with missing credentials: forced to + Microsoft; ElevenLabs client is not created. + + Per-conversation pinning: + - The first synthesis of a conversation picks the primary provider. + - On success the conversation is pinned to that provider. + - On failure (or timeout) the conversation is pinned to Microsoft and + the fallback is used for the same call; later calls never retry the + failed primary for that conversation. + - ``conversation_id=None`` (direct callers) performs a per-call fallback + without recording any pinning state. + """ + + def __init__(self, voice: str = "es-ES-AlvaroNeural", output_dir: str | Path = "audio", + primary_provider: str = "microsoft", elevenlabs_api_key: str = "", + elevenlabs_voice_id: str = "", elevenlabs_timeout: float = 15.0): self.voice = voice self.output_dir = Path(output_dir) self.output_dir.mkdir(parents=True, exist_ok=True) - self._client = EdgeTTSClient(voice) - - async def synthesize(self, text: str, output_path: str | Path | None = None) -> Path: - """Convert text to speech audio file. + self.primary_provider = primary_provider + self.elevenlabs_timeout = elevenlabs_timeout + self._conversation_providers: dict[str, str] = {} + + # ElevenLabs is enabled only when configured as primary AND fully + # credentialed. A misconfigured primary degrades to Microsoft. + self._elevenlabs: ElevenLabsClient | None = None + if primary_provider == "elevenlabs": + if elevenlabs_api_key and elevenlabs_voice_id: + self._elevenlabs = ElevenLabsClient( + api_key=elevenlabs_api_key, + voice_id=elevenlabs_voice_id, + timeout=elevenlabs_timeout, + ) + else: + logger.warning( + "TTS_PRIMARY_PROVIDER=elevenlabs but ELEVENLABS_API_KEY/VOICE_ID missing; " + "falling back to Microsoft" + ) + self._edge: TTSClient = EdgeTTSClient(voice) + + async def synthesize(self, text: str, output_path: str | Path | None = None, + conversation_id: str | None = None) -> tuple[Path, str]: + """Convert text to an audio file and report the provider that produced it. Args: text: Text to synthesize. output_path: Optional custom output path. If None, generates a UUID-based name. + conversation_id: Optional conversation id used for provider pinning. Returns: - Path to the generated audio file. + Tuple of (path to the generated audio file, provider name). Raises: - RuntimeError: If synthesis fails. + ValueError: If text is empty. + RuntimeError: If synthesis fails on every available provider. """ if not text or not text.strip(): raise ValueError("Cannot synthesize empty text") @@ -41,17 +100,66 @@ async def synthesize(self, text: str, output_path: str | Path | None = None) -> output_path = Path(output_path) output_path.parent.mkdir(parents=True, exist_ok=True) - await self._client.synthesize(text, output_path) - return output_path + provider = self._select_provider(conversation_id) + + if provider == "elevenlabs": + try: + # Per-request timeout: a timeout counts as provider failure. + await asyncio.wait_for( + self._elevenlabs.synthesize(text, output_path), + timeout=self.elevenlabs_timeout, + ) + self._pin(conversation_id, "elevenlabs") + return output_path, "elevenlabs" + except (ElevenLabsError, asyncio.TimeoutError) as e: + logger.warning( + "ElevenLabs synthesis failed (conversation=%s): %s — " + "falling back to Microsoft", conversation_id, e + ) + self._pin(conversation_id, "microsoft") + provider = "microsoft" + + # Microsoft path: primary microsoft, pinned fallback, or EL failure above. + await self._edge.synthesize(text, output_path) + self._pin(conversation_id, provider) + return output_path, provider async def synthesize_sentence(self, text: str, sentence_id: int, - output_dir: Path | None = None) -> tuple[int, Path]: - """Synthesize a single sentence for parallel streaming. Returns (id, path).""" + output_dir: Path | None = None, + conversation_id: str | None = None) -> tuple[int, Path, str]: + """Synthesize a single sentence for parallel streaming. + + Returns: + Tuple of (sentence_id, audio path, provider name). + """ out_dir = output_dir or self.output_dir filename = f"sentence_{sentence_id}_{uuid.uuid4().hex}.mp3" output_path = out_dir / filename - await self.synthesize(text, output_path=output_path) - return sentence_id, output_path + _, provider = await self.synthesize( + text, output_path=output_path, conversation_id=conversation_id + ) + return sentence_id, output_path, provider + + def forget_conversation(self, conversation_id: str) -> None: + """Evict a conversation's provider pinning state (idempotent).""" + self._conversation_providers.pop(conversation_id, None) + + def _select_provider(self, conversation_id: str | None) -> str: + """Resolve the provider for a call. + + A pinned conversation always uses its pinned provider; unpinned + conversations use the configured primary. + """ + if conversation_id is not None: + pinned = self._conversation_providers.get(conversation_id) + if pinned is not None: + return pinned + return self.primary_provider if self._elevenlabs is not None else "microsoft" + + def _pin(self, conversation_id: str | None, provider: str) -> None: + """Record a conversation's provider (no-op when conversation_id is None).""" + if conversation_id is not None: + self._conversation_providers[conversation_id] = provider def get_audio_url(self, audio_path: Path) -> str: """Convert an audio file path to a URL path for serving. @@ -62,4 +170,4 @@ def get_audio_url(self, audio_path: Path) -> str: Returns: URL path like /audio/{filename}. """ - return f"/audio/{audio_path.name}" + return f"/audio/{audio_path.name}" \ No newline at end of file diff --git a/tests/test_config.py b/tests/test_config.py index a9173b0..6dc97d8 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -76,6 +76,35 @@ def test_config_paths(): assert cfg.FRONTEND_DIR.name == "frontend" +def test_tts_provider_defaults(monkeypatch): + """TTS provider config defaults to Microsoft with empty ElevenLabs credentials.""" + monkeypatch.delenv("TTS_PRIMARY_PROVIDER", raising=False) + monkeypatch.delenv("ELEVENLABS_API_KEY", raising=False) + monkeypatch.delenv("ELEVENLABS_VOICE_ID", raising=False) + monkeypatch.delenv("TTS_ELEVENLABS_TIMEOUT", raising=False) + with patch.dict(os.environ, {}, clear=False): + cfg = Config() + assert cfg.TTS_PRIMARY_PROVIDER == "microsoft" + assert cfg.ELEVENLABS_API_KEY == "" + assert cfg.ELEVENLABS_VOICE_ID == "" + assert cfg.TTS_ELEVENLABS_TIMEOUT == 15 + + +def test_tts_provider_env_override(): + """TTS provider config respects environment variable overrides.""" + with patch.dict(os.environ, { + "TTS_PRIMARY_PROVIDER": "elevenlabs", + "ELEVENLABS_API_KEY": "test-key", + "ELEVENLABS_VOICE_ID": "test-voice", + "TTS_ELEVENLABS_TIMEOUT": "30", + }): + cfg = Config() + assert cfg.TTS_PRIMARY_PROVIDER == "elevenlabs" + assert cfg.ELEVENLABS_API_KEY == "test-key" + assert cfg.ELEVENLABS_VOICE_ID == "test-voice" + assert cfg.TTS_ELEVENLABS_TIMEOUT == 30 + + def test_httpx_is_main_dependency(): """httpx is a runtime dependency, not only a dev extra.""" from pathlib import Path diff --git a/tests/test_tts.py b/tests/test_tts.py index 61ec8d7..cfc168d 100644 --- a/tests/test_tts.py +++ b/tests/test_tts.py @@ -1,5 +1,6 @@ """Tests for TTS service with mocked Edge TTS.""" +import asyncio import httpx import pytest from pathlib import Path @@ -45,14 +46,15 @@ async def fake_save(path): mock_communicate_cls.return_value = mock_communicate svc = TTSService(output_dir="test_audio_out") - result = await svc.synthesize("Hello, I am Mikel.") + result_path, provider = await svc.synthesize("Hello, I am Mikel.") - assert result.exists() - assert result.suffix == ".mp3" + assert result_path.exists() + assert result_path.suffix == ".mp3" + assert provider == "microsoft" mock_communicate.save.assert_called_once() # Cleanup - result.unlink() + result_path.unlink() svc.output_dir.rmdir() @pytest.mark.asyncio @@ -68,13 +70,14 @@ async def fake_save(path): svc = TTSService(output_dir="test_audio_out") custom_path = svc.output_dir / "custom_response.mp3" - result = await svc.synthesize("Hello", output_path=custom_path) + result_path, provider = await svc.synthesize("Hello", output_path=custom_path) - assert result == custom_path - assert result.exists() + assert result_path == custom_path + assert result_path.exists() + assert provider == "microsoft" # Cleanup - result.unlink() + result_path.unlink() svc.output_dir.rmdir() @pytest.mark.asyncio @@ -203,3 +206,236 @@ async def test_synthesize_timeout(self, mock_client_cls): client = ElevenLabsClient(api_key="test-key", voice_id="test-voice", timeout=15) with pytest.raises(ElevenLabsError): await client.synthesize("Hola", Path("test_audio_el/out.mp3")) + + +class TestTTSServiceOrchestrator: + """Tests for provider selection, per-conversation fallback, and pinning.""" + + def _make_service(self, **kwargs): + defaults = dict(primary_provider="elevenlabs", elevenlabs_api_key="test-key", + elevenlabs_voice_id="test-voice", output_dir="test_audio_orch") + defaults.update(kwargs) + return TTSService(**defaults) + + def test_default_voice_is_alvaro(self): + """Design default voice for the orchestrator is es-ES-AlvaroNeural.""" + svc = TTSService(output_dir="test_audio_orch") + assert svc.voice == "es-ES-AlvaroNeural" + if svc.output_dir.exists(): + svc.output_dir.rmdir() + + @pytest.mark.asyncio + async def test_missing_creds_skips_elevenlabs(self): + """primary=elevenlabs without API key forces Microsoft; EL client is never created.""" + svc = TTSService(primary_provider="elevenlabs", elevenlabs_api_key="", + elevenlabs_voice_id="test-voice", output_dir="test_audio_orch") + assert svc._elevenlabs is None + + async def fake_save(text, output_path): + Path(output_path).touch() + + mock_edge = AsyncMock() + mock_edge.synthesize = AsyncMock(side_effect=fake_save) + svc._edge = mock_edge + + out_path = svc.output_dir / "missing_creds.mp3" + path, provider = await svc.synthesize("Hola", output_path=out_path, + conversation_id="conv-a") + assert provider == "microsoft" + assert path == out_path + assert path.exists() + + out_path.unlink() + svc.output_dir.rmdir() + + @pytest.mark.asyncio + async def test_primary_microsoft_never_tries_elevenlabs(self): + """provider=microsoft means ElevenLabs is never called even with creds present.""" + svc = TTSService(primary_provider="microsoft", elevenlabs_api_key="test-key", + elevenlabs_voice_id="test-voice", output_dir="test_audio_orch") + assert svc._elevenlabs is None + + async def fake_save(text, output_path): + Path(output_path).touch() + + mock_edge = AsyncMock() + mock_edge.synthesize = AsyncMock(side_effect=fake_save) + svc._edge = mock_edge + + out_path = svc.output_dir / "ms_only.mp3" + path, provider = await svc.synthesize("Hola", output_path=out_path, + conversation_id="conv-a") + assert provider == "microsoft" + assert path.exists() + + out_path.unlink() + svc.output_dir.rmdir() + + @pytest.mark.asyncio + async def test_elevenlabs_success_pins_conversation(self): + """Successful EL call returns provider elevenlabs and pins the conversation.""" + svc = self._make_service() + mock_el = AsyncMock() + mock_el.synthesize = AsyncMock(return_value=Path("ignored")) + svc._elevenlabs = mock_el + mock_edge = AsyncMock() + svc._edge = mock_edge + + out_path = svc.output_dir / "el_ok.mp3" + path, provider = await svc.synthesize("Hola", output_path=out_path, + conversation_id="conv-a") + assert provider == "elevenlabs" + assert path == out_path + mock_el.synthesize.assert_awaited_once_with("Hola", out_path) + # Pinned: a second call for the same conversation uses EL again + await svc.synthesize("Otra vez", output_path=out_path, conversation_id="conv-a") + assert mock_el.synthesize.await_count == 2 + assert mock_edge.synthesize.await_count == 0 + + svc.output_dir.rmdir() + + @pytest.mark.asyncio + async def test_elevenlabs_failure_falls_back_and_pins_microsoft(self): + """EL failure triggers MS for the same call and pins the conversation to MS.""" + svc = self._make_service() + mock_el = AsyncMock() + mock_el.synthesize = AsyncMock(side_effect=ElevenLabsError("boom")) + svc._elevenlabs = mock_el + mock_edge = AsyncMock() + mock_edge.synthesize = AsyncMock(return_value=Path("ignored")) + svc._edge = mock_edge + + out_path = svc.output_dir / "el_fail.mp3" + path, provider = await svc.synthesize("Hola", output_path=out_path, + conversation_id="conv-a") + assert provider == "microsoft" + assert path == out_path + mock_edge.synthesize.assert_awaited_once() + # Pinned: the next call for the same conversation never retries EL + await svc.synthesize("Otra vez", output_path=out_path, conversation_id="conv-a") + assert mock_el.synthesize.await_count == 1 + assert mock_edge.synthesize.await_count == 2 + + svc.output_dir.rmdir() + + @pytest.mark.asyncio + async def test_conversation_isolation(self): + """Failure in conversation A does not affect conversation B's provider.""" + svc = self._make_service() + mock_el = AsyncMock() + mock_el.synthesize = AsyncMock( + side_effect=[ElevenLabsError("boom"), Path("ignored-b")] + ) + svc._elevenlabs = mock_el + mock_edge = AsyncMock() + mock_edge.synthesize = AsyncMock(return_value=Path("ignored")) + svc._edge = mock_edge + + out_a = svc.output_dir / "iso_a.mp3" + out_b = svc.output_dir / "iso_b.mp3" + + path_a, provider_a = await svc.synthesize("A", output_path=out_a, + conversation_id="conv-a") + assert provider_a == "microsoft" + + path_b, provider_b = await svc.synthesize("B", output_path=out_b, + conversation_id="conv-b") + assert provider_b == "elevenlabs" + assert path_b == out_b + assert mock_el.synthesize.await_count == 2 # conv B tried EL again + + svc.output_dir.rmdir() + + @pytest.mark.asyncio + async def test_slow_elevenlabs_times_out_and_falls_back(self): + """EL exceeding the timeout is treated as provider failure -> MS fallback.""" + svc = self._make_service(elevenlabs_timeout=0.05) + + async def slow(text, output_path): + await asyncio.sleep(0.5) + return output_path + + mock_el = AsyncMock() + mock_el.synthesize = AsyncMock(side_effect=slow) + svc._elevenlabs = mock_el + mock_edge = AsyncMock() + mock_edge.synthesize = AsyncMock(return_value=Path("ignored")) + svc._edge = mock_edge + + out_path = svc.output_dir / "timeout.mp3" + path, provider = await svc.synthesize("Hola", output_path=out_path, + conversation_id="conv-a") + assert provider == "microsoft" + assert path == out_path + mock_edge.synthesize.assert_awaited_once() + # Pinned after timeout: next call does not retry EL + await svc.synthesize("Otra vez", output_path=out_path, conversation_id="conv-a") + assert mock_el.synthesize.await_count == 1 + + svc.output_dir.rmdir() + + @pytest.mark.asyncio + async def test_both_fail_raises_runtime_error(self): + """When EL and MS both fail, RuntimeError propagates.""" + svc = self._make_service() + mock_el = AsyncMock() + mock_el.synthesize = AsyncMock(side_effect=ElevenLabsError("boom")) + svc._elevenlabs = mock_el + mock_edge = AsyncMock() + mock_edge.synthesize = AsyncMock(side_effect=RuntimeError("Could not synthesize")) + svc._edge = mock_edge + + with pytest.raises(RuntimeError, match="Could not synthesize"): + await svc.synthesize("Hola", conversation_id="conv-a") + + svc.output_dir.rmdir() + + @pytest.mark.asyncio + async def test_no_conversation_id_skips_pinning(self): + """Direct callers without conversation_id get per-call provider, no pinning.""" + svc = self._make_service() + mock_el = AsyncMock() + mock_el.synthesize = AsyncMock(return_value=Path("ignored")) + svc._elevenlabs = mock_el + mock_edge = AsyncMock() + svc._edge = mock_edge + + out_path = svc.output_dir / "nopin.mp3" + path, provider = await svc.synthesize("Hola", output_path=out_path) + assert provider == "elevenlabs" + assert svc._conversation_providers == {} + + svc.output_dir.rmdir() + + @pytest.mark.asyncio + async def test_synthesize_sentence_returns_provider_triple(self): + """synthesize_sentence returns (sentence_id, path, provider).""" + svc = TTSService(output_dir="test_audio_orch") + mock_edge = AsyncMock() + mock_edge.synthesize = AsyncMock(return_value=Path("ignored")) + svc._edge = mock_edge + + sid, path, provider = await svc.synthesize_sentence( + "Hola", 3, output_dir=svc.output_dir, conversation_id="conv-a" + ) + assert sid == 3 + assert path.suffix == ".mp3" + assert provider == "microsoft" + assert svc._conversation_providers["conv-a"] == "microsoft" + + svc.output_dir.rmdir() + + def test_forget_conversation_idempotent(self): + """forget_conversation removes state and tolerates unknown ids.""" + svc = TTSService(output_dir="test_audio_orch") + svc._conversation_providers["conv-a"] = "microsoft" + svc._conversation_providers["conv-b"] = "elevenlabs" + + svc.forget_conversation("conv-a") + assert svc._conversation_providers == {"conv-b": "elevenlabs"} + # Idempotent: forgetting the same or unknown ids is a no-op + svc.forget_conversation("conv-a") + svc.forget_conversation("never-existed") + assert svc._conversation_providers == {"conv-b": "elevenlabs"} + + svc.output_dir.rmdir()