Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions packages/phantom-audio-separation/README.md
Original file line number Diff line number Diff line change
@@ -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).
51 changes: 51 additions & 0 deletions packages/phantom-audio-separation/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 }
Original file line number Diff line number Diff line change
@@ -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__"]
Original file line number Diff line number Diff line change
@@ -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)
48 changes: 48 additions & 0 deletions packages/phantom-audio-separation/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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)
Loading