#!/usr/bin/env python3
from __future__ import annotations
import hashlib
import html
import importlib.metadata
import json
import os
import platform
import re
import shutil
import subprocess
import sys
import sysconfig
import traceback
import zipfile
from collections import defaultdict, deque
from pathlib import Path
from urllib.parse import quote
APP_DIR = Path(__file__).resolve().parent
OUTPUT_ROOT = APP_DIR / "separated"
WORK_ROOT = APP_DIR / ".stem_work"
MODEL_BY_MODE = {
"2": "roformer-model-bs-roformer-leap-vocals-by-pcunwa",
"4": "roformer-model-bs-roformer-musdb18hq-by-zfturbo",
"6": "roformer-model-bs-roformer-sw-by-jarredou",
"53": "roformer-model-bs-roformer-mvsep-mega-53-stems",
}
EXPECTED_STEMS = {
"2": ["vocals", "background"],
"4": ["vocals", "drums", "bass", "other"],
"6": ["vocals", "drums", "bass", "guitar", "piano", "other"],
}
MODE_CHOICES = [
"Voice + music — 2 tracks",
"Core instruments — 4 tracks",
"Full band — 6 tracks",
"Every detail — 53 tracks",
]
MODE_BY_CHOICE = dict(zip(MODE_CHOICES, ["2", "4", "6", "53"]))
def _run(cmd: list[str], cwd: Path | None = None) -> None:
print("+", " ".join(map(str, cmd)), flush=True)
subprocess.run([str(item) for item in cmd], cwd=cwd, check=True)
def validate_runtime() -> tuple[str, str]:
try:
version = importlib.metadata.version("bs-roformer-infer")
except importlib.metadata.PackageNotFoundError as exc:
raise RuntimeError(
"Dependencies are not installed. Run: python -m pip install -r requirements.txt"
) from exc
backend = "mlx" if sys.platform == "darwin" and platform.machine() == "arm64" else "torch"
return version, backend
RUNTIME_COMMIT, COMPUTE_BACKEND = validate_runtime()
import gradio as gr
import imageio_ffmpeg
import numpy as np
import soundfile as sf
if COMPUTE_BACKEND == "mlx":
import mlx.core as mx
else:
import torch
if os.environ.get("SPACE_ID"):
import spaces
else:
class _LocalSpaces:
@staticmethod
def GPU(*_args, **_kwargs):
return lambda fn: fn
spaces = _LocalSpaces()
from bs_roformer import MODEL_REGISTRY, ensure_model_assets
def sha256_file(path: Path, block_size: int = 8 * 1024 * 1024) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
while chunk := f.read(block_size):
h.update(chunk)
return h.hexdigest()
def safe_stem_name(name: str) -> str:
cleaned = "".join(c if c.isalnum() or c in "._-" else "_" for c in name)
cleaned = cleaned.strip("._")
return cleaned or "audio"
def normalized_token(path: Path) -> str:
return re.sub(r"[^a-z0-9]+", "_", path.stem.lower()).strip("_")
def output_stem_name(path: Path) -> str:
"""Derive the model's stem label from its rendered WAV filename."""
return re.sub(r"^input[_ -]", "", path.stem.lower())
def best_match(stem_name: str, paths: list[Path]) -> Path | None:
stem = stem_name.lower()
matches = []
for path in paths:
token = normalized_token(path)
parts = token.split("_")
if stem in parts:
rank = 0
elif token.endswith(stem) or token.startswith(stem):
rank = 1
elif stem in token:
rank = 2
else:
continue
matches.append((rank, len(path.name), path))
if not matches:
return None
matches.sort(key=lambda x: (x[0], x[1]))
return matches[0][2]
def copy_atomic(src: Path, dst: Path) -> None:
dst.parent.mkdir(parents=True, exist_ok=True)
tmp = dst.with_suffix(dst.suffix + ".tmp")
tmp.unlink(missing_ok=True)
shutil.copy2(src, tmp)
tmp.replace(dst)
def stage_audio(input_audio: Path, input_hash: str) -> Path:
stage_dir = WORK_ROOT / "inputs" / input_hash[:16]
stage_dir.mkdir(parents=True, exist_ok=True)
stage_wav = stage_dir / "input.wav"
stage_meta = stage_dir / "input.json"
valid = False
if stage_wav.exists() and stage_meta.exists():
try:
meta = json.loads(stage_meta.read_text())
valid = meta.get("sha256") == input_hash
except Exception:
pass
if valid:
return stage_wav
ffmpeg = imageio_ffmpeg.get_ffmpeg_exe()
tmp_wav = stage_dir / "input.tmp.wav"
tmp_wav.unlink(missing_ok=True)
cmd = [
ffmpeg,
"-hide_banner",
"-loglevel",
"warning",
"-y",
"-i",
str(input_audio),
"-vn",
"-ac",
"2",
"-ar",
"44100",
"-c:a",
"pcm_f32le",
str(tmp_wav),
]
_run(cmd)
tmp_wav.replace(stage_wav)
stage_meta.write_text(json.dumps({
"source_name": input_audio.name,
"sha256": input_hash,
"sample_rate": 44100,
"channels": 2,
}, indent=2))
return stage_wav
def cli_path() -> Path:
found = shutil.which("bs-roformer-infer")
if found:
return Path(found)
candidate = Path(sysconfig.get_path("scripts")) / "bs-roformer-infer"
if candidate.exists():
return candidate
raise RuntimeError("Could not locate the bs-roformer-infer console script.")
def run_inference(stage_wav: Path, raw_dir: Path, model: str) -> None:
infer_input_dir = WORK_ROOT / "jobs" / stage_wav.parent.name / model / "input"
infer_input_dir.mkdir(parents=True, exist_ok=True)
staged_link = infer_input_dir / "input.wav"
if staged_link.exists() or staged_link.is_symlink():
staged_link.unlink()
try:
staged_link.symlink_to(stage_wav.resolve())
except OSError:
shutil.copy2(stage_wav, staged_link)
if raw_dir.exists():
shutil.rmtree(raw_dir)
raw_dir.mkdir(parents=True, exist_ok=True)
cmd = [
str(cli_path()),
"--input_folder",
str(infer_input_dir),
"--store_dir",
str(raw_dir),
"--backend",
COMPUTE_BACKEND,
]
if COMPUTE_BACKEND == "mlx":
checkpoint_path, config_path = mlx_compatible_assets(model)
cmd.extend([
"--model_path",
str(checkpoint_path),
"--config_path",
str(config_path),
])
else:
cmd.extend(["--model", model])
print("+", " ".join(cmd), flush=True)
tail: deque[str] = deque(maxlen=120)
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
)
assert process.stdout is not None
for line in process.stdout:
line = line.rstrip()
if line:
print(line, flush=True)
tail.append(line)
return_code = process.wait()
if return_code != 0:
raise RuntimeError(
f"bs-roformer-infer exited with code {return_code}.\n\n"
+ "\n".join(tail)
)
def ensure_healthy_model_assets(model: str) -> None:
checkpoint, _ = ensure_model_assets(model)
if zipfile.is_zipfile(checkpoint):
return
print(f"Cached checkpoint is corrupt; downloading a fresh copy: {checkpoint}")
checkpoint.unlink(missing_ok=True)
ensure_model_assets(model)
def mlx_compatible_assets(model: str) -> tuple[Path, Path]:
"""Return model assets, patching MLX-incompatible chunk sizes when needed."""
checkpoint, config_path = map(Path, ensure_model_assets(model))
config_text = config_path.read_text()
chunk_matches = list(re.finditer(
r"(?m)^([ \t]*chunk_size:[ \t]*)(\d+)([^\n]*)$",
config_text,
))
hop_match = re.search(
r"(?m)^[ \t]*stft_hop_length:[ \t]*(\d+)",
config_text,
)
if not chunk_matches or hop_match is None:
raise RuntimeError("The detailed separation settings are incomplete.")
# Published configs keep the chunked-inference setting in the audio section.
# Use the first entry instead of the model's unrelated training metadata.
chunk_match = chunk_matches[0]
chunk_size = int(chunk_match.group(2))
hop_length = int(hop_match.group(1))
if chunk_size % hop_length == 0:
return checkpoint, config_path
aligned_chunk_size = chunk_size - (chunk_size % hop_length)
patched_text = (
config_text[:chunk_match.start(2)]
+ str(aligned_chunk_size)
+ config_text[chunk_match.end(2):]
)
patched_dir = WORK_ROOT / "configs"
patched_dir.mkdir(parents=True, exist_ok=True)
patched_path = patched_dir / f"{safe_stem_name(model)}.mlx.yaml"
if not patched_path.exists() or patched_path.read_text() != patched_text:
patched_path.write_text(patched_text)
return checkpoint, patched_path
def normalize_outputs(
mode: str,
stage_wav: Path,
raw_dir: Path,
job_dir: Path,
) -> tuple[list[Path], dict[str, Path]]:
raw_wavs = sorted(raw_dir.rglob("*.wav"))
if not raw_wavs:
raise RuntimeError(f"No WAV outputs found in {raw_dir}")
previews: dict[str, Path] = {}
if mode == "2":
final_dir = job_dir / "stems"
final_dir.mkdir(parents=True, exist_ok=True)
vocals_src = best_match("vocals", raw_wavs)
if vocals_src is None:
raise RuntimeError(
"Could not identify the vocals output.\n"
+ "\n".join(str(p) for p in raw_wavs)
)
vocals_dst = final_dir / "01_vocals.wav"
background_dst = final_dir / "02_background.wav"
copy_atomic(vocals_src, vocals_dst)
instrumental_src = (
best_match("instrumental", raw_wavs)
or best_match("background", raw_wavs)
)
if instrumental_src is not None:
copy_atomic(instrumental_src, background_dst)
else:
mix, sr_mix = sf.read(stage_wav, dtype="float32", always_2d=True)
vocals, sr_vocals = sf.read(vocals_src, dtype="float32", always_2d=True)
if sr_mix != sr_vocals:
raise RuntimeError(
f"Sample-rate mismatch: mixture={sr_mix}, vocals={sr_vocals}"
)
n = min(len(mix), len(vocals))
background = mix[:n] - vocals[:n]
sf.write(background_dst, background, sr_mix, subtype="FLOAT")
files = [vocals_dst, background_dst]
previews = {
"vocals": vocals_dst,
"background": background_dst,
}
return files, previews
if mode in {"4", "6"}:
final_dir = job_dir / "stems"
final_dir.mkdir(parents=True, exist_ok=True)
files = []
for index, stem in enumerate(EXPECTED_STEMS[mode], 1):
src = best_match(stem, raw_wavs)
if src is None:
raise RuntimeError(
f"Could not identify stem {stem!r}.\n"
+ "\n".join(str(p) for p in raw_wavs)
)
dst = final_dir / f"{index:02d}_{stem}.wav"
copy_atomic(src, dst)
files.append(dst)
previews[stem] = dst
return files, previews
# The detailed model emits a distinct WAV for every one of its 53 stems.
# Keep every source in the mixer instead of showing only a small preview.
for src in raw_wavs:
stem = output_stem_name(src)
if stem in previews:
stem = src.stem.lower()
previews[stem] = src
return raw_wavs, previews
def make_zip(files: list[Path], job_dir: Path) -> Path:
zip_path = job_dir / "stems.zip"
tmp_zip = job_dir / "stems.zip.tmp"
tmp_zip.unlink(missing_ok=True)
with zipfile.ZipFile(tmp_zip, "w", compression=zipfile.ZIP_STORED) as zf:
seen: set[str] = set()
for path in files:
arcname = path.name
if arcname in seen:
arcname = f"{path.parent.name}_{path.name}"
seen.add(arcname)
zf.write(path, arcname=arcname)
tmp_zip.replace(zip_path)
return zip_path
STEM_LABELS = {
"vocals": "Vocals",
"background": "Background",
"drums": "Drums",
"bass": "Bass",
"guitar": "Guitar",
"piano": "Piano",
"other": "Other",
"synth": "Synth",
}
GROUP_LABELS = {
"vocals": "Vocals",
"rhythm": "Rhythm",
"guitars_keys": "Guitars & keys",
"orchestral_winds": "Orchestral & winds",
"other": "Other instruments",
}
STEM_GROUPS = {
"vocals": {"vocal", "vocals", "lead-vocal", "back-vocal"},
"rhythm": {"drums", "kick", "snare", "toms", "hh", "percussion", "congas", "tambourine", "timpani", "triangle", "bass", "double-bass"},
"guitars_keys": {"acoustic-guitar", "electric-guitar", "guitar", "banjo", "dobro", "mandolin", "ukulele", "sitar", "harp", "piano", "digital-piano", "keys", "organ", "harpsichord", "accordion", "synth", "marimba", "glockenspiel", "bells"},
"orchestral_winds": {"bowed_strings", "strings", "cello", "viola", "violin", "brass", "trumpet", "trombone", "tuba", "french-horn", "flute", "clarinet", "oboe", "saxophone", "bassoon", "harmonica", "wind", "wind-chimes", "woodwind"},
}
# These broad predictions overlap the more specific instrument predictions in
# the same group. They are a convenient mix layer, not an extra source to add.
AGGREGATE_STEMS = {
"vocal", "vocals",
"drums", "percussion",
"guitar", "keys",
"bowed_strings", "strings", "brass", "wind", "woodwind",
}
ICON_PATHS = {
"sparkles": '
No playable stems were produced.
' controls = [] tracks = [] for group in GROUP_LABELS: group_stems = grouped.get(group) if not group_stems: continue group_label = GROUP_LABELS[group] escaped_group_label = html.escape(group_label) group_controls = [] group_tracks = [] for stem, path in sorted(group_stems, key=lambda item: stem_label(item[0])): label = stem_label(stem) url = "/gradio_api/file=" + quote(str(path.resolve()), safe="/") escaped_label = html.escape(label) escaped_filename = html.escape(path.name, quote=True) icon_name = stem if stem in ICON_PATHS else "other" aggregate = detailed_mode and stem in AGGREGATE_STEMS state = "false" if aggregate else "true" action = "Unmute" if aggregate else "Mute" button_icon = "speaker-muted" if aggregate else "speaker" button_text = "Muted" if aggregate else "On" tier = "aggregate" if aggregate else "individual" group_controls.append( f'Upload a song, separate it, and shape the mix.
Your tracks will appear here.
" ) with gr.Tab("Downloads"): files_output = gr.File( label="Tracks", file_count="multiple", ) zip_output = gr.File( label="All tracks (.zip)", file_count="single", ) outputs = [ status_output, files_output, zip_output, mixer_output, ] audio_input.change( fn=lambda: None, inputs=[], outputs=[], js=""" () => { document.querySelectorAll("audio").forEach((player) => { player.pause(); player.currentTime = 0; }); } """, ) run_button.click( fn=separate, inputs=[audio_input, mode_input, force_input], outputs=outputs, concurrency_limit=1, ) if __name__ == "__main__": is_hf_space = bool(os.environ.get("SPACE_ID")) default_server_name = "0.0.0.0" if is_hf_space else "127.0.0.1" server_name = os.environ.get("GRADIO_SERVER_NAME", default_server_name) server_port = int(os.environ.get("GRADIO_SERVER_PORT", "7860")) share = os.environ.get("GRADIO_SHARE", "").lower() in {"1", "true", "yes", "on"} print(f"bs-roformer-infer commit: {RUNTIME_COMMIT}") print(f"Opening Gradio on http://{server_name}:{server_port}") demo.queue(default_concurrency_limit=1, max_size=8).launch( server_name=server_name, server_port=server_port, share=share, inbrowser=not is_hf_space, allowed_paths=[str(OUTPUT_ROOT.resolve())], theme=APP_THEME, css=MIXER_CSS, head=MIXER_HEAD, )