MusicUtils / processor.py
sathvik77's picture
Add Indian tala and time signature detection alongside Western BPM
63f26c4
Raw
History Blame Contribute Delete
9.08 kB
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())