voice-bridge / backend /main.py
scaffece's picture
Upload 14 files
66915f1 verified
Raw
History Blame Contribute Delete
9.63 kB
"""
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
# ---------------------------------------------------------------------------
@app.get("/api/health")
async def health():
return {"status": "ok"}
@app.post("/api/translate-speak")
@limiter.limit("15/minute")
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})
@app.post("/api/audio-translate")
@limiter.limit("8/minute")
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},
)
@app.post("/api/video-translate")
@limiter.limit("5/minute")
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},
)
@app.post("/api/document-translate")
@limiter.limit("3/minute")
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")