Spaces:
Sleeping
Sleeping
| 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) | |
| 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}", | |
| ) | |
| 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", | |
| ) | |
| async def get_tts_audio_with_path_token( | |
| audio_id: str, | |
| token: str, | |
| ): | |
| return await get_tts_audio(audio_id=audio_id, token=token) | |