Skip to content
Open
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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ Perth enables you to embed imperceptible watermarks in audio files and later det
pip install resemble-perth
```

Core install is `torch` + `torchaudio` only. `librosa` (and `soxr`) pull LGPL
code and are **not** imported by `perth.perth_net` / `PerthNet`. Install the
optional extra only if you need the CLI or librosa I/O:

```bash
pip install 'resemble-perth[audio]'
```

### From Source

```bash
Expand Down
10 changes: 7 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,18 @@ classifiers = [

dependencies = [
"PyYAML>=6.0",
"librosa>=0.11.0",
"numpy>=1.24.0",
"pydub>=0.25.1",
"soundfile>=0.12.0",
"torch>=2.1.0",
"torchaudio>=2.1.0",
]

[project.optional-dependencies]
audio = [
"librosa>=0.11.0",
"pydub>=0.25.1",
"soundfile>=0.12.0",
]

[dependency-groups]
examples = [
"tqdm>=4.64.0",
Expand Down
17 changes: 14 additions & 3 deletions src/perth/perth_net/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
from .perth_net_implicit.perth_watermarker import PerthImplicitWatermarker # noqa: E402, F401
from ..config import Config
from perth.config import Config

PREPACKAGED_MODELS_DIR = Config.get_default_models_dir() # For backward compatibility
PREPACKAGED_MODELS_DIR = Config.get_default_models_dir()


def __getattr__(name: str):
"""Load the implicit watermarker only when requested.

Importing ``perth.perth_net`` or ``PerthNet`` must not pull in librosa/soxr.
"""
if name == "PerthImplicitWatermarker":
from .perth_net_implicit.perth_watermarker import PerthImplicitWatermarker

return PerthImplicitWatermarker
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
36 changes: 32 additions & 4 deletions src/perth/perth_net/perth_net_implicit/perth_watermarker.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import torch
import numpy as np
from librosa import resample

from .model.perth_net import PerthNet
from perth.watermarker import WatermarkerBase
Expand All @@ -12,6 +11,35 @@ def _to_tensor(x, device):
return x.to(dtype=torch.float, device=device)


def _resample(signal, orig_sr, target_sr, *, res_type=None):
"""Resample with torchaudio; librosa is an optional extra, not a core dep."""
try:
from torchaudio.functional import resample as torchaudio_resample

tensor = signal if isinstance(signal, torch.Tensor) else torch.as_tensor(signal)
was_numpy = not isinstance(signal, torch.Tensor)
if tensor.ndim == 1:
tensor = tensor.unsqueeze(0)
squeezed = True
else:
squeezed = False
out = torchaudio_resample(tensor.float(), orig_sr, target_sr)
if squeezed:
out = out.squeeze(0)
return out.numpy() if was_numpy else out
except Exception:
try:
from librosa import resample
except ImportError as error:
raise ImportError(
"PerthNet resample fell back to librosa, but librosa is not installed. "
"Install resemble-perth[audio] or keep torchaudio available."
) from error

kwargs = {} if res_type is None else {"res_type": res_type}
return resample(signal, orig_sr=orig_sr, target_sr=target_sr, **kwargs)


class PerthImplicitWatermarker(WatermarkerBase):
def __init__(
self,
Expand All @@ -33,7 +61,7 @@ def __init__(
def apply_watermark(self, signal, sample_rate, **_):
change_rate = sample_rate != self.perth_net.hp.sample_rate
signal = (
resample(
_resample(
signal, orig_sr=sample_rate, target_sr=self.perth_net.hp.sample_rate
)
if change_rate
Expand All @@ -53,7 +81,7 @@ def apply_watermark(self, signal, sample_rate, **_):
wm_signal = self.perth_net.ap.magphase_to_signal(wm_magspec, phase)
wm_signal = wm_signal.detach().cpu().numpy()
return (
resample(
_resample(
wm_signal, orig_sr=self.perth_net.hp.sample_rate, target_sr=sample_rate
)
if change_rate
Expand All @@ -63,7 +91,7 @@ def apply_watermark(self, signal, sample_rate, **_):
def get_watermark(self, wm_signal, sample_rate, round=True, **_):
change_rate = sample_rate != self.perth_net.hp.sample_rate
if change_rate:
wm_signal = resample(
wm_signal = _resample(
wm_signal,
orig_sr=sample_rate,
target_sr=self.perth_net.hp.sample_rate,
Expand Down
41 changes: 41 additions & 0 deletions tests/test_lazy_perth_net.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import subprocess
import sys
import unittest


class TestLazyPerthNetImports(unittest.TestCase):
def test_import_perth_net_without_librosa(self):
code = (
"import sys\n"
"from perth.perth_net.perth_net_implicit.model.perth_net import PerthNet\n"
"assert PerthNet is not None\n"
"assert 'librosa' not in sys.modules, sys.modules.keys()\n"
"assert 'soxr' not in sys.modules\n"
)
result = subprocess.run(
[sys.executable, "-c", code],
capture_output=True,
text=True,
)
self.assertEqual(result.returncode, 0, result.stderr)

def test_perth_package_import_without_librosa(self):
code = (
"import sys\n"
"import perth\n"
"assert 'librosa' not in sys.modules, sys.modules.keys()\n"
)
result = subprocess.run(
[sys.executable, "-c", code],
capture_output=True,
text=True,
)
self.assertEqual(result.returncode, 0, result.stderr)

def test_core_dependencies_do_not_declare_librosa(self):
from pathlib import Path

text = Path(__file__).resolve().parents[1].joinpath("pyproject.toml").read_text()
core = text.split("[project.optional-dependencies]", 1)[0]
self.assertNotIn("librosa", core)
self.assertIn("librosa>=0.11.0", text)
17 changes: 11 additions & 6 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.