"""Demucs source-separation wrapper. Splits a mixed stereo file into 4 stems (vocals / drums / bass / other) using htdemucs. Used both as a library (by the FastAPI service) and as a CLI for one-off separation: python separator.py song.mp3 -o separated/ """ from __future__ import annotations import os # Must be set before torch is imported: a few htdemucs ops are not yet # implemented on Apple's MPS backend and need the CPU fallback. os.environ.setdefault("PYTORCH_ENABLE_MPS_FALLBACK", "1") import argparse import logging import time from pathlib import Path from typing import Callable import torch from demucs.api import Separator, save_audio logger = logging.getLogger(__name__) STEMS = ("vocals", "drums", "bass", "other") DEFAULT_MODEL = "htdemucs" # progress in [0.0, 1.0] ProgressCallback = Callable[[float], None] def pick_device() -> str: """Best available torch device: cuda > mps > cpu.""" if torch.cuda.is_available(): return "cuda" if torch.backends.mps.is_available(): return "mps" return "cpu" def _demucs_progress_adapter(on_progress: ProgressCallback) -> Callable[[dict], None]: """Adapt demucs' raw callback dict to a single 0-1 float. Demucs reports per-segment offsets per model in the bag; normalize across (models x audio_length) so the caller sees monotonic progress. """ def callback(data: dict) -> None: try: models = max(int(data.get("models", 1)), 1) model_idx = int(data.get("model_idx_in_bag", 0)) length = max(int(data.get("audio_length", 1)), 1) offset = int(data.get("segment_offset", 0)) if data.get("state") == "end": offset = min(offset + int(data.get("segment_length", 0) or 0), length) fraction = (model_idx * length + offset) / (models * length) on_progress(min(max(fraction, 0.0), 1.0)) except Exception: # progress must never kill a separation job logger.debug("progress callback failed", exc_info=True) return callback def separate( input_path: str | Path, output_dir: str | Path, model: str = DEFAULT_MODEL, device: str | None = None, shifts: int = 0, overlap: float = 0.25, output_format: str = "wav", # "wav" | "mp3" (mp3 is ~10x smaller, 320kbps) on_progress: ProgressCallback | None = None, ) -> dict[str, Path]: """Separate ``input_path`` into 4 stem wav files under ``output_dir``. Returns a mapping of stem name -> written file path. Raises FileNotFoundError / RuntimeError on bad input or model failure. """ input_path = Path(input_path) if not input_path.is_file(): raise FileNotFoundError(f"No such audio file: {input_path}") output_dir = Path(output_dir) output_dir.mkdir(parents=True, exist_ok=True) device = device or pick_device() logger.info("Separating %s with %s on %s", input_path.name, model, device) separator = Separator( model=model, device=device, shifts=shifts, overlap=overlap, callback=_demucs_progress_adapter(on_progress) if on_progress else None, ) start = time.perf_counter() _origin, separated = separator.separate_audio_file(input_path) elapsed = time.perf_counter() - start logger.info("Separation finished in %.1fs", elapsed) written: dict[str, Path] = {} for stem, tensor in separated.items(): out_path = output_dir / f"{stem}.{output_format}" if output_format == "mp3": save_audio(tensor, str(out_path), samplerate=separator.samplerate, bitrate=320) else: save_audio(tensor, str(out_path), samplerate=separator.samplerate) written[stem] = out_path logger.info("Wrote %s", out_path) missing = set(STEMS) - set(written) if missing: raise RuntimeError(f"Model did not produce expected stems: {missing}") return written def main() -> None: parser = argparse.ArgumentParser(description="Separate a stereo track into 4 stems with Demucs.") parser.add_argument("input", help="Path to .mp3/.wav file") parser.add_argument("-o", "--output", default="separated", help="Output directory (default: separated/)") parser.add_argument("-m", "--model", default=DEFAULT_MODEL, help=f"Demucs model (default: {DEFAULT_MODEL})") parser.add_argument("-d", "--device", default=None, help="torch device: cuda | mps | cpu (default: auto)") parser.add_argument( "--shifts", type=int, default=0, help="random time-shift passes to average (reduces artifacts, each adds a full pass; default: 0)", ) parser.add_argument( "--overlap", type=float, default=0.25, help="segment overlap 0-0.99 (higher = smoother seams, slower; default: 0.25)", ) args = parser.parse_args() logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") last_shown = -1 def show_progress(fraction: float) -> None: nonlocal last_shown pct = int(fraction * 100) if pct >= last_shown + 5: # print every 5% last_shown = pct print(f" progress: {pct}%", flush=True) stems = separate( args.input, args.output, model=args.model, device=args.device, shifts=args.shifts, overlap=args.overlap, on_progress=show_progress, ) print("\nDone. Stems written:") for name, path in stems.items(): print(f" {name:>7}: {path}") if __name__ == "__main__": main()