From 322b7a3723d4c826bd87c908261eb4297b4f051f Mon Sep 17 00:00:00 2001 From: Lee Saenz <3975533+leesaenz@users.noreply.github.com> Date: Sat, 11 Jul 2026 02:34:57 -0500 Subject: [PATCH 1/5] feat(separation): add phantom-audio-separation workspace package New sibling distribution carrying the Demucs/PyTorch dependency tree (issue #7). The implementation moves verbatim from phantom/separation.py into packages/phantom-audio-separation (phantom_separation.demucs_backend), registered under the phantom.separation entry-point group. Behavior tests move with it (all demucs mocked via sys.modules -- no torch execution). Root package wiring: - [tool.uv.workspace] members = packages/*, workspace source for the plugin - [separation] extra becomes a backward-compatible meta-installer pulling phantom-audio-separation; core deps stay torch-free - dev extra gains tomli for the py3.10 metadata test - uv.lock regenerated (workspace cycle phantom-audio[separation] -> phantom-audio-separation -> phantom-audio resolves cleanly) Co-Authored-By: Claude Fable 5 --- packages/phantom-audio-separation/README.md | 49 +++ .../phantom-audio-separation/pyproject.toml | 51 +++ .../src/phantom_separation/__init__.py | 18 ++ .../src/phantom_separation/demucs_backend.py | 143 +++++++++ .../tests/conftest.py | 48 +++ .../tests/test_demucs_backend.py | 297 ++++++++++++++++++ pyproject.toml | 13 +- uv.lock | 39 ++- 8 files changed, 646 insertions(+), 12 deletions(-) create mode 100644 packages/phantom-audio-separation/README.md create mode 100644 packages/phantom-audio-separation/pyproject.toml create mode 100644 packages/phantom-audio-separation/src/phantom_separation/__init__.py create mode 100644 packages/phantom-audio-separation/src/phantom_separation/demucs_backend.py create mode 100644 packages/phantom-audio-separation/tests/conftest.py create mode 100644 packages/phantom-audio-separation/tests/test_demucs_backend.py diff --git a/packages/phantom-audio-separation/README.md b/packages/phantom-audio-separation/README.md new file mode 100644 index 0000000..58c1bbe --- /dev/null +++ b/packages/phantom-audio-separation/README.md @@ -0,0 +1,49 @@ +# phantom-audio-separation + +Demucs-based stem separation plugin for [phantom-audio](https://github.com/fadelabs/phantom). + +Splits a stereo mix into individual stems (vocals, drums, bass, other) using +Meta's Hybrid Transformer Demucs model. This package carries the heavyweight +PyTorch + Demucs dependency tree (~2.5 GB) so the core `phantom-audio` +analysis library stays lean and independent of PyTorch's release cadence and +platform-support matrix. + +## Install + +```bash +# Recommended: via the phantom-audio meta-extra +uv tool install "phantom-audio[separation]" --python 3.13 + +# Or directly +uv pip install phantom-audio-separation +``` + +## Usage + +Once installed, `phantom-audio` discovers this plugin automatically through +the `phantom.separation` entry-point group -- no configuration needed. The +existing APIs keep working unchanged: + +```python +from phantom import separate_stems + +result = separate_stems("mix.wav", "./stems") +print(result.stems) # {"vocals": ".../vocals.wav", "drums": ..., ...} +``` + +Or from the CLI / MCP server: + +```bash +phantom separate mix.wav --output ./stems/ +``` + +`phantom doctor` reports `phantom-audio-separation` as OK only when the +plugin is installed and importable. + +> **Note:** First use downloads the htdemucs model (~80 MB). Subsequent +> calls use the cached model. + +## License + +AGPL-3.0-or-later, same as phantom-audio. See the +[repository LICENSE](https://github.com/fadelabs/phantom/blob/main/LICENSE). diff --git a/packages/phantom-audio-separation/pyproject.toml b/packages/phantom-audio-separation/pyproject.toml new file mode 100644 index 0000000..f713109 --- /dev/null +++ b/packages/phantom-audio-separation/pyproject.toml @@ -0,0 +1,51 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "phantom-audio-separation" +version = "1.4.0" +description = "Demucs-based stem separation plugin for phantom-audio" +readme = "README.md" +license = "AGPL-3.0-or-later" +requires-python = ">=3.10,<3.14" +authors = [ + { name = "Phantom Contributors" }, +] +keywords = [ + "audio", "stem-separation", "demucs", "source-separation", + "audio-engineering", "phantom", +] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Intended Audience :: Other Audience", + "Topic :: Multimedia :: Sound/Audio :: Analysis", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Operating System :: OS Independent", +] +dependencies = [ + "phantom-audio>=1.3.1", + "demucs>=4.0", + "torch>=2.0", + "torchaudio>=2.0", +] + +[project.entry-points."phantom.separation"] +demucs = "phantom_separation.demucs_backend:separate_stems" + +[project.urls] +Homepage = "https://github.com/fadelabs/phantom" +Documentation = "https://github.com/fadelabs/phantom#readme" +Repository = "https://github.com/fadelabs/phantom" +Issues = "https://github.com/fadelabs/phantom/issues" + +[tool.hatch.build.targets.wheel] +packages = ["src/phantom_separation"] + +[tool.uv.sources] +phantom-audio = { workspace = true } diff --git a/packages/phantom-audio-separation/src/phantom_separation/__init__.py b/packages/phantom-audio-separation/src/phantom_separation/__init__.py new file mode 100644 index 0000000..04fff27 --- /dev/null +++ b/packages/phantom-audio-separation/src/phantom_separation/__init__.py @@ -0,0 +1,18 @@ +"""Demucs-based stem separation plugin for phantom-audio. + +Exposes separate_stems() via the ``phantom.separation`` entry-point group; +phantom-audio's thin shim (phantom.separation) discovers and dispatches to +it automatically when this package is installed. +""" + +try: + from importlib.metadata import PackageNotFoundError + from importlib.metadata import version as _metadata_version + + __version__ = _metadata_version("phantom-audio-separation") +except PackageNotFoundError: + __version__ = "unknown" + +from phantom_separation.demucs_backend import separate_stems + +__all__ = ["separate_stems", "__version__"] diff --git a/packages/phantom-audio-separation/src/phantom_separation/demucs_backend.py b/packages/phantom-audio-separation/src/phantom_separation/demucs_backend.py new file mode 100644 index 0000000..fe0854c --- /dev/null +++ b/packages/phantom-audio-separation/src/phantom_separation/demucs_backend.py @@ -0,0 +1,143 @@ +"""Source separation via Demucs. + +Provides separate_stems() for splitting a stereo mix into individual stems +(vocals, drums, bass, other) using Meta's Hybrid Transformer Demucs model. + +This is the implementation behind the ``phantom.separation`` entry point; +phantom-audio's shim (phantom.separation) dispatches here when this package +is installed. Demucs itself is still imported lazily inside the function +body so a broken torch install fails at call time with a clear error, not +at plugin discovery time. +""" + +from __future__ import annotations + +import hashlib +import os +import re +from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout + +from phantom.exceptions import AnalysisError, AudioLoadError, DependencyMissingError +from phantom.separation import SeparationResult +from phantom._utils import ( + enforce_decode_limits, + validate_input_path, + validate_output_path, + wrap_errors, +) + + +@wrap_errors("Source separation failed") +def separate_stems(input_path: str, output_dir: str) -> SeparationResult: + """Separate a stereo mix into individual stems using Demucs. + + Uses the htdemucs model to split audio into vocals, drums, bass, and + other stems. Each stem is saved as a WAV file in the output directory. + + Note: First use downloads the htdemucs model (~80 MB). Subsequent + calls use the cached model. + + Args: + input_path: Path to the input WAV file to separate. + output_dir: Directory where stem WAV files will be written. + Created if it does not exist. + + Returns: + SeparationResult model with stems dict mapping stem names to + output file paths: + {"vocals": "/out/vocals.wav", "drums": "/out/drums.wav", + "bass": "/out/bass.wav", "other": "/out/other.wav"} + + Raises: + DependencyMissingError: If Demucs is not importable (per D-06). + PathSecurityError: If output_dir is outside PHANTOM_OUTPUT_DIR (when set). + FileNotFoundError: If input file does not exist. + AnalysisError: If separation processing fails. + """ + # Step 0: Validate paths against security restrictions (D-09, D-10) + output_dir = validate_output_path(output_dir) + input_path = validate_input_path(input_path) + + # Step 1: Guard -- import demucs inside function body (per D-06, SEP-02) + try: + from demucs.pretrained import get_model + from demucs.apply import apply_model + from demucs.audio import AudioFile + import torch + import soundfile as sf + except ImportError: + raise DependencyMissingError( + package="Demucs", + extra="separation", + detail=( + "Demucs provides AI-powered source separation into " + "vocals, drums, bass, and other stems." + ), + ) + + # Step 2: Validate input file exists + if not os.path.isfile(input_path): + raise AudioLoadError(f"Input file not found: {os.path.basename(input_path)}") + + # Step 2.5: Decode-bomb guard -- reject over-long/oversized input before + # demucs decodes it (Advisory 2). Mirrors load_audio's sf.info pre-check. + enforce_decode_limits(input_path) + + # Step 3: Ensure output directory exists + os.makedirs(output_dir, exist_ok=True) + + # Step 4: Load model and audio (per D-03: htdemucs only) + model = get_model("htdemucs") + model.cpu() + + wav = AudioFile(input_path).read( + streams=0, + samplerate=model.samplerate, + channels=model.audio_channels, + ) + ref = wav.mean(0) + ref_std = float(ref.std()) + if ref_std < 1e-12: + raise AnalysisError( + "Cannot separate a silent file — the input has no audible signal." + ) + wav = (wav - ref.mean()) / ref_std + + # Step 5: Run separation (with timeout to prevent indefinite hangs) + _SEPARATION_TIMEOUT = 600 # 10 minutes max for any file + + def _run_model(): + with torch.no_grad(): + return apply_model(model, wav[None], progress=False) + + with ThreadPoolExecutor(max_workers=1) as executor: + future = executor.submit(_run_model) + try: + sources = future.result(timeout=_SEPARATION_TIMEOUT) + except FuturesTimeout: + raise AnalysisError( + f"Source separation timed out after {_SEPARATION_TIMEOUT}s. " + "Try a shorter audio file." + ) + sources = sources[0] + sources = sources * ref.std() + ref.mean() + + # Step 6: Save each stem as WAV (per D-01, D-02) + _SAFE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]{0,63}$") + result = {} + for i, stem_name in enumerate(model.sources): + safe_name = os.path.basename(stem_name) + if not _SAFE_NAME_RE.match(safe_name): + safe_name = f"stem_{int(hashlib.sha256(stem_name.encode()).hexdigest()[:8], 16) % 10000}" + stem_path = os.path.join(output_dir, f"{safe_name}.wav") + real_stem = os.path.realpath(stem_path) + real_outdir = os.path.realpath(output_dir) + if not real_stem.startswith(real_outdir + os.sep) and real_stem != real_outdir: + raise AnalysisError( + f"Stem name '{stem_name}' would write outside output directory" + ) + stem_audio = sources[i].cpu().numpy().T + sf.write(stem_path, stem_audio, model.samplerate) + result[stem_name] = stem_path + + return SeparationResult(stems=result) diff --git a/packages/phantom-audio-separation/tests/conftest.py b/packages/phantom-audio-separation/tests/conftest.py new file mode 100644 index 0000000..66b60f4 --- /dev/null +++ b/packages/phantom-audio-separation/tests/conftest.py @@ -0,0 +1,48 @@ +"""Fixtures for phantom-audio-separation tests. + +Mirrors the root test suite's conventions: synthetic in-memory audio only, +writes confined to each test's tmp_path. +""" + +import numpy as np +import pytest +import soundfile as sf + + +@pytest.fixture(autouse=True) +def _confine_writes_to_tmp(tmp_path, monkeypatch): + """Confine writes to each test's tmp_path by default. + + Phantom confines all file writes to PHANTOM_OUTPUT_DIR (default + ~/.phantom/output). Point it at the per-test tmp_path so write-path + tests keep their outputs inside the sandbox. + """ + monkeypatch.setenv("PHANTOM_OUTPUT_DIR", str(tmp_path)) + + +@pytest.fixture +def wav_file_factory(tmp_path): + """Factory fixture: write a numpy array to a temporary WAV file and return path.""" + _counter = [0] + + def _make(samples, sr=44100): + _counter[0] += 1 + path = tmp_path / f"test_{_counter[0]}.wav" + sf.write(str(path), samples, sr) + return str(path) + + return _make + + +@pytest.fixture +def stereo_sine_input(wav_file_factory): + """A 1-second stereo 440 Hz sine WAV file path.""" + sr = 44100 + t = np.linspace(0, 1.0, sr, endpoint=False, dtype=np.float32) + samples = np.column_stack( + [ + 0.5 * np.sin(2 * np.pi * 440 * t), + 0.5 * np.sin(2 * np.pi * 440 * t), + ] + ).astype(np.float32) + return wav_file_factory(samples, sr) diff --git a/packages/phantom-audio-separation/tests/test_demucs_backend.py b/packages/phantom-audio-separation/tests/test_demucs_backend.py new file mode 100644 index 0000000..088dddf --- /dev/null +++ b/packages/phantom-audio-separation/tests/test_demucs_backend.py @@ -0,0 +1,297 @@ +"""Tests for the Demucs separation backend (moved from phantom-audio). + +Covers SEP-01 through SEP-03 behavior of the implementation that now lives +in phantom-audio-separation. All tests mock Demucs via sys.modules so no +real torch/demucs execution happens -- they run identically whether or not +the heavyweight dependencies are installed. + +Skipped entirely when phantom_separation is not importable (e.g. in the +core test environment where the plugin is not installed). +""" + +import importlib +import os + +import numpy as np +import pytest +from unittest.mock import patch, MagicMock + +pytest.importorskip("phantom_separation") + +from phantom.exceptions import ( # noqa: E402 + AnalysisError, + AudioLoadError, + DependencyMissingError, + PathSecurityError, +) +from phantom.separation import SeparationResult # noqa: E402 +from phantom_separation.demucs_backend import separate_stems # noqa: E402 + + +def _make_demucs_mocks(samplerate=44100): + """Create mocks for demucs.pretrained, demucs.apply, demucs.audio, torch.""" + mock_model = MagicMock() + mock_model.samplerate = samplerate + mock_model.audio_channels = 2 + mock_model.sources = ["drums", "bass", "other", "vocals"] + + mock_pretrained = MagicMock() + mock_pretrained.get_model.return_value = mock_model + + mock_apply_module = MagicMock() + # apply_model returns shape [batch, sources, channels, samples] + stem_tensor = MagicMock() + stem_audio = np.zeros((2, samplerate), dtype=np.float32) + stem_tensor.cpu.return_value.numpy.return_value.T = stem_audio.T + sources = MagicMock() + sources.__getitem__ = lambda self, i: stem_tensor + sources.shape = (1, 4, 2, samplerate) + batch_sources = MagicMock() + batch_sources.__getitem__ = lambda self, i: sources + mock_apply_module.apply_model.return_value = batch_sources + + wav_data = MagicMock() + wav_data.mean.return_value = MagicMock( + mean=MagicMock(return_value=0.0), + std=MagicMock(return_value=1.0), + ) + mock_audio_file_cls = MagicMock( + return_value=MagicMock( + read=MagicMock(return_value=wav_data), + ) + ) + mock_audio_module = MagicMock() + mock_audio_module.AudioFile = mock_audio_file_cls + + mock_torch = MagicMock() + mock_torch.no_grad.return_value.__enter__ = MagicMock(return_value=None) + mock_torch.no_grad.return_value.__exit__ = MagicMock(return_value=False) + + mock_sf = MagicMock() + + return { + "demucs": MagicMock(), + "demucs.pretrained": mock_pretrained, + "demucs.apply": mock_apply_module, + "demucs.audio": mock_audio_module, + "torch": mock_torch, + "soundfile": mock_sf, + "_model": mock_model, + "_pretrained": mock_pretrained, + "_apply": mock_apply_module, + "_sf": mock_sf, + } + + +def _demucs_patch(mocks): + """patch.dict context manager installing the demucs/torch mocks.""" + return patch.dict( + "sys.modules", + { + "demucs.pretrained": mocks["demucs.pretrained"], + "demucs.apply": mocks["demucs.apply"], + "demucs.audio": mocks["demucs.audio"], + "demucs": mocks["demucs"], + "torch": mocks["torch"], + }, + ) + + +class TestSeparateStems: + """Tests for Demucs-based source separation (SEP-01 through SEP-03).""" + + def test_missing_demucs_raises_dependency_error(self, monkeypatch, tmp_path): + """When demucs is not importable, DependencyMissingError is raised (SEP-03).""" + import builtins + import sys + + for mod in ["demucs", "demucs.pretrained", "demucs.apply", "demucs.audio"]: + monkeypatch.delitem(sys.modules, mod, raising=False) + + original_import = builtins.__import__ + + def _mock_import(name, *args, **kwargs): + if name.startswith("demucs"): + raise ImportError(f"No module named '{name}'") + return original_import(name, *args, **kwargs) + + monkeypatch.setattr("builtins.__import__", _mock_import) + + with pytest.raises(DependencyMissingError) as exc_info: + separate_stems("any.wav", str(tmp_path / "out")) + assert 'uv tool install "phantom-audio[separation]"' in str(exc_info.value) + + def test_missing_input_raises_file_not_found(self, tmp_path): + """AudioLoadError when input_path does not exist (SEP-01).""" + nonexistent = str(tmp_path / "nonexistent.wav") + output_dir = str(tmp_path / "stems") + mocks = _make_demucs_mocks() + with _demucs_patch(mocks): + with pytest.raises(AudioLoadError, match="Input file not found"): + separate_stems(nonexistent, output_dir) + + def test_successful_separation(self, tmp_path, stereo_sine_input): + """Successful separation returns dict mapping stem names to file paths (SEP-01).""" + output_dir = str(tmp_path / "stems") + + mocks = _make_demucs_mocks() + with _demucs_patch(mocks): + result = separate_stems(stereo_sine_input, output_dir) + + assert isinstance(result, SeparationResult) + assert set(result.stems.keys()) == {"vocals", "drums", "bass", "other"} + for stem_name, stem_path in result.stems.items(): + assert stem_path.endswith(f"{stem_name}.wav") + + def test_model_is_htdemucs(self, tmp_path, stereo_sine_input): + """get_model is called with 'htdemucs' (D-03).""" + output_dir = str(tmp_path / "stems") + + mocks = _make_demucs_mocks() + with _demucs_patch(mocks): + separate_stems(stereo_sine_input, output_dir) + + mocks["_pretrained"].get_model.assert_called_once_with("htdemucs") + + def test_output_dir_created(self, tmp_path, stereo_sine_input): + """Output directory is created if it does not exist.""" + output_dir = str(tmp_path / "deeply" / "nested" / "stems") + + assert not os.path.isdir(output_dir) + + mocks = _make_demucs_mocks() + with _demucs_patch(mocks): + separate_stems(stereo_sine_input, output_dir) + + assert os.path.isdir(output_dir) + + def test_demucs_error_wrapped_in_analysis_error(self, tmp_path, stereo_sine_input): + """Demucs internal error is wrapped in AnalysisError.""" + output_dir = str(tmp_path / "stems") + + mocks = _make_demucs_mocks() + mocks["_apply"].apply_model.side_effect = RuntimeError( + "Internal demucs failure" + ) + + with _demucs_patch(mocks): + with pytest.raises(AnalysisError, match="Source separation failed"): + separate_stems(stereo_sine_input, output_dir) + + def test_function_signature(self, tmp_path): + """separate_stems accepts exactly 2 positional args named input_path and output_dir (D-04).""" + mocks = _make_demucs_mocks() + with _demucs_patch(mocks): + with pytest.raises(AudioLoadError): + separate_stems( + input_path=str(tmp_path / "a.wav"), + output_dir=str(tmp_path / "out"), + ) + + def test_silent_input_raises_analysis_error(self, tmp_path, wav_file_factory): + """Fully-silent input (ref.std() == 0) raises a clean AnalysisError, not NaN (P-14). + + Without the zero-std guard, ``(wav - ref.mean()) / ref.std()`` divides by + zero and feeds NaNs into demucs. The guard must intercept it first with a + musician-friendly message. + """ + sr = 44100 + # A genuinely silent file: all zeros. (The mock's ref.std() is forced to + # 0.0 below so the guard fires regardless of demucs internals.) + samples = np.zeros((sr, 2), dtype=np.float32) + input_path = wav_file_factory(samples, sr) + output_dir = str(tmp_path / "stems") + + mocks = _make_demucs_mocks() + # Force the demucs reference channel to report zero standard deviation, + # simulating a fully-silent input reaching the normalization step. + mocks[ + "demucs.audio" + ].AudioFile.return_value.read.return_value.mean.return_value = MagicMock( + mean=MagicMock(return_value=0.0), + std=MagicMock(return_value=0.0), + ) + + with _demucs_patch(mocks): + with pytest.raises(AnalysisError, match="silent"): + separate_stems(input_path, output_dir) + + def test_import_without_demucs(self, monkeypatch): + """Importing the backend without demucs installed does not raise (SEP-02).""" + import builtins + import sys + import phantom_separation.demucs_backend + + for mod in ["demucs", "demucs.pretrained", "demucs.apply", "demucs.audio"]: + monkeypatch.delitem(sys.modules, mod, raising=False) + + original_import = builtins.__import__ + + def _mock_import(name, *args, **kwargs): + if name.startswith("demucs"): + raise ImportError(f"No module named '{name}'") + return original_import(name, *args, **kwargs) + + monkeypatch.setattr("builtins.__import__", _mock_import) + importlib.reload(phantom_separation.demucs_backend) + + +class TestDecodeLimits: + """Tests for the decode-bomb guard in separate_stems() (Advisory 2). + + demucs is mocked present so execution reaches the guard (which runs after + the dependency import but before any model load / decode). + """ + + def test_over_duration_input_rejected( + self, tmp_path, wav_file_factory, monkeypatch + ): + """An input longer than PHANTOM_MAX_DURATION is rejected before decode.""" + monkeypatch.setenv("PHANTOM_MAX_DURATION", "0.5") + input_path = wav_file_factory(np.zeros((44100 * 2, 2), dtype=np.float32)) # 2s + with _demucs_patch(_make_demucs_mocks()): + with pytest.raises(AudioLoadError, match="exceeds the"): + separate_stems(input_path, str(tmp_path / "stems")) + + def test_over_size_input_rejected(self, tmp_path, wav_file_factory, monkeypatch): + """An input larger than PHANTOM_MAX_FILE_SIZE is rejected before decode.""" + monkeypatch.setenv("PHANTOM_MAX_FILE_SIZE", "100") # 100 bytes + input_path = wav_file_factory(np.zeros((44100, 2), dtype=np.float32)) + with _demucs_patch(_make_demucs_mocks()): + with pytest.raises(AudioLoadError, match="exceeds the"): + separate_stems(input_path, str(tmp_path / "stems")) + + +class TestOutputDirValidation: + """Tests for PHANTOM_OUTPUT_DIR validation in separate_stems().""" + + def test_output_dir_rejected_when_outside(self, tmp_path, monkeypatch): + """separate_stems rejects output_dir outside PHANTOM_OUTPUT_DIR.""" + allowed = tmp_path / "allowed" + allowed.mkdir() + monkeypatch.setenv("PHANTOM_OUTPUT_DIR", str(allowed)) + input_file = tmp_path / "input.wav" + input_file.write_bytes(b"fake") + with pytest.raises(PathSecurityError, match="outside the allowed directory"): + separate_stems(str(input_file), str(tmp_path / "forbidden")) + + def test_output_dir_confined_to_default_when_unset(self, tmp_path, monkeypatch): + """With PHANTOM_OUTPUT_DIR unset, output_dir outside the default sandbox is rejected (Finding 1).""" + monkeypatch.delenv("PHANTOM_OUTPUT_DIR", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + with pytest.raises(PathSecurityError, match="outside the allowed directory"): + separate_stems("/nonexistent/input.wav", str(tmp_path / "elsewhere")) + + +class TestEntryPoint: + """The plugin registers the phantom.separation entry point correctly.""" + + def test_entry_point_registered_and_loads(self): + """The installed distribution exposes phantom.separation -> separate_stems.""" + from importlib.metadata import entry_points + + eps = list(entry_points(group="phantom.separation")) + if not eps: + pytest.skip("plugin not installed as a distribution (source-tree run)") + loaded = eps[0].load() + assert loaded is separate_stems diff --git a/pyproject.toml b/pyproject.toml index 61e395c..64585cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,16 +51,17 @@ dev = [ "pytest-timeout>=2.4", "pyloudnorm>=0.1.0", "hypothesis>=6.0", + "tomli>=2.0; python_version < '3.11'", "ruff>=0.15", "pre-commit>=4.0", ] analysis = [ "librosa>=0.11.0", ] +# Backward-compatible meta-installer: the Demucs/PyTorch implementation now +# lives in the sibling distribution phantom-audio-separation (issue #7). separation = [ - "demucs>=4.0", - "torch>=2.0", - "torchaudio>=2.0", + "phantom-audio-separation", ] matching = [ "matchering>=2.0", @@ -103,3 +104,9 @@ filterwarnings = [ dev = [ "midiutil>=1.2.1", ] + +[tool.uv.workspace] +members = ["packages/*"] + +[tool.uv.sources] +phantom-audio-separation = { workspace = true } diff --git a/uv.lock b/uv.lock index 79c2907..8e429c0 100644 --- a/uv.lock +++ b/uv.lock @@ -11,6 +11,12 @@ resolution-markers = [ "python_full_version < '3.11'", ] +[manifest] +members = [ + "phantom-audio", + "phantom-audio-separation", +] + [[package]] name = "aiofile" version = "3.9.0" @@ -1954,12 +1960,10 @@ dependencies = [ [package.optional-dependencies] all = [ - { name = "demucs" }, { name = "librosa" }, { name = "matchering" }, { name = "pedalboard" }, - { name = "torch" }, - { name = "torchaudio" }, + { name = "phantom-audio-separation" }, ] analysis = [ { name = "librosa" }, @@ -1972,6 +1976,7 @@ dev = [ { name = "pytest-asyncio" }, { name = "pytest-timeout" }, { name = "ruff" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, ] matching = [ { name = "matchering" }, @@ -1980,9 +1985,7 @@ processing = [ { name = "pedalboard" }, ] separation = [ - { name = "demucs" }, - { name = "torch" }, - { name = "torchaudio" }, + { name = "phantom-audio-separation" }, ] [package.dev-dependencies] @@ -1993,7 +1996,6 @@ dev = [ [package.metadata] requires-dist = [ { name = "click", specifier = ">=8.0" }, - { name = "demucs", marker = "extra == 'separation'", specifier = ">=4.0" }, { name = "essentia", specifier = "==2.1b6.dev1389" }, { name = "fastmcp", specifier = ">=3.2" }, { name = "ffmpeg-progress-yield", specifier = ">=0.7" }, @@ -2003,6 +2005,7 @@ requires-dist = [ { name = "numpy", specifier = ">=2.0" }, { name = "pedalboard", marker = "extra == 'processing'", specifier = ">=0.9" }, { name = "phantom-audio", extras = ["analysis", "separation", "matching", "processing"], marker = "extra == 'all'" }, + { name = "phantom-audio-separation", marker = "extra == 'separation'", editable = "packages/phantom-audio-separation" }, { name = "plotext", specifier = ">=5.3" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.0" }, { name = "pydantic", specifier = ">=2.0" }, @@ -2015,14 +2018,32 @@ requires-dist = [ { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15" }, { name = "scipy", specifier = ">=1.12" }, { name = "soundfile", specifier = ">=0.13.0" }, - { name = "torch", marker = "extra == 'separation'", specifier = ">=2.0" }, - { name = "torchaudio", marker = "extra == 'separation'", specifier = ">=2.0" }, + { name = "tomli", marker = "python_full_version < '3.11' and extra == 'dev'", specifier = ">=2.0" }, ] provides-extras = ["dev", "analysis", "separation", "matching", "processing", "all"] [package.metadata.requires-dev] dev = [{ name = "midiutil", specifier = ">=1.2.1" }] +[[package]] +name = "phantom-audio-separation" +version = "1.4.0" +source = { editable = "packages/phantom-audio-separation" } +dependencies = [ + { name = "demucs" }, + { name = "phantom-audio" }, + { name = "torch" }, + { name = "torchaudio" }, +] + +[package.metadata] +requires-dist = [ + { name = "demucs", specifier = ">=4.0" }, + { name = "phantom-audio", editable = "." }, + { name = "torch", specifier = ">=2.0" }, + { name = "torchaudio", specifier = ">=2.0" }, +] + [[package]] name = "platformdirs" version = "4.9.6" From 30b5906cd7a639799dbbe15937fcd4a6be713823 Mon Sep 17 00:00:00 2001 From: Lee Saenz <3975533+leesaenz@users.noreply.github.com> Date: Sat, 11 Jul 2026 02:35:15 -0500 Subject: [PATCH 2/5] refactor(separation): dispatch through the phantom.separation entry point phantom/separation.py becomes a thin shim: it keeps SeparationResult and the separate_stems signature, discovers an installed backend plugin via the phantom.separation entry-point group, and dispatches to it. With no plugin installed it raises DependencyMissingError with the unchanged install hint (uv tool install "phantom-audio[separation]"). Plugin detection replaces the direct demucs import probe everywhere: - _diagnostics: separation_plugin_status() reports OK only when the entry point is present AND loads; demucs dropped from OPTIONAL_DEPS - phantom doctor: phantom-audio-separation row via plugin detection - MCP _startup_preflight: separation=OK/missing via plugin detection - phantom setup: probes the plugin and installs phantom-audio-separation Co-Authored-By: Claude Fable 5 --- src/phantom/_diagnostics.py | 41 +++++++++- src/phantom/cli/doctor.py | 12 ++- src/phantom/cli/setup.py | 26 ++++--- src/phantom/separation.py | 149 +++++++++++------------------------- src/phantom/server.py | 8 +- 5 files changed, 118 insertions(+), 118 deletions(-) diff --git a/src/phantom/_diagnostics.py b/src/phantom/_diagnostics.py index 4ec9a77..0a9168f 100644 --- a/src/phantom/_diagnostics.py +++ b/src/phantom/_diagnostics.py @@ -13,13 +13,23 @@ "rich_click": "rich_click", } +# Optional deps probed by direct import. Stem separation is NOT probed this +# way anymore: it ships as the sibling plugin distribution +# phantom-audio-separation and is detected via separation_plugin_status(). OPTIONAL_DEPS = { - "demucs": "separation", "matchering": "matching", "pedalboard": "processing", "librosa": "analysis", } +#: Entry-point group that separation backend plugins register under. +#: Mirrors phantom.separation.SEPARATION_EP_GROUP (kept here too so +#: diagnostics never import the dispatch module). +SEPARATION_EP_GROUP = "phantom.separation" + +#: Distribution that provides the separation plugin (install hint / doctor row). +SEPARATION_PLUGIN_DIST = "phantom-audio-separation" + def try_import(name: str) -> tuple[bool, str]: """Try to import a package. Returns (success, version_or_error).""" @@ -29,3 +39,32 @@ def try_import(name: str) -> tuple[bool, str]: return True, str(version) except Exception as exc: return False, str(exc) + + +def separation_plugin_status() -> tuple[bool, str]: + """Check whether a separation backend plugin is installed and loadable. + + Returns (ok, version_or_error). ok is True only when an entry point in + the phantom.separation group is present AND its module imports + successfully -- a merely-present-but-broken plugin reports False so + ``phantom doctor`` never claims separation works when it does not. + """ + from importlib.metadata import entry_points, version as dist_version + + try: + eps = list(entry_points(group=SEPARATION_EP_GROUP)) + except Exception as exc: + return False, str(exc) + if not eps: + return False, "not installed" + ep = eps[0] + try: + ep.load() + except Exception as exc: + return False, f"plugin found but failed to load: {exc}" + try: + dist = getattr(ep, "dist", None) + name = dist.name if dist is not None else SEPARATION_PLUGIN_DIST + return True, str(dist_version(name)) + except Exception: + return True, "?" diff --git a/src/phantom/cli/doctor.py b/src/phantom/cli/doctor.py index a50505a..2a752b7 100644 --- a/src/phantom/cli/doctor.py +++ b/src/phantom/cli/doctor.py @@ -14,7 +14,13 @@ from rich.table import Table from phantom import __version__ -from phantom._diagnostics import CORE_DEPS, OPTIONAL_DEPS, try_import as _try_import +from phantom._diagnostics import ( + CORE_DEPS, + OPTIONAL_DEPS, + SEPARATION_PLUGIN_DIST, + separation_plugin_status, + try_import as _try_import, +) from phantom.cli._formatting import get_console, output_json from phantom.exceptions import RECOMMENDED_PYTHON @@ -67,7 +73,11 @@ def _collect_results() -> dict: results["core_deps"] = core # 3. Optional deps + # Separation is a sibling plugin distribution detected via entry points; + # OK only when the plugin actually loads (issue #7). optional = {} + sep_ok, sep_ver = separation_plugin_status() + optional[SEPARATION_PLUGIN_DIST] = {"ok": sep_ok, "version": sep_ver} for pkg in OPTIONAL_DEPS: ok, ver = _try_import(pkg) optional[pkg] = {"ok": ok, "version": ver} diff --git a/src/phantom/cli/setup.py b/src/phantom/cli/setup.py index 56b6673..68fad04 100644 --- a/src/phantom/cli/setup.py +++ b/src/phantom/cli/setup.py @@ -241,14 +241,22 @@ def setup(json_output: bool, skip_reaper: bool, skip_plugin: bool) -> None: } missing_extras = [] for extra, (label, note) in extras.items(): - try: - if extra == "separation": - import demucs # noqa: F401 - elif extra == "matching": - import matchering # noqa: F401 - elif extra == "processing": - import pedalboard # noqa: F401 - except ImportError: + if extra == "separation": + # Separation is a sibling plugin distribution detected via + # entry points, not a direct import (issue #7). + from phantom._diagnostics import separation_plugin_status + + installed, _ = separation_plugin_status() + else: + try: + if extra == "matching": + import matchering # noqa: F401 + elif extra == "processing": + import pedalboard # noqa: F401 + installed = True + except ImportError: + installed = False + if not installed: suffix = f" ({note})" if note else "" missing_extras.append((extra, f"{label}{suffix}")) @@ -262,7 +270,7 @@ def setup(json_output: bool, skip_reaper: bool, skip_plugin: bool) -> None: ): # Map extras to their actual package names extra_packages = { - "separation": "demucs", + "separation": "phantom-audio-separation", "matching": "matchering", "processing": "pedalboard", } diff --git a/src/phantom/separation.py b/src/phantom/separation.py index 769ee4e..e8fd661 100644 --- a/src/phantom/separation.py +++ b/src/phantom/separation.py @@ -1,28 +1,27 @@ -"""Source separation via Demucs. - -Provides separate_stems() for splitting a stereo mix into individual stems -(vocals, drums, bass, other) using Meta's Hybrid Transformer Demucs model. - -Demucs is an optional dependency. The function raises DependencyMissingError -with install instructions if Demucs is not available. +"""Stem separation dispatch shim. + +The Demucs implementation lives in the sibling distribution +``phantom-audio-separation`` (see packages/phantom-audio-separation) so the +core library carries no PyTorch dependency tree. This module keeps the +public API stable: it discovers an installed separation backend through the +``phantom.separation`` entry-point group and dispatches to it, or raises +DependencyMissingError with the familiar install hint when no plugin is +installed. The ``phantom-audio[separation]`` extra remains a meta-installer +that pulls in phantom-audio-separation. """ from __future__ import annotations -import hashlib -import os -import re -from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout +from importlib.metadata import entry_points +from typing import Callable, Optional from pydantic import BaseModel -from phantom.exceptions import AnalysisError, AudioLoadError, DependencyMissingError -from phantom._utils import ( - enforce_decode_limits, - validate_input_path, - validate_output_path, - wrap_errors, -) +from phantom.exceptions import DependencyMissingError +from phantom._utils import wrap_errors + +#: Entry-point group that separation backend plugins register under. +SEPARATION_EP_GROUP = "phantom.separation" class SeparationResult(BaseModel): @@ -31,15 +30,28 @@ class SeparationResult(BaseModel): stems: dict[str, str] +def _load_backend() -> Optional[Callable[[str, str], SeparationResult]]: + """Discover and load the first installed separation backend plugin. + + Returns the backend callable, or None when no plugin is installed. + Load errors from a present-but-broken plugin propagate to the caller + (wrap_errors turns them into AnalysisError with context). + """ + for ep in entry_points(group=SEPARATION_EP_GROUP): + return ep.load() + return None + + @wrap_errors("Source separation failed") def separate_stems(input_path: str, output_dir: str) -> SeparationResult: - """Separate a stereo mix into individual stems using Demucs. + """Separate a stereo mix into individual stems (vocals, drums, bass, other). - Uses the htdemucs model to split audio into vocals, drums, bass, and - other stems. Each stem is saved as a WAV file in the output directory. + Dispatches to the installed separation backend plugin (normally + phantom-audio-separation, which uses Meta's Hybrid Transformer Demucs + model). Each stem is saved as a WAV file in the output directory. - Demucs is an optional dependency. If not installed, a - DependencyMissingError is raised with install instructions. + Separation is an optional feature. If no backend plugin is installed, + a DependencyMissingError is raised with install instructions. Note: First use downloads the htdemucs model (~80 MB). Subsequent calls use the cached model. @@ -56,95 +68,20 @@ def separate_stems(input_path: str, output_dir: str) -> SeparationResult: "bass": "/out/bass.wav", "other": "/out/other.wav"} Raises: - DependencyMissingError: If Demucs is not installed (per D-06). + DependencyMissingError: If no separation plugin is installed (per D-06). PathSecurityError: If output_dir is outside PHANTOM_OUTPUT_DIR (when set). FileNotFoundError: If input file does not exist. AnalysisError: If separation processing fails. """ - # Step 0: Validate paths against security restrictions (D-09, D-10) - output_dir = validate_output_path(output_dir) - input_path = validate_input_path(input_path) - - # Step 1: Guard -- import demucs inside function body (per D-06, SEP-02) - try: - from demucs.pretrained import get_model - from demucs.apply import apply_model - from demucs.audio import AudioFile - import torch - import soundfile as sf - except ImportError: + backend = _load_backend() + if backend is None: raise DependencyMissingError( - package="Demucs", + package="phantom-audio-separation", extra="separation", detail=( - "Demucs provides AI-powered source separation into " - "vocals, drums, bass, and other stems." + "Stem separation ships as the sibling package " + "phantom-audio-separation, which provides AI-powered source " + "separation into vocals, drums, bass, and other stems." ), ) - - # Step 2: Validate input file exists - if not os.path.isfile(input_path): - raise AudioLoadError(f"Input file not found: {os.path.basename(input_path)}") - - # Step 2.5: Decode-bomb guard -- reject over-long/oversized input before - # demucs decodes it (Advisory 2). Mirrors load_audio's sf.info pre-check. - enforce_decode_limits(input_path) - - # Step 3: Ensure output directory exists - os.makedirs(output_dir, exist_ok=True) - - # Step 4: Load model and audio (per D-03: htdemucs only) - model = get_model("htdemucs") - model.cpu() - - wav = AudioFile(input_path).read( - streams=0, - samplerate=model.samplerate, - channels=model.audio_channels, - ) - ref = wav.mean(0) - ref_std = float(ref.std()) - if ref_std < 1e-12: - raise AnalysisError( - "Cannot separate a silent file — the input has no audible signal." - ) - wav = (wav - ref.mean()) / ref_std - - # Step 5: Run separation (with timeout to prevent indefinite hangs) - _SEPARATION_TIMEOUT = 600 # 10 minutes max for any file - - def _run_model(): - with torch.no_grad(): - return apply_model(model, wav[None], progress=False) - - with ThreadPoolExecutor(max_workers=1) as executor: - future = executor.submit(_run_model) - try: - sources = future.result(timeout=_SEPARATION_TIMEOUT) - except FuturesTimeout: - raise AnalysisError( - f"Source separation timed out after {_SEPARATION_TIMEOUT}s. " - "Try a shorter audio file." - ) - sources = sources[0] - sources = sources * ref.std() + ref.mean() - - # Step 6: Save each stem as WAV (per D-01, D-02) - _SAFE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]{0,63}$") - result = {} - for i, stem_name in enumerate(model.sources): - safe_name = os.path.basename(stem_name) - if not _SAFE_NAME_RE.match(safe_name): - safe_name = f"stem_{int(hashlib.sha256(stem_name.encode()).hexdigest()[:8], 16) % 10000}" - stem_path = os.path.join(output_dir, f"{safe_name}.wav") - real_stem = os.path.realpath(stem_path) - real_outdir = os.path.realpath(output_dir) - if not real_stem.startswith(real_outdir + os.sep) and real_stem != real_outdir: - raise AnalysisError( - f"Stem name '{stem_name}' would write outside output directory" - ) - stem_audio = sources[i].cpu().numpy().T - sf.write(stem_path, stem_audio, model.samplerate) - result[stem_name] = stem_path - - return SeparationResult(stems=result) + return backend(input_path, output_dir) diff --git a/src/phantom/server.py b/src/phantom/server.py index 68fb5f2..150d9f2 100644 --- a/src/phantom/server.py +++ b/src/phantom/server.py @@ -559,12 +559,18 @@ def read_live_metrics(instance_id: str | None = None) -> dict: def _startup_preflight() -> None: """Log version and optional extras status to stderr on startup.""" from phantom import __version__ - from phantom._diagnostics import OPTIONAL_DEPS, try_import + from phantom._diagnostics import ( + OPTIONAL_DEPS, + separation_plugin_status, + try_import, + ) if os.environ.get("PHANTOM_QUIET"): return extras = {} + sep_ok, _ = separation_plugin_status() + extras["separation"] = "OK" if sep_ok else "missing" for pkg, extra_name in OPTIONAL_DEPS.items(): ok, _ = try_import(pkg) extras[extra_name] = "OK" if ok else "missing" From b290cfe4f0ea180ffffd5edd1fbb3cc84cd75483 Mon Sep 17 00:00:00 2001 From: Lee Saenz <3975533+leesaenz@users.noreply.github.com> Date: Sat, 11 Jul 2026 02:35:28 -0500 Subject: [PATCH 3/5] test(separation): cover shim dispatch, discovery, and core metadata tests/test_separation.py rewritten around the shim: missing-plugin DependencyMissingError hint, backend dispatch passthrough, entry-point discovery, and a metadata test asserting no torch/torchaudio/demucs in phantom-audio's own dependencies or extras and that [separation] is exactly the phantom-audio-separation meta-installer. Doctor tests gain plugin-row coverage (OK only when loadable; missing plugin never fails overall health). Co-Authored-By: Claude Fable 5 --- tests/test_cli_doctor.py | 33 +++ tests/test_separation.py | 483 +++++++++++---------------------------- 2 files changed, 163 insertions(+), 353 deletions(-) diff --git a/tests/test_cli_doctor.py b/tests/test_cli_doctor.py index 1f8d30a..bd01911 100644 --- a/tests/test_cli_doctor.py +++ b/tests/test_cli_doctor.py @@ -85,6 +85,39 @@ def fake_try_import(name): assert data["core_deps"]["numpy"]["ok"] is False +def test_doctor_reports_separation_plugin_ok(runner): + """doctor reports separation OK only when the plugin actually loads (issue #7).""" + import json + + with patch( + "phantom.cli.doctor.separation_plugin_status", + return_value=(True, "1.4.0"), + ): + result = runner.invoke(cli, ["doctor", "--json"]) + + data = json.loads(result.output) + row = data["optional_deps"]["phantom-audio-separation"] + assert row["ok"] is True + assert row["version"] == "1.4.0" + + +def test_doctor_reports_separation_plugin_missing(runner): + """doctor reports the separation plugin as not-ok when absent or broken.""" + import json + + with patch( + "phantom.cli.doctor.separation_plugin_status", + return_value=(False, "not installed"), + ): + result = runner.invoke(cli, ["doctor", "--json"]) + + data = json.loads(result.output) + row = data["optional_deps"]["phantom-audio-separation"] + assert row["ok"] is False + # A missing plugin never fails overall doctor health (optional feature). + assert data["ok"] is True + + def test_doctor_counts_only_bridge_lua(runner, tmp_path): """Only the known bridge lua counts, not unrelated .lua files (P-21).""" import json diff --git a/tests/test_separation.py b/tests/test_separation.py index a1ee8c2..c29b354 100644 --- a/tests/test_separation.py +++ b/tests/test_separation.py @@ -1,373 +1,150 @@ -"""Tests for the source separation module. - -Covers SEP-01 through SEP-03. -All tests mock Demucs since it is an optional heavyweight dependency. +"""Tests for the stem separation dispatch shim (issue #7). + +The Demucs implementation lives in the sibling distribution +phantom-audio-separation (packages/phantom-audio-separation), where its +behavior tests live too. These tests cover the thin shim in +phantom.separation: entry-point discovery, dispatch, the +DependencyMissingError install hint when no plugin is installed, and the +core-metadata guarantee that no torch/demucs dependency leaks into +phantom-audio itself. """ -import importlib -import os +import sys +from importlib.metadata import entry_points +from pathlib import Path -import numpy as np import pytest -from unittest.mock import patch, MagicMock - -from phantom.separation import separate_stems, SeparationResult -from phantom.exceptions import ( - AnalysisError, - AudioLoadError, - DependencyMissingError, - PathSecurityError, -) +import phantom.separation as separation_mod +from phantom.exceptions import DependencyMissingError +from phantom.separation import ( + SEPARATION_EP_GROUP, + SeparationResult, + _load_backend, + separate_stems, +) -def _make_demucs_mocks(samplerate=44100): - """Create mocks for demucs.pretrained, demucs.apply, demucs.audio, torch.""" - mock_model = MagicMock() - mock_model.samplerate = samplerate - mock_model.audio_channels = 2 - mock_model.sources = ["drums", "bass", "other", "vocals"] - - mock_pretrained = MagicMock() - mock_pretrained.get_model.return_value = mock_model - - mock_apply_module = MagicMock() - # apply_model returns shape [batch, sources, channels, samples] - stem_tensor = MagicMock() - stem_audio = np.zeros((2, samplerate), dtype=np.float32) - stem_tensor.cpu.return_value.numpy.return_value.T = stem_audio.T - sources = MagicMock() - sources.__getitem__ = lambda self, i: stem_tensor - sources.shape = (1, 4, 2, samplerate) - batch_sources = MagicMock() - batch_sources.__getitem__ = lambda self, i: sources - mock_apply_module.apply_model.return_value = batch_sources - - wav_data = MagicMock() - wav_data.mean.return_value = MagicMock( - mean=MagicMock(return_value=0.0), - std=MagicMock(return_value=1.0), - ) - mock_audio_file_cls = MagicMock( - return_value=MagicMock( - read=MagicMock(return_value=wav_data), - ) - ) - mock_audio_module = MagicMock() - mock_audio_module.AudioFile = mock_audio_file_cls - - mock_torch = MagicMock() - mock_torch.no_grad.return_value.__enter__ = MagicMock(return_value=None) - mock_torch.no_grad.return_value.__exit__ = MagicMock(return_value=False) +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib - mock_sf = MagicMock() - return { - "demucs": MagicMock(), - "demucs.pretrained": mock_pretrained, - "demucs.apply": mock_apply_module, - "demucs.audio": mock_audio_module, - "torch": mock_torch, - "soundfile": mock_sf, - "_model": mock_model, - "_pretrained": mock_pretrained, - "_apply": mock_apply_module, - "_sf": mock_sf, - } +def _plugin_installed() -> bool: + """True when a phantom.separation entry point is present.""" + return bool(list(entry_points(group=SEPARATION_EP_GROUP))) -class TestSeparateStems: - """Tests for Demucs-based source separation (SEP-01 through SEP-03).""" +class _FakeEntryPoint: + """Minimal stand-in for importlib.metadata.EntryPoint.""" - def test_missing_demucs_raises_dependency_error(self, monkeypatch, tmp_path): - """When demucs is not installed, DependencyMissingError is raised (SEP-03).""" - import builtins - import sys + def __init__(self, target): + self._target = target + self.name = "fake" - for mod in ["demucs", "demucs.pretrained", "demucs.apply", "demucs.audio"]: - monkeypatch.delitem(sys.modules, mod, raising=False) + def load(self): + return self._target - original_import = builtins.__import__ - def _mock_import(name, *args, **kwargs): - if name.startswith("demucs"): - raise ImportError(f"No module named '{name}'") - return original_import(name, *args, **kwargs) +class TestMissingPlugin: + """Without the plugin installed, the shim raises with the install hint.""" - monkeypatch.setattr("builtins.__import__", _mock_import) + @pytest.mark.skipif( + _plugin_installed(), + reason="phantom-audio-separation is installed -- test requires it absent", + ) + def test_missing_plugin_raises_dependency_error(self, tmp_path): + """No plugin -> DependencyMissingError with the [separation] hint.""" + with pytest.raises(DependencyMissingError) as exc_info: + separate_stems("any.wav", str(tmp_path / "out")) + assert 'uv tool install "phantom-audio[separation]"' in str(exc_info.value) + assert "phantom-audio-separation" in str(exc_info.value) + def test_no_entry_points_raises_dependency_error(self, monkeypatch, tmp_path): + """Even with a plugin installed, an empty group raises the hint.""" + monkeypatch.setattr(separation_mod, "entry_points", lambda group: []) with pytest.raises(DependencyMissingError) as exc_info: separate_stems("any.wav", str(tmp_path / "out")) assert 'uv tool install "phantom-audio[separation]"' in str(exc_info.value) - def test_missing_input_raises_file_not_found(self, tmp_path): - """AudioLoadError when input_path does not exist (SEP-01).""" - nonexistent = str(tmp_path / "nonexistent.wav") - output_dir = str(tmp_path / "stems") - mocks = _make_demucs_mocks() - with patch.dict( - "sys.modules", - { - "demucs.pretrained": mocks["demucs.pretrained"], - "demucs.apply": mocks["demucs.apply"], - "demucs.audio": mocks["demucs.audio"], - "demucs": mocks["demucs"], - "torch": mocks["torch"], - }, - ): - with pytest.raises(AudioLoadError, match="Input file not found"): - separate_stems(nonexistent, output_dir) - - def test_successful_separation(self, tmp_path, wav_file_factory): - """Successful separation returns dict mapping stem names to file paths (SEP-01).""" - sr = 44100 - t = np.linspace(0, 1.0, sr, endpoint=False, dtype=np.float32) - samples = np.column_stack( - [ - 0.5 * np.sin(2 * np.pi * 440 * t), - 0.5 * np.sin(2 * np.pi * 440 * t), - ] - ).astype(np.float32) - input_path = wav_file_factory(samples, sr) - output_dir = str(tmp_path / "stems") - - mocks = _make_demucs_mocks() - with patch.dict( - "sys.modules", - { - "demucs.pretrained": mocks["demucs.pretrained"], - "demucs.apply": mocks["demucs.apply"], - "demucs.audio": mocks["demucs.audio"], - "demucs": mocks["demucs"], - "torch": mocks["torch"], - }, - ): - result = separate_stems(input_path, output_dir) - - assert isinstance(result, SeparationResult) - assert set(result.stems.keys()) == {"vocals", "drums", "bass", "other"} - for stem_name, stem_path in result.stems.items(): - assert stem_path.endswith(f"{stem_name}.wav") - - def test_model_is_htdemucs(self, tmp_path, wav_file_factory): - """get_model is called with 'htdemucs' (D-03).""" - sr = 44100 - t = np.linspace(0, 1.0, sr, endpoint=False, dtype=np.float32) - samples = np.column_stack( - [ - 0.5 * np.sin(2 * np.pi * 440 * t), - 0.5 * np.sin(2 * np.pi * 440 * t), - ] - ).astype(np.float32) - input_path = wav_file_factory(samples, sr) - output_dir = str(tmp_path / "stems") - - mocks = _make_demucs_mocks() - with patch.dict( - "sys.modules", - { - "demucs.pretrained": mocks["demucs.pretrained"], - "demucs.apply": mocks["demucs.apply"], - "demucs.audio": mocks["demucs.audio"], - "demucs": mocks["demucs"], - "torch": mocks["torch"], - }, - ): - separate_stems(input_path, output_dir) - - mocks["_pretrained"].get_model.assert_called_once_with("htdemucs") - - def test_output_dir_created(self, tmp_path, wav_file_factory): - """Output directory is created if it does not exist.""" - sr = 44100 - t = np.linspace(0, 1.0, sr, endpoint=False, dtype=np.float32) - samples = np.column_stack( - [ - 0.5 * np.sin(2 * np.pi * 440 * t), - 0.5 * np.sin(2 * np.pi * 440 * t), - ] - ).astype(np.float32) - input_path = wav_file_factory(samples, sr) - output_dir = str(tmp_path / "deeply" / "nested" / "stems") - - assert not os.path.isdir(output_dir) - - mocks = _make_demucs_mocks() - with patch.dict( - "sys.modules", - { - "demucs.pretrained": mocks["demucs.pretrained"], - "demucs.apply": mocks["demucs.apply"], - "demucs.audio": mocks["demucs.audio"], - "demucs": mocks["demucs"], - "torch": mocks["torch"], - }, - ): - separate_stems(input_path, output_dir) - - assert os.path.isdir(output_dir) - - def test_demucs_error_wrapped_in_analysis_error(self, tmp_path, wav_file_factory): - """Demucs internal error is wrapped in AnalysisError.""" - sr = 44100 - t = np.linspace(0, 1.0, sr, endpoint=False, dtype=np.float32) - samples = np.column_stack( - [ - 0.5 * np.sin(2 * np.pi * 440 * t), - 0.5 * np.sin(2 * np.pi * 440 * t), - ] - ).astype(np.float32) - input_path = wav_file_factory(samples, sr) - output_dir = str(tmp_path / "stems") - - mocks = _make_demucs_mocks() - mocks["_apply"].apply_model.side_effect = RuntimeError( - "Internal demucs failure" - ) - - with patch.dict( - "sys.modules", - { - "demucs.pretrained": mocks["demucs.pretrained"], - "demucs.apply": mocks["demucs.apply"], - "demucs.audio": mocks["demucs.audio"], - "demucs": mocks["demucs"], - "torch": mocks["torch"], - }, - ): - with pytest.raises(AnalysisError, match="Source separation failed"): - separate_stems(input_path, output_dir) - - def test_function_signature(self, tmp_path): - """separate_stems accepts exactly 2 positional args named input_path and output_dir (D-04).""" - mocks = _make_demucs_mocks() - with patch.dict( - "sys.modules", - { - "demucs.pretrained": mocks["demucs.pretrained"], - "demucs.apply": mocks["demucs.apply"], - "demucs.audio": mocks["demucs.audio"], - "demucs": mocks["demucs"], - "torch": mocks["torch"], - }, - ): - with pytest.raises(AudioLoadError): - separate_stems( - input_path=str(tmp_path / "a.wav"), - output_dir=str(tmp_path / "out"), - ) - - def test_silent_input_raises_analysis_error(self, tmp_path, wav_file_factory): - """Fully-silent input (ref.std() == 0) raises a clean AnalysisError, not NaN (P-14). - - Without the zero-std guard, ``(wav - ref.mean()) / ref.std()`` divides by - zero and feeds NaNs into demucs. The guard must intercept it first with a - musician-friendly message. - """ - sr = 44100 - # A genuinely silent file: all zeros. (The mock's ref.std() is forced to - # 0.0 below so the guard fires regardless of demucs internals.) - samples = np.zeros((sr, 2), dtype=np.float32) - input_path = wav_file_factory(samples, sr) - output_dir = str(tmp_path / "stems") - - mocks = _make_demucs_mocks() - # Force the demucs reference channel to report zero standard deviation, - # simulating a fully-silent input reaching the normalization step. - mocks[ - "demucs.audio" - ].AudioFile.return_value.read.return_value.mean.return_value = MagicMock( - mean=MagicMock(return_value=0.0), - std=MagicMock(return_value=0.0), - ) - - with patch.dict( - "sys.modules", - { - "demucs.pretrained": mocks["demucs.pretrained"], - "demucs.apply": mocks["demucs.apply"], - "demucs.audio": mocks["demucs.audio"], - "demucs": mocks["demucs"], - "torch": mocks["torch"], - }, - ): - with pytest.raises(AnalysisError, match="silent"): - separate_stems(input_path, output_dir) - - def test_import_without_demucs(self, monkeypatch): - """Importing phantom.separation without demucs installed does not raise (SEP-02).""" - import builtins - import sys - import phantom.separation - - for mod in ["demucs", "demucs.pretrained", "demucs.apply", "demucs.audio"]: - monkeypatch.delitem(sys.modules, mod, raising=False) - - original_import = builtins.__import__ - - def _mock_import(name, *args, **kwargs): - if name.startswith("demucs"): - raise ImportError(f"No module named '{name}'") - return original_import(name, *args, **kwargs) - - monkeypatch.setattr("builtins.__import__", _mock_import) - importlib.reload(phantom.separation) - - -class TestDecodeLimits: - """Tests for the decode-bomb guard in separate_stems() (Advisory 2). - - demucs is mocked present so execution reaches the guard (which runs after - the dependency import but before any model load / decode). - """ - - def _demucs_patch(self): - mocks = _make_demucs_mocks() - return patch.dict( - "sys.modules", - { - "demucs.pretrained": mocks["demucs.pretrained"], - "demucs.apply": mocks["demucs.apply"], - "demucs.audio": mocks["demucs.audio"], - "demucs": mocks["demucs"], - "torch": mocks["torch"], - }, - ) - - def test_over_duration_input_rejected( - self, tmp_path, wav_file_factory, monkeypatch - ): - """An input longer than PHANTOM_MAX_DURATION is rejected before decode.""" - monkeypatch.setenv("PHANTOM_MAX_DURATION", "0.5") - input_path = wav_file_factory(np.zeros((44100 * 2, 2), dtype=np.float32)) # 2s - with self._demucs_patch(): - with pytest.raises(AudioLoadError, match="exceeds the"): - separate_stems(input_path, str(tmp_path / "stems")) - - def test_over_size_input_rejected(self, tmp_path, wav_file_factory, monkeypatch): - """An input larger than PHANTOM_MAX_FILE_SIZE is rejected before decode.""" - monkeypatch.setenv("PHANTOM_MAX_FILE_SIZE", "100") # 100 bytes - input_path = wav_file_factory(np.zeros((44100, 2), dtype=np.float32)) - with self._demucs_patch(): - with pytest.raises(AudioLoadError, match="exceeds the"): - separate_stems(input_path, str(tmp_path / "stems")) - - -class TestOutputDirValidation: - """Tests for PHANTOM_OUTPUT_DIR validation in separate_stems().""" - - def test_output_dir_rejected_when_outside(self, tmp_path, monkeypatch): - """separate_stems rejects output_dir outside PHANTOM_OUTPUT_DIR.""" - allowed = tmp_path / "allowed" - allowed.mkdir() - monkeypatch.setenv("PHANTOM_OUTPUT_DIR", str(allowed)) - input_file = tmp_path / "input.wav" - input_file.write_bytes(b"fake") - with pytest.raises(PathSecurityError, match="outside the allowed directory"): - separate_stems(str(input_file), str(tmp_path / "forbidden")) - - def test_output_dir_confined_to_default_when_unset(self, tmp_path, monkeypatch): - """With PHANTOM_OUTPUT_DIR unset, output_dir outside the default sandbox is rejected (Finding 1).""" - monkeypatch.delenv("PHANTOM_OUTPUT_DIR", raising=False) - monkeypatch.setenv("HOME", str(tmp_path)) - with pytest.raises(PathSecurityError, match="outside the allowed directory"): - separate_stems("/nonexistent/input.wav", str(tmp_path / "elsewhere")) + +class TestDispatch: + """The shim dispatches to the discovered backend unchanged.""" + + def test_dispatch_passes_args_and_returns_result(self, monkeypatch, tmp_path): + """Backend receives the shim's args; its result passes through.""" + calls = [] + expected = SeparationResult(stems={"vocals": "/out/vocals.wav"}) + + def fake_backend(input_path, output_dir): + calls.append((input_path, output_dir)) + return expected + + monkeypatch.setattr(separation_mod, "_load_backend", lambda: fake_backend) + + result = separate_stems("mix.wav", str(tmp_path / "stems")) + + assert result is expected + assert calls == [("mix.wav", str(tmp_path / "stems"))] + + def test_backend_phantom_error_passes_through(self, monkeypatch, tmp_path): + """A PhantomError raised by the backend is not double-wrapped.""" + + def fake_backend(input_path, output_dir): + raise DependencyMissingError(package="Demucs", extra="separation") + + monkeypatch.setattr(separation_mod, "_load_backend", lambda: fake_backend) + + with pytest.raises(DependencyMissingError, match="Demucs is not installed"): + separate_stems("mix.wav", str(tmp_path / "stems")) + + +class TestEntryPointDiscovery: + """_load_backend discovers backends via the phantom.separation group.""" + + def test_load_backend_returns_first_entry_point(self, monkeypatch): + """The first entry point in the group is loaded and returned.""" + sentinel = object() + + def fake_entry_points(group): + assert group == SEPARATION_EP_GROUP + return [_FakeEntryPoint(sentinel), _FakeEntryPoint(object())] + + monkeypatch.setattr(separation_mod, "entry_points", fake_entry_points) + assert _load_backend() is sentinel + + def test_load_backend_returns_none_when_no_plugins(self, monkeypatch): + """An empty entry-point group yields None.""" + monkeypatch.setattr(separation_mod, "entry_points", lambda group: []) + assert _load_backend() is None + + +class TestCoreMetadata: + """phantom-audio's own dependency metadata carries no torch/demucs.""" + + @pytest.fixture(scope="class") + def project(self): + pyproject = Path(__file__).parents[1] / "pyproject.toml" + with open(pyproject, "rb") as fh: + return tomllib.load(fh)["project"] + + def test_no_torch_in_core_dependencies(self, project): + """Core dependencies never mention torch, torchaudio, or demucs.""" + for dep in project["dependencies"]: + for banned in ("torch", "torchaudio", "demucs"): + assert banned not in dep.lower(), f"{banned} leaked into core: {dep}" + + def test_no_torch_in_any_extra(self, project): + """No optional extra depends on torch/torchaudio/demucs directly.""" + for extra, deps in project["optional-dependencies"].items(): + for dep in deps: + for banned in ("torch", "torchaudio", "demucs"): + assert banned not in dep.lower(), ( + f"{banned} leaked into extra '{extra}': {dep}" + ) + + def test_separation_extra_is_meta_installer(self, project): + """[separation] pulls exactly the sibling plugin distribution.""" + assert project["optional-dependencies"]["separation"] == [ + "phantom-audio-separation" + ] From aba46e828bad8f106fb09f7b23f4e3ecb694e64a Mon Sep 17 00:00:00 2001 From: Lee Saenz <3975533+leesaenz@users.noreply.github.com> Date: Sat, 11 Jul 2026 02:35:28 -0500 Subject: [PATCH 4/5] ci: lint plugin package and run its tests in the optional-deps job - ruff check/format now cover packages/ in CI and scripts/pre-push - optional-deps job runs packages/phantom-audio-separation/tests (the workspace member installs there via --extra separation; tests stay mock-based, no torch execution) - README notes the [separation] extra is a meta-installer for the sibling package; RELEASING.md documents building/publishing it Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 6 +++--- README.md | 2 ++ RELEASING.md | 10 ++++++++++ scripts/pre-push | 4 ++-- 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72e63c9..3259037 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,10 +31,10 @@ jobs: run: uv sync --locked --python ${{ matrix.python-version }} --extra dev - name: Lint (ruff check) - run: uv run ruff check src/ tests/ + run: uv run ruff check src/ tests/ packages/ - name: Format check (ruff format) - run: uv run ruff format --check src/ tests/ + run: uv run ruff format --check src/ tests/ packages/ - name: Test (pytest) run: uv run pytest tests/ -x -q --tb=short @@ -58,7 +58,7 @@ jobs: run: uv sync --locked --python 3.12 --extra dev --extra matching --extra separation --extra processing - name: Test optional dependencies - run: uv run pytest tests/test_optional_deps.py -x -q --tb=short + run: uv run pytest tests/test_optional_deps.py packages/phantom-audio-separation/tests -x -q --tb=short security: name: Dependency audit (pip-audit) diff --git a/README.md b/README.md index 1720143..b8cd1e3 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,8 @@ uv tool install "phantom-audio[all]" --python 3.13 ```bash # Stem separation only (Demucs + PyTorch ~2.5GB) +# Ships as the sibling package phantom-audio-separation; the [separation] +# extra is a backward-compatible meta-installer that pulls it in. uv tool install "phantom-audio[separation]" --python 3.13 # Reference matching only (GPLv3 -- see License section) diff --git a/RELEASING.md b/RELEASING.md index f7aa495..2b1f2a4 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -59,6 +59,16 @@ uv build uv publish --token "$(security find-generic-password -a __token__ -s pypi -w)" ``` +**Sibling package:** the stem-separation plugin lives in +`packages/phantom-audio-separation` (its own version in +`packages/phantom-audio-separation/pyproject.toml`). When it has changes to +release, build and publish it too: + +```bash +uv build --package phantom-audio-separation +uv publish --token "$(security find-generic-password -a __token__ -s pypi -w)" dist/phantom_audio_separation-* +``` + Verify it works: ```bash uv tool install phantom-audio --python 3.13 --force diff --git a/scripts/pre-push b/scripts/pre-push index 0e86a9f..9f50e37 100755 --- a/scripts/pre-push +++ b/scripts/pre-push @@ -25,11 +25,11 @@ echo " No PII found in tracked files." echo "" echo "==> Linting (ruff check)..." -uv tool run ruff check src/ tests/ +uv tool run ruff check src/ tests/ packages/ echo "" echo "==> Format check (ruff format)..." -uv tool run ruff format --check src/ tests/ +uv tool run ruff format --check src/ tests/ packages/ echo "" echo "==> Running tests (pytest)..." From 2e553c4829f1a709fd1ff9125a6dd02884bcba73 Mon Sep 17 00:00:00 2001 From: Lee Saenz <3975533+leesaenz@users.noreply.github.com> Date: Sat, 11 Jul 2026 02:42:10 -0500 Subject: [PATCH 5/5] fix(test): make entry-point identity check reload-safe test_import_without_demucs reloads the backend module, replacing its function objects; comparing ep.load() against the import captured at collection time then fails. Compare against the module attribute at assertion time instead (the same lookup ep.load() performs). Co-Authored-By: Claude Fable 5 --- .../phantom-audio-separation/tests/test_demucs_backend.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/phantom-audio-separation/tests/test_demucs_backend.py b/packages/phantom-audio-separation/tests/test_demucs_backend.py index 088dddf..62d3cac 100644 --- a/packages/phantom-audio-separation/tests/test_demucs_backend.py +++ b/packages/phantom-audio-separation/tests/test_demucs_backend.py @@ -290,8 +290,14 @@ def test_entry_point_registered_and_loads(self): """The installed distribution exposes phantom.separation -> separate_stems.""" from importlib.metadata import entry_points + import phantom_separation.demucs_backend as backend_mod + eps = list(entry_points(group="phantom.separation")) if not eps: pytest.skip("plugin not installed as a distribution (source-tree run)") loaded = eps[0].load() - assert loaded is separate_stems + # Compare against the module attribute at assertion time (not the + # import captured at collection time): test_import_without_demucs + # reloads the backend module, which replaces its function objects. + assert loaded is backend_mod.separate_stems + assert loaded.__module__ == "phantom_separation.demucs_backend"