AIdea-Server / src /api /tts_routes.py
Ahmed Mostafa
feat: implement TTS generation API with tokenized secure audio retrieval
8ff5b20
Raw
History Blame Contribute Delete
4.86 kB
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)