Spaces:
Sleeping
Sleeping
| """ | |
| Voice Bridge backend — English to Luganda translation + speech, for text, | |
| audio, video, and document input. | |
| All heavy ML inference (translation, STT, TTS) runs on Sunbird AI's hosted | |
| API — this server's job is: validate input safely, extract/chunk content, | |
| call Sunbird, post-process audio, and return a result. It never trains or | |
| hosts models itself, which is what keeps this reliable on free hosting tiers. | |
| """ | |
| import os | |
| import tempfile | |
| from pathlib import Path | |
| from typing import Optional | |
| from dotenv import load_dotenv | |
| from fastapi import FastAPI, File, UploadFile, Form, HTTPException, Request | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.responses import Response | |
| from fastapi.staticfiles import StaticFiles | |
| from slowapi import Limiter, _rate_limit_exceeded_handler | |
| from slowapi.errors import RateLimitExceeded | |
| from slowapi.util import get_remote_address | |
| from sunbird_client import SunbirdClient, SunbirdAPIError | |
| from text_extraction import extract_text_from_file, split_into_sentences | |
| from audio_utils import deepen_voice, concatenate_audio, extract_audio_from_video | |
| load_dotenv() | |
| # --------------------------------------------------------------------------- | |
| # Configuration & startup checks | |
| # --------------------------------------------------------------------------- | |
| # API key is optional for local inference mode (HF Spaces deployment) | |
| SUNBIRD_API_KEY = os.environ.get("SUNBIRD_API_KEY", "") | |
| # Comma-separated list of frontend origins allowed to call this API. | |
| # In production this MUST be your real deployed frontend URL, not "*". | |
| ALLOWED_ORIGINS = [ | |
| origin.strip() | |
| for origin in os.environ.get("ALLOWED_ORIGINS", "http://localhost:7860").split(",") | |
| if origin.strip() | |
| ] | |
| MAX_TEXT_CHARS = 5000 | |
| MAX_AUDIO_MB = 25 | |
| MAX_VIDEO_MB = 150 | |
| MAX_DOCUMENT_MB = 20 | |
| MAX_DOCUMENT_SENTENCES = 800 # safety cap so one upload can't run unbounded API calls | |
| ALLOWED_AUDIO_EXT = {".mp3", ".wav", ".ogg", ".m4a", ".aac"} | |
| ALLOWED_VIDEO_EXT = {".mp4", ".mov", ".mkv", ".webm"} | |
| ALLOWED_DOCUMENT_EXT = {".pdf", ".epub", ".txt"} | |
| sunbird = SunbirdClient(api_key=SUNBIRD_API_KEY) | |
| limiter = Limiter(key_func=get_remote_address) | |
| app = FastAPI(title="Voice Bridge API", version="1.0.0") | |
| app.state.limiter = limiter | |
| app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=ALLOWED_ORIGINS, | |
| allow_credentials=False, | |
| allow_methods=["GET", "POST"], | |
| allow_headers=["Content-Type"], | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Input validation helpers | |
| # --------------------------------------------------------------------------- | |
| def validate_extension(filename: Optional[str], allowed: set[str], kind: str) -> str: | |
| if not filename: | |
| raise HTTPException(400, f"Missing {kind} filename") | |
| ext = Path(filename).suffix.lower() | |
| if ext not in allowed: | |
| raise HTTPException( | |
| 400, f"Unsupported {kind} file type '{ext}'. Allowed: {', '.join(sorted(allowed))}" | |
| ) | |
| return ext | |
| async def read_and_validate_size(file: UploadFile, max_mb: int, kind: str) -> bytes: | |
| contents = await file.read() | |
| size_mb = len(contents) / (1024 * 1024) | |
| if size_mb > max_mb: | |
| raise HTTPException(400, f"{kind} file too large ({size_mb:.1f}MB). Max: {max_mb}MB") | |
| if size_mb == 0: | |
| raise HTTPException(400, f"{kind} file is empty") | |
| return contents | |
| def clean_text_input(text: str) -> str: | |
| text = text.strip() | |
| if not text: | |
| raise HTTPException(400, "Text cannot be empty") | |
| if len(text) > MAX_TEXT_CHARS: | |
| raise HTTPException(400, f"Text too long ({len(text)} chars). Max: {MAX_TEXT_CHARS}") | |
| return text | |
| def validate_voice(voice: str) -> str: | |
| if voice not in ("female", "male"): | |
| raise HTTPException(400, "voice must be 'female' or 'male'") | |
| return voice | |
| def apply_voice(audio_bytes: bytes, voice: str) -> bytes: | |
| return deepen_voice(audio_bytes) if voice == "male" else audio_bytes | |
| def audio_response(audio_bytes: bytes, **extra_headers: str) -> Response: | |
| headers = {"Content-Disposition": "inline; filename=output.mp3"} | |
| headers.update(extra_headers) | |
| return Response(content=audio_bytes, media_type="audio/mpeg", headers=headers) | |
| # --------------------------------------------------------------------------- | |
| # Routes | |
| # --------------------------------------------------------------------------- | |
| async def health(): | |
| return {"status": "ok"} | |
| async def translate_speak(request: Request, text: str = Form(...), voice: str = Form("female")): | |
| text = clean_text_input(text) | |
| voice = validate_voice(voice) | |
| try: | |
| translated = await sunbird.translate(text, source="eng", target="lug") | |
| audio_bytes = await sunbird.text_to_speech(translated) | |
| except SunbirdAPIError as e: | |
| raise HTTPException(502, f"Translation service error: {e}") | |
| audio_bytes = apply_voice(audio_bytes, voice) | |
| return audio_response(audio_bytes, **{"X-Translated-Text": translated}) | |
| async def audio_translate(request: Request, file: UploadFile = File(...), voice: str = Form("female")): | |
| validate_extension(file.filename, ALLOWED_AUDIO_EXT, "audio") | |
| voice = validate_voice(voice) | |
| contents = await read_and_validate_size(file, MAX_AUDIO_MB, "Audio") | |
| try: | |
| english_text = await sunbird.speech_to_text(contents, file.filename, language="eng") | |
| translated = await sunbird.translate(english_text, source="eng", target="lug") | |
| audio_bytes = await sunbird.text_to_speech(translated) | |
| except SunbirdAPIError as e: | |
| raise HTTPException(502, f"Processing error: {e}") | |
| audio_bytes = apply_voice(audio_bytes, voice) | |
| return audio_response( | |
| audio_bytes, | |
| **{"X-Original-Text": english_text, "X-Translated-Text": translated}, | |
| ) | |
| async def video_translate(request: Request, file: UploadFile = File(...), voice: str = Form("female")): | |
| validate_extension(file.filename, ALLOWED_VIDEO_EXT, "video") | |
| voice = validate_voice(voice) | |
| contents = await read_and_validate_size(file, MAX_VIDEO_MB, "Video") | |
| with tempfile.TemporaryDirectory() as tmp: | |
| video_path = Path(tmp) / f"input{Path(file.filename).suffix}" | |
| video_path.write_bytes(contents) | |
| audio_path = Path(tmp) / "extracted.wav" | |
| try: | |
| extract_audio_from_video(str(video_path), str(audio_path)) | |
| except Exception as e: | |
| raise HTTPException(400, f"Could not extract audio from video: {e}") | |
| audio_bytes_in = audio_path.read_bytes() | |
| try: | |
| english_text = await sunbird.speech_to_text(audio_bytes_in, "extracted.wav", language="eng") | |
| translated = await sunbird.translate(english_text, source="eng", target="lug") | |
| audio_bytes = await sunbird.text_to_speech(translated) | |
| except SunbirdAPIError as e: | |
| raise HTTPException(502, f"Processing error: {e}") | |
| audio_bytes = apply_voice(audio_bytes, voice) | |
| return audio_response( | |
| audio_bytes, | |
| **{"X-Original-Text": english_text, "X-Translated-Text": translated}, | |
| ) | |
| async def document_translate( | |
| request: Request, | |
| file: UploadFile = File(...), | |
| voice: str = Form("female"), | |
| max_sentences: Optional[int] = Form(None), | |
| ): | |
| ext = validate_extension(file.filename, ALLOWED_DOCUMENT_EXT, "document") | |
| voice = validate_voice(voice) | |
| contents = await read_and_validate_size(file, MAX_DOCUMENT_MB, "Document") | |
| with tempfile.TemporaryDirectory() as tmp: | |
| doc_path = Path(tmp) / f"input{ext}" | |
| doc_path.write_bytes(contents) | |
| try: | |
| raw_text = extract_text_from_file(str(doc_path)) | |
| except Exception as e: | |
| raise HTTPException(400, f"Could not read document: {e}") | |
| sentences = split_into_sentences(raw_text) | |
| limit = min(max_sentences, MAX_DOCUMENT_SENTENCES) if max_sentences else MAX_DOCUMENT_SENTENCES | |
| sentences = [s for s in sentences[:limit] if s.strip()] | |
| if not sentences: | |
| raise HTTPException(400, "No readable text found in this document") | |
| audio_chunks = [] | |
| failed = 0 | |
| for sentence in sentences: | |
| try: | |
| translated = await sunbird.translate(sentence, source="eng", target="lug") | |
| chunk_audio = await sunbird.text_to_speech(translated) | |
| audio_chunks.append(chunk_audio) | |
| except SunbirdAPIError: | |
| failed += 1 | |
| continue # one bad sentence shouldn't sink the whole document | |
| if not audio_chunks: | |
| raise HTTPException(502, "Translation service failed for every sentence. Try again shortly.") | |
| final_audio = concatenate_audio(audio_chunks) | |
| final_audio = apply_voice(final_audio, voice) | |
| return audio_response( | |
| final_audio, | |
| **{"X-Sentence-Count": str(len(audio_chunks)), "X-Failed-Sentences": str(failed)}, | |
| ) | |
| # Serve the frontend's static files (the PWA) from the same server/origin. | |
| # Mounted last so it doesn't shadow the /api/* routes above. | |
| frontend_dir = Path(__file__).resolve().parent.parent / "frontend" | |
| # HF Spaces runs from /app/backend so adjust path accordingly | |
| if not frontend_dir.exists(): | |
| frontend_dir = Path("/app/frontend") | |
| if frontend_dir.exists(): | |
| app.mount("/", StaticFiles(directory=str(frontend_dir), html=True), name="frontend") | |