From d35e6f0cc54c2abfe91701d5d8fdcfdb7bbe7180 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Mon, 16 Mar 2026 23:29:18 -0700 Subject: [PATCH] fix sample upload blocking the event loop and causing server timeouts Move audio validation and saving to thread pool so librosa/ffmpeg decoding doesn't block the async event loop. Combine validate + load into a single pass to avoid decoding the file twice. Add 50 MB upload limit and chunked reads to prevent unbounded memory allocation. Closes #278 --- backend/routes/profiles.py | 16 ++++++++++++++-- backend/services/profiles.py | 12 ++++++++---- backend/utils/audio.py | 30 ++++++++++++++++++++++++------ 3 files changed, 46 insertions(+), 12 deletions(-) diff --git a/backend/routes/profiles.py b/backend/routes/profiles.py index 6b7b4509..5b2257e0 100644 --- a/backend/routes/profiles.py +++ b/backend/routes/profiles.py @@ -102,6 +102,10 @@ async def delete_profile( return {"message": "Profile deleted successfully"} +SAMPLE_MAX_FILE_SIZE = 50 * 1024 * 1024 # 50 MB +SAMPLE_UPLOAD_CHUNK_SIZE = 1024 * 1024 # 1 MB + + @router.post("/profiles/{profile_id}/samples", response_model=models.ProfileSampleResponse) async def add_profile_sample( profile_id: str, @@ -115,8 +119,16 @@ async def add_profile_sample( file_suffix = _uploaded_ext if _uploaded_ext in _allowed_audio_exts else ".wav" with tempfile.NamedTemporaryFile(suffix=file_suffix, delete=False) as tmp: - content = await file.read() - tmp.write(content) + total_size = 0 + while chunk := await file.read(SAMPLE_UPLOAD_CHUNK_SIZE): + total_size += len(chunk) + if total_size > SAMPLE_MAX_FILE_SIZE: + Path(tmp.name).unlink(missing_ok=True) + raise HTTPException( + status_code=413, + detail=f"File too large (max {SAMPLE_MAX_FILE_SIZE // (1024 * 1024)} MB)", + ) + tmp.write(chunk) tmp_path = tmp.name try: diff --git a/backend/services/profiles.py b/backend/services/profiles.py index 46180f3c..d20c142d 100644 --- a/backend/services/profiles.py +++ b/backend/services/profiles.py @@ -22,7 +22,7 @@ from ..database import ( Generation as DBGeneration, ) from ..models import EffectConfig -from ..utils.audio import validate_reference_audio, load_audio, save_audio +from ..utils.audio import validate_reference_audio, validate_and_load_reference_audio, load_audio, save_audio from ..utils.images import validate_image, process_avatar from ..utils.cache import _get_cache_dir, clear_profile_cache from .tts import get_tts_model @@ -117,11 +117,16 @@ async def add_profile_sample( Returns: Created sample """ + import asyncio + profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first() if not profile: raise ValueError(f"Profile {profile_id} not found") - is_valid, error_msg = validate_reference_audio(audio_path) + # Validate and load audio in a single pass, off the event loop + is_valid, error_msg, audio, sr = await asyncio.to_thread( + validate_and_load_reference_audio, audio_path + ) if not is_valid: raise ValueError(f"Invalid reference audio: {error_msg}") @@ -130,8 +135,7 @@ async def add_profile_sample( profile_dir.mkdir(parents=True, exist_ok=True) dest_path = profile_dir / f"{sample_id}.wav" - audio, sr = load_audio(audio_path) - save_audio(audio, str(dest_path), sr) + await asyncio.to_thread(save_audio, audio, str(dest_path), sr) db_sample = DBProfileSample( id=sample_id, diff --git a/backend/utils/audio.py b/backend/utils/audio.py index 2c6de760..2016992c 100644 --- a/backend/utils/audio.py +++ b/backend/utils/audio.py @@ -217,22 +217,40 @@ def validate_reference_audio( Returns: Tuple of (is_valid, error_message) """ + result = validate_and_load_reference_audio( + audio_path, min_duration, max_duration, min_rms + ) + return (result[0], result[1]) + + +def validate_and_load_reference_audio( + audio_path: str, + min_duration: float = 2.0, + max_duration: float = 30.0, + min_rms: float = 0.01, +) -> Tuple[bool, Optional[str], Optional[np.ndarray], Optional[int]]: + """ + Validate and load reference audio in a single pass. + + Returns: + Tuple of (is_valid, error_message, audio_array, sample_rate) + """ try: audio, sr = load_audio(audio_path) duration = len(audio) / sr if duration < min_duration: - return False, f"Audio too short (minimum {min_duration} seconds)" + return False, f"Audio too short (minimum {min_duration} seconds)", None, None if duration > max_duration: - return False, f"Audio too long (maximum {max_duration} seconds)" + return False, f"Audio too long (maximum {max_duration} seconds)", None, None rms = np.sqrt(np.mean(audio**2)) if rms < min_rms: - return False, "Audio is too quiet or silent" + return False, "Audio is too quiet or silent", None, None if np.abs(audio).max() > 0.99: - return False, "Audio is clipping (reduce input gain)" + return False, "Audio is clipping (reduce input gain)", None, None - return True, None + return True, None, audio, sr except Exception as e: - return False, f"Error validating audio: {str(e)}" + return False, f"Error validating audio: {str(e)}", None, None