pdf-quiz-generator/backend/app/routers/tts.py

140 lines
4.9 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from fastapi.responses import Response
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.config import settings
from app.database import get_db
from app.models.user import User
from app.models.ai_model_config import AIModelConfig
from app.services import ai_service
from app.utils.auth import get_current_user, check_rate_limit
router = APIRouter()
class TTSRequest(BaseModel):
text: str
voice: str | None = None # model_id override
MAX_AUDIO_UPLOAD_BYTES = 25 * 1024 * 1024
def _task_model(db: Session, task: str, fallback: str) -> tuple[str, str | None]:
config = db.query(AIModelConfig).filter(
AIModelConfig.task == task,
AIModelConfig.is_active == True,
AIModelConfig.is_default == True,
).first()
if config:
return config.model_id, config.api_key or None
return fallback, None
def _polly_enabled() -> bool:
try:
import redis as redis_lib
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
val = r.get("settings:polly_enabled")
return val != "false"
except Exception:
return True
def _default_tts_model(db: Session) -> tuple[str, str | None]:
config = db.query(AIModelConfig).filter(
AIModelConfig.task == "tts",
AIModelConfig.is_active == True,
AIModelConfig.is_default == True,
AIModelConfig.model_id.like("local-%"),
).first()
if config:
return config.model_id, config.api_key or None
return "local-kokoro-tts", None
@router.get("/voices")
def get_voices(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return LiteLLM-routed TTS models only."""
query = db.query(AIModelConfig).filter(
AIModelConfig.task == "tts",
AIModelConfig.is_active == True,
AIModelConfig.model_id.like("local-%"),
)
if not _polly_enabled():
query = query.filter(~AIModelConfig.model_id.like("polly/%"))
db_models = query.order_by(AIModelConfig.is_default.desc(), AIModelConfig.name).all()
return [{"id": m.model_id, "name": m.name, "is_default": m.is_default} for m in db_models]
@router.post("/speak")
def text_to_speech(
request: TTSRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Convert text to speech using configured or user-selected TTS model."""
# Rate limit: 60 TTS requests per user per hour (admins/unthrottled users exempt)
check_rate_limit(
key=f"tts_speak:{current_user.id}",
max_calls=240,
window_seconds=3600,
detail="You've reached the audio limit. The limit resets automatically — try again shortly. Contact an admin if you need this raised.",
user=current_user,
)
if not request.text.strip():
raise HTTPException(status_code=400, detail="Text cannot be empty")
text = request.text[:2000]
if request.voice and request.voice.startswith("local-"):
if request.voice.startswith("polly/") and not _polly_enabled():
raise HTTPException(status_code=400, detail="AWS Polly is currently disabled")
config = db.query(AIModelConfig).filter(AIModelConfig.model_id == request.voice).first()
model_id = config.model_id if config else request.voice
api_key = config.api_key if (config and config.api_key) else None
else:
model_id, api_key = _default_tts_model(db)
audio = ai_service.generate_tts_audio(text, model_id=model_id, api_key=api_key)
if audio is None:
raise HTTPException(status_code=500, detail="TTS generation failed. Check model configuration.")
return Response(content=audio, media_type="audio/mpeg")
@router.post("/transcribe")
def speech_to_text(
file: UploadFile = File(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Transcribe microphone audio using the configured STT model."""
check_rate_limit(
key=f"stt_transcribe:{current_user.id}",
max_calls=120,
window_seconds=3600,
detail="You've reached the speech transcription limit. Try again shortly.",
user=current_user,
)
audio = file.file.read()
if not audio:
raise HTTPException(status_code=400, detail="Audio file is empty")
if len(audio) > MAX_AUDIO_UPLOAD_BYTES:
raise HTTPException(status_code=413, detail="Audio file is too large")
model_id, api_key = _task_model(db, "stt", "local-parakeet-v3")
text = ai_service.transcribe_audio(
audio,
filename=file.filename or "audio.webm",
content_type=file.content_type or "audio/webm",
model_id=model_id,
api_key=api_key,
)
if text is None:
raise HTTPException(status_code=502, detail="Speech transcription failed. Check STT model configuration.")
return {"text": text, "model": model_id}