From 382db40716ed3b96e6d26d38752fd05c103154c6 Mon Sep 17 00:00:00 2001 From: Yash Hayaran Date: Wed, 16 Sep 2026 18:27:05 +0530 Subject: [PATCH 1/2] feat: lazy-load PerthNet without importing librosa Keep perth.perth_net and PerthNet importable without librosa/soxr so serving stacks can use the encoder on torchaudio STFT only. Signed-off-by: Yash Hayaran --- src/perth/perth_net/__init__.py | 17 ++++++++-- .../perth_net_implicit/perth_watermarker.py | 31 ++++++++++++++--- tests/test_lazy_perth_net.py | 33 +++++++++++++++++++ 3 files changed, 74 insertions(+), 7 deletions(-) create mode 100644 tests/test_lazy_perth_net.py diff --git a/src/perth/perth_net/__init__.py b/src/perth/perth_net/__init__.py index 582d6fb..b8de769 100644 --- a/src/perth/perth_net/__init__.py +++ b/src/perth/perth_net/__init__.py @@ -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}") diff --git a/src/perth/perth_net/perth_net_implicit/perth_watermarker.py b/src/perth/perth_net/perth_net_implicit/perth_watermarker.py index e0edc84..686fd50 100644 --- a/src/perth/perth_net/perth_net_implicit/perth_watermarker.py +++ b/src/perth/perth_net/perth_net_implicit/perth_watermarker.py @@ -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 @@ -12,6 +11,30 @@ 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 when possible; librosa is an optional fallback.""" + try: + import torch + 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: + from librosa import resample + + 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, @@ -33,7 +56,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 @@ -53,7 +76,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 @@ -63,7 +86,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, diff --git a/tests/test_lazy_perth_net.py b/tests/test_lazy_perth_net.py new file mode 100644 index 0000000..ee42f09 --- /dev/null +++ b/tests/test_lazy_perth_net.py @@ -0,0 +1,33 @@ +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) From c12bf20dbf37965f869207efc135547cea52c34d Mon Sep 17 00:00:00 2001 From: Yash Hayaran Date: Wed, 16 Sep 2026 19:33:42 +0530 Subject: [PATCH 2/2] feat: drop librosa from core Perth dependencies Keep PerthNet installable with torch/torchaudio only. librosa remains an optional [audio] extra and a resample fallback so serving stacks are not forced to pull LGPL soxr. Signed-off-by: Yash Hayaran --- README.md | 8 ++++++++ pyproject.toml | 10 +++++++--- .../perth_net_implicit/perth_watermarker.py | 11 ++++++++--- tests/test_lazy_perth_net.py | 8 ++++++++ uv.lock | 17 +++++++++++------ 5 files changed, 42 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index dce15b3..47d49c5 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index b5b296b..7bf8eff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/src/perth/perth_net/perth_net_implicit/perth_watermarker.py b/src/perth/perth_net/perth_net_implicit/perth_watermarker.py index 686fd50..072d6c4 100644 --- a/src/perth/perth_net/perth_net_implicit/perth_watermarker.py +++ b/src/perth/perth_net/perth_net_implicit/perth_watermarker.py @@ -12,9 +12,8 @@ def _to_tensor(x, device): def _resample(signal, orig_sr, target_sr, *, res_type=None): - """Resample with torchaudio when possible; librosa is an optional fallback.""" + """Resample with torchaudio; librosa is an optional extra, not a core dep.""" try: - import torch from torchaudio.functional import resample as torchaudio_resample tensor = signal if isinstance(signal, torch.Tensor) else torch.as_tensor(signal) @@ -29,7 +28,13 @@ def _resample(signal, orig_sr, target_sr, *, res_type=None): out = out.squeeze(0) return out.numpy() if was_numpy else out except Exception: - from librosa import resample + 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) diff --git a/tests/test_lazy_perth_net.py b/tests/test_lazy_perth_net.py index ee42f09..0357dc8 100644 --- a/tests/test_lazy_perth_net.py +++ b/tests/test_lazy_perth_net.py @@ -31,3 +31,11 @@ def test_perth_package_import_without_librosa(self): 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) diff --git a/uv.lock b/uv.lock index 4194bbd..f1e8193 100644 --- a/uv.lock +++ b/uv.lock @@ -2061,16 +2061,20 @@ name = "resemble-perth" version = "1.1.0" source = { editable = "." } dependencies = [ - { name = "librosa" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "pydub" }, { name = "pyyaml" }, - { name = "soundfile" }, { name = "torch" }, { name = "torchaudio" }, ] +[package.optional-dependencies] +audio = [ + { name = "librosa" }, + { name = "pydub" }, + { name = "soundfile" }, +] + [package.dev-dependencies] dev = [ { name = "pre-commit" }, @@ -2096,14 +2100,15 @@ extra = [ [package.metadata] requires-dist = [ - { name = "librosa", specifier = ">=0.11.0" }, + { name = "librosa", marker = "extra == 'audio'", specifier = ">=0.11.0" }, { name = "numpy", specifier = ">=1.24.0" }, - { name = "pydub", specifier = ">=0.25.1" }, + { name = "pydub", marker = "extra == 'audio'", specifier = ">=0.25.1" }, { name = "pyyaml", specifier = ">=6.0" }, - { name = "soundfile", specifier = ">=0.12.0" }, + { name = "soundfile", marker = "extra == 'audio'", specifier = ">=0.12.0" }, { name = "torch", specifier = ">=2.1.0" }, { name = "torchaudio", specifier = ">=2.1.0" }, ] +provides-extras = ["audio"] [package.metadata.requires-dev] dev = [