Spaces:
Running
Running
| import asyncio | |
| import os | |
| import shutil | |
| import sys | |
| from pathlib import Path | |
| from typing import Dict | |
| from audio_analysis import analyze_audio_file | |
| from models import JobState, JobStatus | |
| def _get_device() -> str: | |
| """Return the best available device for Demucs inference.""" | |
| device = os.environ.get("DEMUCS_DEVICE") | |
| if device: | |
| return device | |
| try: | |
| import torch | |
| if torch.cuda.is_available(): | |
| return "cuda" | |
| if hasattr(torch.backends, "mps") and torch.backends.mps.is_available(): | |
| return "mps" | |
| except ImportError: | |
| pass | |
| return "cpu" | |
| _jobs: Dict[str, JobStatus] = {} | |
| _queue: asyncio.Queue | None = None | |
| STEM_NAMES = ("vocals", "drums", "bass", "other") | |
| def init_queue() -> None: | |
| """Call this from inside a running event loop (FastAPI startup).""" | |
| global _queue | |
| _queue = asyncio.Queue() | |
| def get_job_status(job_id: str) -> JobStatus | None: | |
| return _jobs.get(job_id) | |
| def create_job(job_id: str, original_filename: str | None = None) -> JobStatus: | |
| status = JobStatus( | |
| job_id=job_id, | |
| state=JobState.pending, | |
| progress=0, | |
| original_filename=original_filename, | |
| ) | |
| _jobs[job_id] = status | |
| return status | |
| async def enqueue_job( | |
| job_id: str, | |
| input_path: str, | |
| output_dir: str, | |
| trim_start_sec: float | None = None, | |
| trim_end_sec: float | None = None, | |
| original_filename: str | None = None, | |
| ) -> None: | |
| if _queue is None: | |
| raise RuntimeError("Queue not initialized. Call init_queue() in startup.") | |
| create_job(job_id, original_filename=original_filename) | |
| try: | |
| _queue.put_nowait((job_id, input_path, output_dir, trim_start_sec, trim_end_sec)) | |
| except asyncio.QueueFull: | |
| _jobs.pop(job_id, None) | |
| raise RuntimeError("Processing queue is full. Please try again later.") | |
| async def _run_subprocess_exec(*args: str) -> tuple[int, bytes, bytes]: | |
| proc = await asyncio.create_subprocess_exec( | |
| *args, | |
| stdout=asyncio.subprocess.PIPE, | |
| stderr=asyncio.subprocess.PIPE, | |
| ) | |
| stdout, stderr = await proc.communicate() | |
| return proc.returncode, stdout, stderr | |
| async def _trim_audio( | |
| input_file: Path, | |
| job_id: str, | |
| trim_start_sec: float | None, | |
| trim_end_sec: float | None, | |
| ) -> Path: | |
| if trim_start_sec is None and trim_end_sec is None: | |
| return input_file | |
| trim_start = trim_start_sec or 0.0 | |
| trimmed_file = input_file.parent / f"{job_id}_trimmed.mp3" | |
| ffmpeg_cmd = [ | |
| "ffmpeg", | |
| "-y", | |
| "-hide_banner", | |
| "-loglevel", | |
| "error", | |
| "-ss", | |
| str(trim_start), | |
| "-i", | |
| str(input_file), | |
| ] | |
| if trim_end_sec is not None: | |
| ffmpeg_cmd.extend(["-to", str(trim_end_sec)]) | |
| ffmpeg_cmd.extend(["-c", "copy", str(trimmed_file)]) | |
| code, _, stderr = await _run_subprocess_exec(*ffmpeg_cmd) | |
| if code != 0: | |
| fallback_cmd = [ | |
| "ffmpeg", | |
| "-y", | |
| "-hide_banner", | |
| "-loglevel", | |
| "error", | |
| "-ss", | |
| str(trim_start), | |
| "-i", | |
| str(input_file), | |
| ] | |
| if trim_end_sec is not None: | |
| fallback_cmd.extend(["-to", str(trim_end_sec)]) | |
| fallback_cmd.extend(["-codec:a", "libmp3lame", "-b:a", "320k", str(trimmed_file)]) | |
| fallback_code, _, fallback_stderr = await _run_subprocess_exec(*fallback_cmd) | |
| if fallback_code != 0: | |
| msg = fallback_stderr.decode(errors="replace")[:1200] | |
| if not msg: | |
| msg = stderr.decode(errors="replace")[:1200] | |
| raise RuntimeError(f"Audio trim failed: {msg}") | |
| return trimmed_file | |
| async def _mix_instrumental(output_dir: Path) -> Path: | |
| drums = output_dir / "drums.mp3" | |
| bass = output_dir / "bass.mp3" | |
| other = output_dir / "other.mp3" | |
| instrumental = output_dir / "instrumental.mp3" | |
| cmd = [ | |
| "ffmpeg", | |
| "-y", | |
| "-hide_banner", | |
| "-loglevel", | |
| "error", | |
| "-i", | |
| str(drums), | |
| "-i", | |
| str(bass), | |
| "-i", | |
| str(other), | |
| "-filter_complex", | |
| "amix=inputs=3:duration=longest:normalize=0", | |
| "-codec:a", | |
| "libmp3lame", | |
| "-b:a", | |
| "320k", | |
| str(instrumental), | |
| ] | |
| code, _, stderr = await _run_subprocess_exec(*cmd) | |
| if code != 0: | |
| msg = stderr.decode(errors="replace")[:1200] | |
| raise RuntimeError(f"Failed to create instrumental stem: {msg}") | |
| return instrumental | |
| def _move_demucs_stems(demucs_work_dir: Path, output_dir: Path) -> dict[str, str]: | |
| stems: dict[str, str] = {} | |
| output_dir.mkdir(parents=True, exist_ok=True) | |
| for stem_name in STEM_NAMES: | |
| source_file = next(demucs_work_dir.rglob(f"{stem_name}.mp3"), None) | |
| if source_file is None: | |
| continue | |
| target_file = output_dir / f"{stem_name}.mp3" | |
| source_file.replace(target_file) | |
| stems[stem_name] = target_file.name | |
| return stems | |
| async def _run_demucs( | |
| job_id: str, | |
| input_path: str, | |
| output_dir: str, | |
| trim_start_sec: float | None = None, | |
| trim_end_sec: float | None = None, | |
| ) -> None: | |
| _jobs[job_id].state = JobState.processing | |
| _jobs[job_id].progress = 10 | |
| _jobs[job_id].stems = None | |
| _jobs[job_id].bpm = None | |
| _jobs[job_id].bpm_confidence = None | |
| _jobs[job_id].musical_key = None | |
| _jobs[job_id].key_scale = None | |
| _jobs[job_id].key_confidence = None | |
| input_file = Path(input_path) | |
| output_directory = Path(output_dir) | |
| demucs_work_dir = output_directory.parent / f"demucs_{job_id}" | |
| trimmed_file: Path | None = None | |
| try: | |
| demucs_input = input_file | |
| if trim_start_sec is not None or trim_end_sec is not None: | |
| _jobs[job_id].progress = 20 | |
| demucs_input = await _trim_audio( | |
| input_file=input_file, | |
| job_id=job_id, | |
| trim_start_sec=trim_start_sec, | |
| trim_end_sec=trim_end_sec, | |
| ) | |
| trimmed_file = demucs_input | |
| proc = await asyncio.create_subprocess_exec( | |
| sys.executable, | |
| "-m", | |
| "demucs", | |
| "--device", | |
| _get_device(), | |
| "--mp3", | |
| "--mp3-bitrate", | |
| "320", | |
| "-o", | |
| str(demucs_work_dir), | |
| str(demucs_input), | |
| stdout=asyncio.subprocess.PIPE, | |
| stderr=asyncio.subprocess.PIPE, | |
| ) | |
| _jobs[job_id].progress = 35 | |
| _, stderr = await proc.communicate() | |
| _jobs[job_id].progress = 80 | |
| if proc.returncode != 0: | |
| raise RuntimeError(f"Demucs failed: {stderr[:2000].decode(errors='replace')}") | |
| stems = _move_demucs_stems(demucs_work_dir=demucs_work_dir, output_dir=output_directory) | |
| if not stems: | |
| raise RuntimeError("Demucs did not produce any stem MP3 outputs") | |
| if all(k in stems for k in ("drums", "bass", "other")): | |
| instrumental_path = await _mix_instrumental(output_directory) | |
| stems["instrumental"] = instrumental_path.name | |
| _jobs[job_id].progress = 92 | |
| original_for_analysis = output_directory / "original.mp3" | |
| try: | |
| if original_for_analysis.exists(): | |
| analysis = await asyncio.to_thread(analyze_audio_file, original_for_analysis) | |
| _jobs[job_id].bpm = analysis.get("bpm") | |
| _jobs[job_id].bpm_confidence = analysis.get("bpm_confidence") | |
| _jobs[job_id].musical_key = analysis.get("musical_key") | |
| _jobs[job_id].key_scale = analysis.get("key_scale") | |
| _jobs[job_id].key_confidence = analysis.get("key_confidence") | |
| _jobs[job_id].tala = analysis.get("tala") | |
| _jobs[job_id].time_signature = analysis.get("time_signature") | |
| except Exception: | |
| # Analysis is additive metadata only; stem generation should still succeed. | |
| pass | |
| _jobs[job_id].stems = stems | |
| _jobs[job_id].state = JobState.done | |
| _jobs[job_id].progress = 100 | |
| except Exception as e: | |
| _jobs[job_id].state = JobState.error | |
| _jobs[job_id].error = str(e)[:500] | |
| raise | |
| finally: | |
| shutil.rmtree(demucs_work_dir, ignore_errors=True) | |
| if trimmed_file and trimmed_file.exists(): | |
| trimmed_file.unlink(missing_ok=True) | |
| async def _worker() -> None: | |
| if _queue is None: | |
| raise RuntimeError("Queue not initialized before worker started") | |
| while True: | |
| job_id, input_path, output_dir, trim_start_sec, trim_end_sec = await _queue.get() | |
| try: | |
| await _run_demucs( | |
| job_id, | |
| input_path, | |
| output_dir, | |
| trim_start_sec=trim_start_sec, | |
| trim_end_sec=trim_end_sec, | |
| ) | |
| except Exception: | |
| pass | |
| finally: | |
| _queue.task_done() | |
| async def start_worker() -> None: | |
| asyncio.create_task(_worker()) | |