Spaces:
Running
Running
feat(backend): demucs two-stem -> mp3 adapter
Browse files- separate.py +42 -0
- tests/test_separate.py +21 -0
separate.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import subprocess
|
| 2 |
+
import sys
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from processor import _get_device
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def _run_demucs(audio_path: Path) -> Path:
|
| 8 |
+
"""Run demucs in two-stems mode targeting vocals; return path to no_vocals stem."""
|
| 9 |
+
out_root = audio_path.parent / "demucs_out"
|
| 10 |
+
out_root.mkdir(parents=True, exist_ok=True)
|
| 11 |
+
cmd = [
|
| 12 |
+
sys.executable,
|
| 13 |
+
"-m",
|
| 14 |
+
"demucs",
|
| 15 |
+
"--two-stems", "vocals",
|
| 16 |
+
"-n", "htdemucs",
|
| 17 |
+
"--device", _get_device(),
|
| 18 |
+
"--mp3",
|
| 19 |
+
"--mp3-bitrate", "192",
|
| 20 |
+
"-o", str(out_root),
|
| 21 |
+
str(audio_path),
|
| 22 |
+
]
|
| 23 |
+
subprocess.run(cmd, check=True)
|
| 24 |
+
# demucs writes to {out_root}/htdemucs/{audio_stem}/no_vocals.mp3
|
| 25 |
+
candidate = out_root / "htdemucs" / audio_path.stem / "no_vocals.mp3"
|
| 26 |
+
if not candidate.exists():
|
| 27 |
+
raise RuntimeError(f"demucs did not produce {candidate}")
|
| 28 |
+
return candidate
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _encode_mp3(wav_path: Path, mp3_path: Path) -> Path:
|
| 32 |
+
"""Re-encode (or copy) a stem file to the target mp3_path."""
|
| 33 |
+
subprocess.run(
|
| 34 |
+
["ffmpeg", "-y", "-i", str(wav_path), "-b:a", "192k", str(mp3_path)],
|
| 35 |
+
check=True, capture_output=True,
|
| 36 |
+
)
|
| 37 |
+
return mp3_path
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def separate_vocals_to_mp3(audio_path: Path) -> Path:
|
| 41 |
+
no_vocals_stem = _run_demucs(audio_path)
|
| 42 |
+
return _encode_mp3(no_vocals_stem, audio_path.parent / "no_vocals.mp3")
|
tests/test_separate.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from unittest.mock import patch, MagicMock
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
from separate import separate_vocals_to_mp3
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
@patch("separate._run_demucs")
|
| 7 |
+
@patch("separate._encode_mp3")
|
| 8 |
+
def test_separate_returns_mp3_path(mock_encode, mock_demucs, tmp_path):
|
| 9 |
+
audio = tmp_path / "in.m4a"
|
| 10 |
+
audio.write_bytes(b"x")
|
| 11 |
+
no_vocals_wav = tmp_path / "no_vocals.wav"
|
| 12 |
+
no_vocals_wav.write_bytes(b"y")
|
| 13 |
+
mock_demucs.return_value = no_vocals_wav
|
| 14 |
+
out_mp3 = tmp_path / "no_vocals.mp3"
|
| 15 |
+
mock_encode.return_value = out_mp3
|
| 16 |
+
|
| 17 |
+
result = separate_vocals_to_mp3(audio)
|
| 18 |
+
|
| 19 |
+
assert result == out_mp3
|
| 20 |
+
mock_demucs.assert_called_once_with(audio)
|
| 21 |
+
mock_encode.assert_called_once_with(no_vocals_wav, audio.parent / "no_vocals.mp3")
|