import re import secrets import time import uuid from pathlib import Path from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import FileResponse from pydantic import BaseModel, Field from src.auth.dependencies import get_current_user from src.db.models import User from src.speech.speech import TextToSpeechService from src.utils.config import settings from src.utils.logger import setup_logger logger = setup_logger(__name__) router = APIRouter(prefix="/tts", tags=["Text to Speech"]) MAX_TTS_CHARS = 20000 TTS_TTL_SECONDS = 24 * 60 * 60 PLAYBACK_TOKEN_TTL_SECONDS = 60 * 60 playback_tokens: dict[str, dict[str, str | float]] = {} class TextToSpeechRequest(BaseModel): text: str = Field(..., min_length=1) title: str | None = None rate: str = "-10%" class TextToSpeechResponse(BaseModel): audioId: str audioUrl: str def _safe_user_id(user_id: str | None) -> str: value = user_id or "unknown" return re.sub(r"[^A-Za-z0-9_-]", "_", value) def _user_tts_dir_for_user_id(user_id: str | None) -> Path: user_dir = settings.output_dir / "tts" / _safe_user_id(user_id) user_dir.mkdir(parents=True, exist_ok=True) return user_dir def _user_tts_dir(user: User) -> Path: return _user_tts_dir_for_user_id(user.id) def _create_playback_token(audio_id: str, user_id: str | None) -> str: token = secrets.token_urlsafe(32) playback_tokens[token] = { "audio_id": audio_id, "user_id": user_id or "", "expires_at": time.time() + PLAYBACK_TOKEN_TTL_SECONDS, } return token def _resolve_playback_token(audio_id: str, token: str | None) -> str: if not token: raise HTTPException(status_code=401, detail="Playback token is required.") token_data = playback_tokens.get(token) if not token_data: raise HTTPException(status_code=401, detail="Invalid playback token.") if float(token_data["expires_at"]) < time.time(): playback_tokens.pop(token, None) raise HTTPException(status_code=401, detail="Playback token expired.") if token_data["audio_id"] != audio_id: raise HTTPException(status_code=403, detail="Playback token mismatch.") return str(token_data["user_id"]) def _cleanup_old_tts_files(root: Path) -> None: cutoff = time.time() - TTS_TTL_SECONDS token_cutoff = time.time() for token, data in list(playback_tokens.items()): if float(data["expires_at"]) < token_cutoff: playback_tokens.pop(token, None) if not root.exists(): return for file_path in root.rglob("*.mp3"): try: if file_path.stat().st_mtime < cutoff: file_path.unlink(missing_ok=True) except OSError as exc: logger.warning("Failed to clean old TTS file %s: %s", file_path, exc) @router.post("/generate", response_model=TextToSpeechResponse) async def generate_tts_audio( request: TextToSpeechRequest, current_user: User = Depends(get_current_user), ): text = request.text.strip() if not text: raise HTTPException(status_code=400, detail="Text is required.") _cleanup_old_tts_files(settings.output_dir / "tts") if len(text) > MAX_TTS_CHARS: text = text[:MAX_TTS_CHARS] audio_id = str(uuid.uuid4()) output_file = _user_tts_dir(current_user) / f"{audio_id}.mp3" try: service = TextToSpeechService() await service.generate_audio( text=text, output_file=str(output_file), rate=request.rate, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except Exception as exc: logger.error("TTS generation failed: %s", exc) raise HTTPException( status_code=500, detail="Failed to generate speech audio.", ) from exc playback_token = _create_playback_token(audio_id, current_user.id) return TextToSpeechResponse( audioId=audio_id, audioUrl=f"/tts/audio/{audio_id}/{playback_token}", ) @router.get("/audio/{audio_id}") async def get_tts_audio( audio_id: str, token: str | None = None, ): if not re.fullmatch(r"[A-Fa-f0-9-]{36}", audio_id): raise HTTPException(status_code=400, detail="Invalid audio id.") user_id = _resolve_playback_token(audio_id, token) audio_file = _user_tts_dir_for_user_id(user_id) / f"{audio_id}.mp3" if not audio_file.exists(): raise HTTPException(status_code=404, detail="Audio file not found.") return FileResponse( path=audio_file, media_type="audio/mpeg", filename=f"{audio_id}.mp3", ) @router.get("/audio/{audio_id}/{token}") async def get_tts_audio_with_path_token( audio_id: str, token: str, ): return await get_tts_audio(audio_id=audio_id, token=token)