Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions backend/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
142 changes: 125 additions & 17 deletions backend/services/tts.py
Original file line number Diff line number Diff line change
@@ -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")
Expand All @@ -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.
Expand All @@ -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}"
29 changes: 29 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading