Spaces:
Sleeping
Sleeping
Ahmed Mostafa commited on
Commit ·
aa1867f
1
Parent(s): 8285ecf
feat: implement text-to-speech service, add API routers, and define database models
Browse files- src/api/main.py +2 -0
- src/api/notes_routes.py +1 -0
- src/api/recommendation_routes.py +6 -2
- src/api/tts_routes.py +113 -0
- src/db/models.py +0 -1
src/api/main.py
CHANGED
|
@@ -12,6 +12,7 @@ from src.api.auth_routes import router as auth_router
|
|
| 12 |
from src.api.notes_routes import router as notes_router
|
| 13 |
from src.api.recommendation_routes import router as recommendation_router
|
| 14 |
from src.api.chat_routes import router as chat_router
|
|
|
|
| 15 |
from src.utils.logger import setup_logger
|
| 16 |
|
| 17 |
logger = setup_logger(__name__)
|
|
@@ -41,6 +42,7 @@ app.include_router(notes_router)
|
|
| 41 |
app.include_router(recommendation_router)
|
| 42 |
app.include_router(auth_router)
|
| 43 |
app.include_router(chat_router)
|
|
|
|
| 44 |
|
| 45 |
@app.get("/")
|
| 46 |
def read_root():
|
|
|
|
| 12 |
from src.api.notes_routes import router as notes_router
|
| 13 |
from src.api.recommendation_routes import router as recommendation_router
|
| 14 |
from src.api.chat_routes import router as chat_router
|
| 15 |
+
from src.api.tts_routes import router as tts_router
|
| 16 |
from src.utils.logger import setup_logger
|
| 17 |
|
| 18 |
logger = setup_logger(__name__)
|
|
|
|
| 42 |
app.include_router(recommendation_router)
|
| 43 |
app.include_router(auth_router)
|
| 44 |
app.include_router(chat_router)
|
| 45 |
+
app.include_router(tts_router)
|
| 46 |
|
| 47 |
@app.get("/")
|
| 48 |
def read_root():
|
src/api/notes_routes.py
CHANGED
|
@@ -305,6 +305,7 @@ async def process_video_task(
|
|
| 305 |
tasks[task_id]["category"] = categories
|
| 306 |
tasks[task_id]["keyPoints"] = key_points_list
|
| 307 |
tasks[task_id]["videoTitle"] = resolved_video_title
|
|
|
|
| 308 |
tasks[task_id]["thumbnail"] = (
|
| 309 |
f"https://img.youtube.com/vi/{video_id}/mqdefault.jpg" if video_id else ""
|
| 310 |
)
|
|
|
|
| 305 |
tasks[task_id]["category"] = categories
|
| 306 |
tasks[task_id]["keyPoints"] = key_points_list
|
| 307 |
tasks[task_id]["videoTitle"] = resolved_video_title
|
| 308 |
+
tasks[task_id]["videoDuration"] = video_duration
|
| 309 |
tasks[task_id]["thumbnail"] = (
|
| 310 |
f"https://img.youtube.com/vi/{video_id}/mqdefault.jpg" if video_id else ""
|
| 311 |
)
|
src/api/recommendation_routes.py
CHANGED
|
@@ -90,8 +90,12 @@ async def get_recommendations(
|
|
| 90 |
logger.info(f"🎯 Getting recommendations for user: {user_id}")
|
| 91 |
|
| 92 |
try:
|
| 93 |
-
#
|
| 94 |
-
notes_count =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
is_personalized = notes_count >= MIN_INTERACTIONS_FOR_PERSONALIZATION
|
| 97 |
|
|
|
|
| 90 |
logger.info(f"🎯 Getting recommendations for user: {user_id}")
|
| 91 |
|
| 92 |
try:
|
| 93 |
+
# Get notes count from analytics collection in Firestore
|
| 94 |
+
notes_count = 0
|
| 95 |
+
if db is not None and user_id:
|
| 96 |
+
analytics_doc = db.collection("analytics").document(user_id).get()
|
| 97 |
+
if analytics_doc.exists:
|
| 98 |
+
notes_count = analytics_doc.to_dict().get("notesCount", 0) or 0
|
| 99 |
|
| 100 |
is_personalized = notes_count >= MIN_INTERACTIONS_FOR_PERSONALIZATION
|
| 101 |
|
src/api/tts_routes.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
import time
|
| 3 |
+
import uuid
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 7 |
+
from fastapi.responses import FileResponse
|
| 8 |
+
from pydantic import BaseModel, Field
|
| 9 |
+
|
| 10 |
+
from src.auth.dependencies import get_current_user
|
| 11 |
+
from src.db.models import User
|
| 12 |
+
from src.speech.speech import TextToSpeechService
|
| 13 |
+
from src.utils.config import settings
|
| 14 |
+
from src.utils.logger import setup_logger
|
| 15 |
+
|
| 16 |
+
logger = setup_logger(__name__)
|
| 17 |
+
router = APIRouter(prefix="/tts", tags=["Text to Speech"])
|
| 18 |
+
|
| 19 |
+
MAX_TTS_CHARS = 20000
|
| 20 |
+
TTS_TTL_SECONDS = 24 * 60 * 60
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class TextToSpeechRequest(BaseModel):
|
| 24 |
+
text: str = Field(..., min_length=1)
|
| 25 |
+
title: str | None = None
|
| 26 |
+
rate: str = "-10%"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class TextToSpeechResponse(BaseModel):
|
| 30 |
+
audioId: str
|
| 31 |
+
audioUrl: str
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _safe_user_id(user_id: str | None) -> str:
|
| 35 |
+
value = user_id or "unknown"
|
| 36 |
+
return re.sub(r"[^A-Za-z0-9_-]", "_", value)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _user_tts_dir(user: User) -> Path:
|
| 40 |
+
user_dir = settings.output_dir / "tts" / _safe_user_id(user.id)
|
| 41 |
+
user_dir.mkdir(parents=True, exist_ok=True)
|
| 42 |
+
return user_dir
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _cleanup_old_tts_files(root: Path) -> None:
|
| 46 |
+
cutoff = time.time() - TTS_TTL_SECONDS
|
| 47 |
+
if not root.exists():
|
| 48 |
+
return
|
| 49 |
+
|
| 50 |
+
for file_path in root.rglob("*.mp3"):
|
| 51 |
+
try:
|
| 52 |
+
if file_path.stat().st_mtime < cutoff:
|
| 53 |
+
file_path.unlink(missing_ok=True)
|
| 54 |
+
except OSError as exc:
|
| 55 |
+
logger.warning("Failed to clean old TTS file %s: %s", file_path, exc)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
@router.post("/generate", response_model=TextToSpeechResponse)
|
| 59 |
+
async def generate_tts_audio(
|
| 60 |
+
request: TextToSpeechRequest,
|
| 61 |
+
current_user: User = Depends(get_current_user),
|
| 62 |
+
):
|
| 63 |
+
text = request.text.strip()
|
| 64 |
+
if not text:
|
| 65 |
+
raise HTTPException(status_code=400, detail="Text is required.")
|
| 66 |
+
|
| 67 |
+
_cleanup_old_tts_files(settings.output_dir / "tts")
|
| 68 |
+
|
| 69 |
+
if len(text) > MAX_TTS_CHARS:
|
| 70 |
+
text = text[:MAX_TTS_CHARS]
|
| 71 |
+
|
| 72 |
+
audio_id = str(uuid.uuid4())
|
| 73 |
+
output_file = _user_tts_dir(current_user) / f"{audio_id}.mp3"
|
| 74 |
+
|
| 75 |
+
try:
|
| 76 |
+
service = TextToSpeechService()
|
| 77 |
+
await service.generate_audio(
|
| 78 |
+
text=text,
|
| 79 |
+
output_file=str(output_file),
|
| 80 |
+
rate=request.rate,
|
| 81 |
+
)
|
| 82 |
+
except ValueError as exc:
|
| 83 |
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
| 84 |
+
except Exception as exc:
|
| 85 |
+
logger.error("TTS generation failed: %s", exc)
|
| 86 |
+
raise HTTPException(
|
| 87 |
+
status_code=500,
|
| 88 |
+
detail="Failed to generate speech audio.",
|
| 89 |
+
) from exc
|
| 90 |
+
|
| 91 |
+
return TextToSpeechResponse(
|
| 92 |
+
audioId=audio_id,
|
| 93 |
+
audioUrl=f"/tts/audio/{audio_id}",
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
@router.get("/audio/{audio_id}")
|
| 98 |
+
async def get_tts_audio(
|
| 99 |
+
audio_id: str,
|
| 100 |
+
current_user: User = Depends(get_current_user),
|
| 101 |
+
):
|
| 102 |
+
if not re.fullmatch(r"[A-Fa-f0-9-]{36}", audio_id):
|
| 103 |
+
raise HTTPException(status_code=400, detail="Invalid audio id.")
|
| 104 |
+
|
| 105 |
+
audio_file = _user_tts_dir(current_user) / f"{audio_id}.mp3"
|
| 106 |
+
if not audio_file.exists():
|
| 107 |
+
raise HTTPException(status_code=404, detail="Audio file not found.")
|
| 108 |
+
|
| 109 |
+
return FileResponse(
|
| 110 |
+
path=audio_file,
|
| 111 |
+
media_type="audio/mpeg",
|
| 112 |
+
filename=f"{audio_id}.mp3",
|
| 113 |
+
)
|
src/db/models.py
CHANGED
|
@@ -19,7 +19,6 @@ class User(BaseModel):
|
|
| 19 |
age: Optional[int] = Field(default=None)
|
| 20 |
gender: Optional[str] = Field(default=None)
|
| 21 |
created_at: datetime = Field(default_factory=datetime.utcnow)
|
| 22 |
-
notesCount: Optional[int] = Field(default=0)
|
| 23 |
|
| 24 |
class Config:
|
| 25 |
from_attributes = True
|
|
|
|
| 19 |
age: Optional[int] = Field(default=None)
|
| 20 |
gender: Optional[str] = Field(default=None)
|
| 21 |
created_at: datetime = Field(default_factory=datetime.utcnow)
|
|
|
|
| 22 |
|
| 23 |
class Config:
|
| 24 |
from_attributes = True
|