Skip to content
Merged
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
43 changes: 32 additions & 11 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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:
Expand All @@ -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: {}
"""
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
38 changes: 38 additions & 0 deletions backend/services/edge_tts_client.py
Original file line number Diff line number Diff line change
@@ -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
60 changes: 60 additions & 0 deletions backend/services/elevenlabs_client.py
Original file line number Diff line number Diff line change
@@ -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
Loading