mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-12 19:30:38 -07:00
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
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
+24
-6
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user