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/main.py b/backend/main.py index de07e93..701eb13 100644 --- a/backend/main.py +++ b/backend/main.py @@ -89,6 +89,10 @@ def detect_farewell(text: str) -> bool: tts_service = TTSService( voice=config.TTS_VOICE, output_dir=config.AUDIO_DIR, + primary_provider=config.TTS_PRIMARY_PROVIDER, + elevenlabs_api_key=config.ELEVENLABS_API_KEY, + elevenlabs_voice_id=config.ELEVENLABS_VOICE_ID, + elevenlabs_timeout=config.TTS_ELEVENLABS_TIMEOUT, ) rag_pipeline = RAGPipeline( chunk_size=config.CHUNK_SIZE, @@ -159,6 +163,22 @@ def cleanup_stale_audio(): logger.info("Cleaned up stale audio: %s", f.name) +def evict_stale_conversations(cutoff: datetime) -> list: + """Evict conversations idle before ``cutoff`` and forget their TTS pinning. + + Returns the list of evicted conversation ids. + """ + stale_ids = [ + cid for cid, c in conversations.items() + if datetime.fromisoformat(c.get("last_activity_at", "")) < cutoff + ] + for cid in stale_ids: + logger.debug("Evicting stale conversation: %s", cid) + del conversations[cid] + tts_service.forget_conversation(cid) + return stale_ids + + async def periodic_cleanup(interval_seconds: int) -> None: """Periodic background task: evict stale conversations, prune rate-limit store, clean audio.""" from datetime import datetime, timedelta @@ -168,13 +188,7 @@ async def periodic_cleanup(interval_seconds: int) -> None: while True: try: cutoff = datetime.utcnow() - timedelta(hours=config.SESSION_TTL_HOURS) - stale_ids = [ - cid for cid, c in conversations.items() - if datetime.fromisoformat(c.get("last_activity_at", "")) < cutoff - ] - for cid in stale_ids: - logger.debug("Evicting stale conversation: %s", cid) - del conversations[cid] + evict_stale_conversations(cutoff) except Exception as e: logger.error("Conversation eviction failed: %s", e) try: @@ -422,7 +436,11 @@ async def send_message(conversation_id: str, audio: UploadFile = File(...)): output_audio = config.AUDIO_DIR / f"{conversation_id}/{message_id}.mp3" try: clean_text = sanitize_for_tts(response_text) - audio_path = await tts_service.synthesize(clean_text, output_path=output_audio) + audio_path, tts_provider = await tts_service.synthesize( + clean_text, + output_path=output_audio, + conversation_id=conversation_id, + ) except RuntimeError as e: raise HTTPException(status_code=503, detail=f"TTS synthesis failed: {e}") _t.append(time.time()) @@ -458,6 +476,7 @@ async def send_message(conversation_id: str, audio: UploadFile = File(...)): "user_text": user_text, "response_text": response_text, "audio_url": f"/audio/{conversation_id}/{message_id}.mp3", + "tts_provider": tts_provider, } finally: @@ -473,7 +492,7 @@ async def send_message_stream(conversation_id: str, audio: UploadFile = File(... Events: - transcription: {"text": "..."} - token: {"text": "..."} (one per LLM chunk) - - audio_url: {"url": "..."} + - audio_chunk: {"id": ..., "url": "...", "provider": "elevenlabs"|"microsoft"} - error: {"detail": "..."} - done: {} """ @@ -602,6 +621,7 @@ def run_llm_stream(): tts_service.synthesize_sentence( clean_sentence, sentence_id, output_dir=config.AUDIO_DIR / conversation_id, + conversation_id=conversation_id, ) ) tts_futures[task] = sentence_id @@ -613,10 +633,11 @@ def run_llm_stream(): else: # A TTS task completed — yield the audio chunk immediately try: - sid, audio_path = done.result() + sid, audio_path, tts_provider = done.result() yield sse_format("audio_chunk", { "id": sid, - "url": f"/audio/{conversation_id}/{audio_path.name}" + "url": f"/audio/{conversation_id}/{audio_path.name}", + "provider": tts_provider, }) except Exception as e: logger.error("TTS task %s failed: %s", done, e) diff --git a/backend/services/edge_tts_client.py b/backend/services/edge_tts_client.py new file mode 100644 index 0000000..5d57e4e --- /dev/null +++ b/backend/services/edge_tts_client.py @@ -0,0 +1,38 @@ +"""Edge TTS client — wraps Microsoft Edge text-to-speech synthesis.""" + +import logging +from pathlib import Path + +import edge_tts + +logger = logging.getLogger(__name__) + + +class EdgeTTSClient: + """Synthesize speech through Microsoft Edge TTS.""" + + def __init__(self, voice: str): + self.voice = voice + + async def synthesize(self, text: str, output_path: Path) -> Path: + """Convert text to an audio file at the given output path. + + Args: + text: Text to synthesize. + output_path: Destination path for the generated audio file. + + Returns: + The output path. + + Raises: + RuntimeError: If synthesis fails. + """ + try: + communicate = edge_tts.Communicate(text, self.voice) + await communicate.save(str(output_path)) + logger.info("Synthesized %d chars -> %s", len(text), output_path.name) + return output_path + + except Exception as e: + logger.error("TTS synthesis failed: %s", e) + raise RuntimeError(f"Could not synthesize speech: {e}") from e diff --git a/backend/services/elevenlabs_client.py b/backend/services/elevenlabs_client.py new file mode 100644 index 0000000..5fe4fa0 --- /dev/null +++ b/backend/services/elevenlabs_client.py @@ -0,0 +1,60 @@ +"""ElevenLabs text-to-speech client (raw HTTP via httpx).""" + +import logging +from pathlib import Path + +import httpx + +logger = logging.getLogger(__name__) + +ELEVENLABS_API_URL = "https://api.elevenlabs.io/v1/text-to-speech" +DEFAULT_MODEL_ID = "eleven_multilingual_v2" + + +class ElevenLabsError(Exception): + """Raised when ElevenLabs synthesis fails (HTTP error, timeout, or IO).""" + + +class ElevenLabsClient: + """Synthesize speech through the ElevenLabs text-to-speech API.""" + + def __init__(self, api_key: str, voice_id: str, timeout: float = 15.0, + model_id: str = DEFAULT_MODEL_ID): + self.api_key = api_key + self.voice_id = voice_id + self.timeout = timeout + self.model_id = model_id + + async def synthesize(self, text: str, output_path: Path) -> Path: + """Synthesize text to audio and write it to output_path. + + Args: + text: Text to synthesize. + output_path: Destination path for the generated audio file. + + Returns: + The output path. + + Raises: + ElevenLabsError: If the API returns an error status or the request fails. + """ + url = f"{ELEVENLABS_API_URL}/{self.voice_id}" + headers = {"xi-api-key": self.api_key, "Content-Type": "application/json"} + payload = {"text": text, "model_id": self.model_id} + + try: + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.post(url, headers=headers, json=payload) + + if response.status_code != 200: + raise ElevenLabsError( + f"ElevenLabs API error {response.status_code}: {response.text[:200]}" + ) + + output_path.write_bytes(response.content) + logger.info("Synthesized %d chars -> %s via ElevenLabs", len(text), output_path.name) + return output_path + + except httpx.HTTPError as e: + logger.error("ElevenLabs request failed: %s", e) + raise ElevenLabsError(f"ElevenLabs request failed: {e}") from e diff --git a/backend/services/tts.py b/backend/services/tts.py index e0721e9..09dc3b9 100644 --- a/backend/services/tts.py +++ b/backend/services/tts.py @@ -1,35 +1,94 @@ -"""Text-to-Speech service using 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 -import edge_tts +from backend.services.edge_tts_client import EdgeTTSClient +from backend.services.elevenlabs_client import ElevenLabsClient, ElevenLabsError logger = logging.getLogger(__name__) -class TTSService: - """Edge TTS wrapper for text-to-speech synthesis.""" +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) - - 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,24 +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) - try: - communicate = edge_tts.Communicate(text, self.voice) - await communicate.save(str(output_path)) - logger.info("Synthesized %d chars -> %s", len(text), output_path.name) - return output_path - - except Exception as e: - logger.error("TTS synthesis failed: %s", e) - raise RuntimeError(f"Could not synthesize speech: {e}") from e + 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. @@ -69,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/pyproject.toml b/pyproject.toml index 09c0943..6820af8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,13 +12,13 @@ dependencies = [ "pydub>=0.25.1", "numpy>=1.24.0", "python-multipart>=0.0.6", + "httpx>=0.25.0", ] [project.optional-dependencies] dev = [ "pytest>=7.4.0", "pytest-asyncio>=0.21.0", - "httpx>=0.25.0", ] [tool.pytest.ini_options] diff --git a/tests/test_api.py b/tests/test_api.py index 6730c8b..e1eae7c 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,5 +1,8 @@ """Tests for FastAPI endpoints with mocked services.""" +import shutil +from datetime import datetime, timedelta + import pytest from unittest.mock import MagicMock, patch, AsyncMock from pathlib import Path @@ -31,11 +34,11 @@ def mock_services(): mock_llm.generate.return_value = "I built InterviewTTS using Python and FastAPI." # TTS mock - async def mock_synthesize(text, output_path=None): + async def mock_synthesize(text, output_path=None, conversation_id=None): path = output_path or Path("audio/test.mp3") path.parent.mkdir(parents=True, exist_ok=True) path.touch() - return path + return path, "microsoft" mock_tts.synthesize = mock_synthesize # Profile mock @@ -255,12 +258,12 @@ def test_tts_synthesis_error_emits_sse_and_continues(self, client, mock_services # Mock TTS synthesize_sentence to fail on first call, succeed on second call_count = [0] - async def fake_synth(text, sid, output_dir): + async def fake_synth(text, sid, output_dir, conversation_id=None): call_count[0] += 1 if call_count[0] == 1: raise RuntimeError("TTS failed") from pathlib import Path - return (sid, Path(f"audio/{conv_id}/sentence_{sid}.mp3")) + return (sid, Path(f"audio/{conv_id}/sentence_{sid}.mp3"), "microsoft") mock_services["tts"].synthesize_sentence = fake_synth @@ -296,7 +299,7 @@ def test_tts_result_exception_emits_error_and_continues(self, client, mock_servi [], ) - async def failing_synth(text, sid, output_dir): + async def failing_synth(text, sid, output_dir, conversation_id=None): raise RuntimeError("simulated TTS failure in task") mock_services["tts"].synthesize_sentence = failing_synth @@ -317,3 +320,253 @@ async def failing_synth(text, sid, output_dir): assert len(error_events) >= 1, "Expected at least one error event" assert len(done_events) >= 1, "Expected done event despite errors" + + +class TestTTSProviderFlag: + """Tests for provider flags in responses (spec: Provider Flag in Responses).""" + + def test_message_json_includes_tts_provider(self, client, mock_services): + """Non-streaming POST /message returns tts_provider in JSON body.""" + conv_response = client.post("/api/conversation") + conversation_id = conv_response.json()["conversation_id"] + + response = client.post( + f"/api/conversation/{conversation_id}/message", + files={"audio": ("test.webm", b"audio data", "audio/webm")}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["tts_provider"] == "microsoft" + + def test_stream_audio_chunk_includes_provider(self, client, mock_services): + """Streaming audio_chunk events carry the provider that produced the audio.""" + import json + + conv = client.post("/api/conversation") + conv_id = conv.json()["conversation_id"] + + mock_services["llm"].generate_stream_with_context.return_value = ( + iter(["Hello world.", "How are you?"]), + [], + ) + + async def fake_synth(text, sid, output_dir, conversation_id=None): + return (sid, Path(f"audio/{conv_id}/sentence_{sid}.mp3"), "microsoft") + + mock_services["tts"].synthesize_sentence = fake_synth + + with client.stream( + "POST", + f"/api/conversation/{conv_id}/message/stream", + files={"audio": ("test.webm", b"audio data", "audio/webm")}, + ) as response: + assert response.status_code == 200 + events = [] + for line in response.iter_lines(): + if line and line.startswith("data: "): + events.append(json.loads(line[6:])) + + audio_chunk_events = [e for e in events if e.get("event") == "audio_chunk"] + assert len(audio_chunk_events) >= 1, "Expected at least one audio_chunk event" + for event in audio_chunk_events: + assert event["data"]["provider"] == "microsoft" + + +class TestTTSFallbackIntegration: + """Integration: transparent EL->MS fallback and both-fail 503 at the API layer. + + Uses a real TTSService with mocked clients (patch clients, not the service), + so the orchestrator's fallback/pinning logic runs end-to-end through main.py. + """ + + @staticmethod + def _make_service(**kwargs): + from backend.services.tts import TTSService + + defaults = dict( + primary_provider="elevenlabs", + elevenlabs_api_key="test-key", + elevenlabs_voice_id="test-voice", + output_dir="test_audio_api", + ) + defaults.update(kwargs) + return TTSService(**defaults) + + @staticmethod + def _cleanup(svc): + """Remove the output dir created by the service constructor.""" + if svc.output_dir.exists(): + shutil.rmtree(svc.output_dir, ignore_errors=True) + + @staticmethod + def _fail_elevenlabs(svc, error=None): + """Make the service's ElevenLabs client raise on every synthesize call.""" + from backend.services.elevenlabs_client import ElevenLabsError + + svc._elevenlabs = AsyncMock() + svc._elevenlabs.synthesize = AsyncMock( + side_effect=error or ElevenLabsError("EL down") + ) + + @staticmethod + def _succeed_edge(svc): + """Make the service's Microsoft edge client create the audio file.""" + async def fake_edge(text, output_path): + Path(output_path).parent.mkdir(parents=True, exist_ok=True) + Path(output_path).touch() + return output_path + + svc._edge = AsyncMock() + svc._edge.synthesize = AsyncMock(side_effect=fake_edge) + + @staticmethod + def _fail_edge(svc, error=None): + """Make the service's Microsoft edge client raise on synthesize.""" + svc._edge = AsyncMock() + svc._edge.synthesize = AsyncMock( + side_effect=error or RuntimeError("Could not synthesize") + ) + + def test_elevenlabs_failure_falls_back_to_microsoft(self, client, mock_services): + """EL failure is transparent: 200 with provider=microsoft.""" + svc = self._make_service() + self._fail_elevenlabs(svc) + self._succeed_edge(svc) + + with patch("backend.main.tts_service", svc): + conv_response = client.post("/api/conversation") + conversation_id = conv_response.json()["conversation_id"] + + response = client.post( + f"/api/conversation/{conversation_id}/message", + files={"audio": ("test.webm", b"audio data", "audio/webm")}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["tts_provider"] == "microsoft" + assert data["audio_url"].startswith("/audio/") + + self._cleanup(svc) + + def test_elevenlabs_failure_pins_conversation(self, client, mock_services): + """After EL fails, a second message in the same conversation skips EL.""" + svc = self._make_service() + self._fail_elevenlabs(svc) + self._succeed_edge(svc) + + with patch("backend.main.tts_service", svc): + conv_response = client.post("/api/conversation") + conversation_id = conv_response.json()["conversation_id"] + + first = client.post( + f"/api/conversation/{conversation_id}/message", + files={"audio": ("test.webm", b"audio data", "audio/webm")}, + ) + second = client.post( + f"/api/conversation/{conversation_id}/message", + files={"audio": ("test.webm", b"audio data", "audio/webm")}, + ) + + assert first.status_code == 200 + assert second.status_code == 200 + assert second.json()["tts_provider"] == "microsoft" + # Pinned: EL must not be retried on the second turn of the same conversation + assert svc._elevenlabs.synthesize.await_count == 1 + + self._cleanup(svc) + + def test_conversation_isolation(self, client, mock_services): + """A failed conversation does not pin others: a fresh conversation retries EL.""" + from backend.services.elevenlabs_client import ElevenLabsError + + svc = self._make_service() + # First call fails (conv A), second call succeeds (conv B). + # The orchestrator ignores EL's return value, so a plain Path suffices. + svc._elevenlabs = AsyncMock() + svc._elevenlabs.synthesize = AsyncMock( + side_effect=[ElevenLabsError("EL down"), Path("ignored-b")] + ) + self._succeed_edge(svc) + + with patch("backend.main.tts_service", svc): + conv_a = client.post("/api/conversation").json()["conversation_id"] + conv_b = client.post("/api/conversation").json()["conversation_id"] + + resp_a = client.post( + f"/api/conversation/{conv_a}/message", + files={"audio": ("test.webm", b"audio data", "audio/webm")}, + ) + resp_b = client.post( + f"/api/conversation/{conv_b}/message", + files={"audio": ("test.webm", b"audio data", "audio/webm")}, + ) + + assert resp_a.status_code == 200 + assert resp_a.json()["tts_provider"] == "microsoft" + assert resp_b.status_code == 200 + assert resp_b.json()["tts_provider"] == "elevenlabs" + assert svc._elevenlabs.synthesize.await_count == 2 # conv B retried EL + + self._cleanup(svc) + + def test_both_providers_fail_returns_503(self, client, mock_services): + """EL and MS both failing returns HTTP 503 with 'TTS synthesis failed'.""" + svc = self._make_service() + self._fail_elevenlabs(svc) + self._fail_edge(svc) + + with patch("backend.main.tts_service", svc): + conv_response = client.post("/api/conversation") + conversation_id = conv_response.json()["conversation_id"] + + response = client.post( + f"/api/conversation/{conversation_id}/message", + files={"audio": ("test.webm", b"audio data", "audio/webm")}, + ) + + assert response.status_code == 503 + assert "TTS synthesis failed" in response.json()["detail"] + + self._cleanup(svc) + + +class TestConversationEviction: + """Tests for TTS pinning eviction in periodic_cleanup.""" + + def test_eviction_forgets_conversation_pinning(self, client, mock_services): + """Evicting a stale conversation also forgets its TTS provider pinning.""" + from backend.main import conversations, evict_stale_conversations + + conv_response = client.post("/api/conversation") + conversation_id = conv_response.json()["conversation_id"] + + # Simulate a stale conversation: last activity 5h ago, cutoff 1h ago + conversations[conversation_id]["last_activity_at"] = ( + datetime.utcnow() - timedelta(hours=5) + ).isoformat() + cutoff = datetime.utcnow() - timedelta(hours=1) + + evicted = evict_stale_conversations(cutoff) + + assert conversation_id in evicted + assert conversation_id not in conversations + mock_services["tts"].forget_conversation.assert_called_once_with(conversation_id) + + def test_eviction_keeps_fresh_conversations(self, client, mock_services): + """Conversations active after the cutoff are not evicted nor forgotten.""" + from backend.main import conversations, evict_stale_conversations + + conv_response = client.post("/api/conversation") + conversation_id = conv_response.json()["conversation_id"] + + # Fresh: last activity now, cutoff 1h ago + conversations[conversation_id]["last_activity_at"] = datetime.utcnow().isoformat() + cutoff = datetime.utcnow() - timedelta(hours=1) + + evicted = evict_stale_conversations(cutoff) + + assert conversation_id not in evicted + assert conversation_id in conversations + mock_services["tts"].forget_conversation.assert_not_called() diff --git a/tests/test_config.py b/tests/test_config.py index b1f3285..6dc97d8 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -74,3 +74,41 @@ def test_config_paths(): assert cfg.CANDIDATE_DIR.name == "candidate" assert cfg.AUDIO_DIR.name == "audio" 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 + pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml" + content = pyproject.read_text() + main_block = content.split("[project.optional-dependencies]")[0] + assert "httpx>=0.25.0" in main_block diff --git a/tests/test_conversation_memory.py b/tests/test_conversation_memory.py index 3294a2b..1f5c8fb 100644 --- a/tests/test_conversation_memory.py +++ b/tests/test_conversation_memory.py @@ -306,10 +306,10 @@ def test_last_activity_at_updated_on_message(): mock_profile.profile_data = {"name": "Mikel"} mock_profile.documents = {} - async def mock_synth(text, output_path=None): + async def mock_synth(text, output_path=None, conversation_id=None): output_path.parent.mkdir(parents=True, exist_ok=True) output_path.touch() - return output_path + return output_path, "microsoft" mock_tts.synthesize = mock_synth client = TestClient(app) diff --git a/tests/test_tts.py b/tests/test_tts.py index b9716f1..cfc168d 100644 --- a/tests/test_tts.py +++ b/tests/test_tts.py @@ -1,9 +1,13 @@ """Tests for TTS service with mocked Edge TTS.""" +import asyncio +import httpx import pytest from pathlib import Path from unittest.mock import AsyncMock, patch, MagicMock +from backend.services.edge_tts_client import EdgeTTSClient +from backend.services.elevenlabs_client import ElevenLabsClient, ElevenLabsError from backend.services.tts import TTSService @@ -31,7 +35,7 @@ async def test_synthesize_empty_text(self): svc.output_dir.rmdir() @pytest.mark.asyncio - @patch("backend.services.tts.edge_tts.Communicate") + @patch("backend.services.edge_tts_client.edge_tts.Communicate") async def test_synthesize_success(self, mock_communicate_cls): """Synthesize creates audio file and returns path.""" # Make the mock actually create the file @@ -42,18 +46,19 @@ 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 - @patch("backend.services.tts.edge_tts.Communicate") + @patch("backend.services.edge_tts_client.edge_tts.Communicate") async def test_synthesize_custom_path(self, mock_communicate_cls): """Synthesize respects custom output path.""" async def fake_save(path): @@ -65,17 +70,18 @@ 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 - @patch("backend.services.tts.edge_tts.Communicate") + @patch("backend.services.edge_tts_client.edge_tts.Communicate") async def test_synthesize_failure(self, mock_communicate_cls): """Synthesize raises on Edge TTS failure.""" mock_communicate = MagicMock() @@ -97,3 +103,339 @@ def test_get_audio_url(self): assert url == "/audio/abc123.mp3" # Cleanup svc.output_dir.rmdir() + + +class TestEdgeTTSClient: + """Tests for the extracted EdgeTTSClient.""" + + def test_init(self): + """Client stores the configured voice.""" + client = EdgeTTSClient(voice="es-ES-AlvaroNeural") + assert client.voice == "es-ES-AlvaroNeural" + + @pytest.mark.asyncio + @patch("backend.services.edge_tts_client.edge_tts.Communicate") + async def test_synthesize_writes_file(self, mock_communicate_cls): + """Synthesize writes audio through edge_tts and returns the path.""" + async def fake_save(path): + Path(path).parent.mkdir(parents=True, exist_ok=True) + Path(path).touch() + mock_communicate = MagicMock() + mock_communicate.save = AsyncMock(side_effect=fake_save) + mock_communicate_cls.return_value = mock_communicate + + client = EdgeTTSClient(voice="es-ES-AlvaroNeural") + out = Path("test_audio_el/out.mp3") + result = await client.synthesize("Hola", out) + + assert result == out + assert out.exists() + mock_communicate.save.assert_called_once_with(str(out)) + + out.unlink() + out.parent.rmdir() + + @pytest.mark.asyncio + @patch("backend.services.edge_tts_client.edge_tts.Communicate") + async def test_synthesize_failure_raises_runtime_error(self, mock_communicate_cls): + """Synthesis failure is wrapped in RuntimeError.""" + mock_communicate = MagicMock() + mock_communicate.save = AsyncMock(side_effect=Exception("Network error")) + mock_communicate_cls.return_value = mock_communicate + + client = EdgeTTSClient(voice="es-ES-AlvaroNeural") + with pytest.raises(RuntimeError, match="Could not synthesize"): + await client.synthesize("Hola", Path("test_audio_el/out.mp3")) + + +class TestElevenLabsClient: + """Tests for ElevenLabsClient with mocked httpx transport.""" + + @pytest.mark.asyncio + @patch("backend.services.elevenlabs_client.httpx.AsyncClient") + async def test_synthesize_success(self, mock_client_cls): + """Successful response writes audio bytes and returns output path.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.content = b"ID3 audio-bytes" + mock_client = MagicMock() + mock_client.__aenter__.return_value = mock_client + mock_client.post = AsyncMock(return_value=mock_response) + mock_client_cls.return_value = mock_client + + client = ElevenLabsClient(api_key="test-key", voice_id="test-voice", timeout=15) + out_dir = Path("test_audio_el") + out_dir.mkdir(exist_ok=True) + out_path = out_dir / "el.mp3" + result = await client.synthesize("Hola, soy Mikel.", out_path) + + assert result == out_path + assert out_path.read_bytes() == b"ID3 audio-bytes" + # The request must target the voice-specific ElevenLabs endpoint + assert mock_client.post.call_args.args[0] == \ + "https://api.elevenlabs.io/v1/text-to-speech/test-voice" + + out_path.unlink() + out_dir.rmdir() + + @pytest.mark.asyncio + @patch("backend.services.elevenlabs_client.httpx.AsyncClient") + async def test_synthesize_http_error(self, mock_client_cls): + """Non-2xx status raises ElevenLabsError carrying the status code.""" + mock_response = MagicMock() + mock_response.status_code = 401 + mock_response.content = b"unauthorized" + mock_client = MagicMock() + mock_client.__aenter__.return_value = mock_client + mock_client.post = AsyncMock(return_value=mock_response) + mock_client_cls.return_value = mock_client + + client = ElevenLabsClient(api_key="test-key", voice_id="test-voice", timeout=15) + with pytest.raises(ElevenLabsError, match="401"): + await client.synthesize("Hola", Path("test_audio_el/out.mp3")) + + @pytest.mark.asyncio + @patch("backend.services.elevenlabs_client.httpx.AsyncClient") + async def test_synthesize_timeout(self, mock_client_cls): + """Transport timeout is wrapped in ElevenLabsError.""" + mock_client = MagicMock() + mock_client.__aenter__.return_value = mock_client + mock_client.post = AsyncMock(side_effect=httpx.TimeoutException("timed out")) + mock_client_cls.return_value = mock_client + + 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()