diff --git a/docs/source/faq.rst b/docs/source/faq.rst index 4a77c0213..4146c9cce 100644 --- a/docs/source/faq.rst +++ b/docs/source/faq.rst @@ -64,3 +64,43 @@ Can Pedalboard plugins accept MIDI? As of version 0.7.4, both :class:`pedalboard.VST3Plugin` and :class:`pedalboard.AudioUnitPlugin` support passing MIDI messages to instrument plugins for audio rendering. However, effect plugins cannot yet be passed MIDI data. + +Can Pedalboard objects be pickled, copied, or sent to other processes? +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +As of version 0.9.27, yes! All of Pedalboard's built-in plugins, as well as :class:`pedalboard.Pedalboard`, +:class:`pedalboard.Chain`, and :class:`pedalboard.Mix` objects, support :py:mod:`pickle` and :py:mod:`copy`. +This allows plugins to be passed to other processes (i.e.: with :py:mod:`multiprocessing`, +:py:mod:`concurrent.futures`, a PyTorch ``DataLoader`` with ``num_workers > 0``, Ray, or Dask), +or to be saved alongside a dataset to record exactly which effects were applied: + +.. code-block:: python + + import pickle + from concurrent.futures import ProcessPoolExecutor + from pedalboard import Pedalboard, Compressor, Reverb + + board = Pedalboard([Compressor(threshold_db=-20, ratio=4), Reverb(room_size=0.8)]) + + # Save and restore a board, including all of its parameters: + restored = pickle.loads(pickle.dumps(board)) + + # ...or send it to a pool of worker processes: + with ProcessPoolExecutor() as executor: + outputs = list(executor.map(board, audio_clips, [sample_rate] * len(audio_clips))) + +A few things to keep in mind: + +- Only a plugin's parameters are pickled, not its internal state. An unpickled plugin + behaves as if it had just been reset (i.e.: a :class:`pedalboard.Reverb` will not + include the tail of any audio processed before it was pickled). +- A :class:`pedalboard.Convolution` loaded from a file stores the path to that file, which must also + exist wherever the plugin is unpickled. (Impulse responses passed as NumPy arrays are pickled by value.) +- A :class:`pedalboard.VST3Plugin` or :class:`pedalboard.AudioUnitPlugin` stores its path and its + :py:attr:`raw_state`, and is reloaded from that path when unpickled, so the same plugin must be + installed at the same location on the machine that unpickles it. +- As with any pickled data, only unpickle data from sources you trust. Unpickling an external plugin + loads (and runs) the plugin found at the stored path. + +Note that Pedalboard releases the GIL while processing audio, so threads are often enough to use +multiple CPU cores within a single process. diff --git a/pedalboard/ExternalPlugin.h b/pedalboard/ExternalPlugin.h index 98584e03e..6eb91d9ef 100644 --- a/pedalboard/ExternalPlugin.h +++ b/pedalboard/ExternalPlugin.h @@ -1379,6 +1379,8 @@ class ExternalPlugin : public AbstractExternalPlugin { StandalonePluginWindow::openWindowAndWait(*pluginInstance, optionalEvent); } + const juce::String &getPathToPluginFile() const { return pathToPluginFile; } + ExternalPluginReloadType reloadType = ExternalPluginReloadType::Unknown; juce::PluginDescription foundPluginDescription; @@ -1714,6 +1716,13 @@ example: a Windows VST3 plugin bundle will not load on Linux or macOS.) return plugin.getName().toStdString(); }, "The name of this plugin.") + .def_property_readonly( + "path_to_plugin_file", + [](ExternalPlugin &plugin) { + return plugin.getPathToPluginFile().toStdString(); + }, + "The path to the plugin file or bundle that this plugin was loaded " + "from.\n\n*Introduced in v0.9.27.*") .def_property( "raw_state", [](const ExternalPlugin &plugin) { @@ -1934,6 +1943,13 @@ see :class:`pedalboard.VST3Plugin`.) return plugin.getName().toStdString(); }, "The name of this plugin, as reported by the plugin itself.") + .def_property_readonly( + "path_to_plugin_file", + [](ExternalPlugin &plugin) { + return plugin.getPathToPluginFile().toStdString(); + }, + "The path to the plugin file or bundle that this plugin was loaded " + "from.\n\n*Introduced in v0.9.27.*") .def_property( "raw_state", [](const ExternalPlugin &plugin) { diff --git a/pedalboard/_pedalboard.py b/pedalboard/_pedalboard.py index 1d36a25ae..7538c9435 100644 --- a/pedalboard/_pedalboard.py +++ b/pedalboard/_pedalboard.py @@ -32,8 +32,10 @@ ) from pedalboard_native import ( # type: ignore + Convolution, # type: ignore ExternalPlugin, # type: ignore Plugin, + PluginContainer, # type: ignore _AudioProcessorParameter, ) from pedalboard_native.utils import Chain # type: ignore @@ -843,3 +845,95 @@ def load_plugin( ), ) ) + + +# Pickling support. +# +# Plugins are pickled by value: we record the plugin's class, any arguments that +# its constructor needs (i.e.: a Convolution's impulse response, or the plugins +# inside a Chain or Mix), and the value of every writable property. Unpickling +# constructs a fresh plugin and re-applies those properties. +# +# Note that only a plugin's configuration is pickled, not its internal DSP state +# (i.e.: the tail of a reverb or the contents of a delay line). An unpickled +# plugin behaves as if it were freshly constructed (or had just been reset). + + +def _writable_property_names(cls: type) -> List[str]: + """ + Return the names of all public, writable properties on the given class, + in method resolution order (base classes first). + """ + names: List[str] = [] + for klass in reversed(cls.__mro__): + for name, value in vars(klass).items(): + if ( + isinstance(value, property) + and value.fset is not None + and not name.startswith("_") + and name not in names + ): + names.append(name) + return names + + +def _restore_plugin(cls: type, init_kwargs: dict, properties: dict) -> Plugin: + """Reconstruct a plugin from the output of :py:func:`_reduce_plugin`.""" + plugin = cls(**init_kwargs) + for name, value in properties.items(): + setattr(plugin, name, value) + return plugin + + +def _get_init_kwargs(plugin: Plugin) -> dict: + """ + Return the keyword arguments that must be passed to ``type(plugin)``'s + constructor to recreate it. Most plugins have default values for all of + their parameters, and so return an empty dictionary here. + """ + if type(plugin).__module__ == "pedalboard_native._internal": + raise TypeError(f"Internal test plugins like {type(plugin).__name__} cannot be pickled.") + + if isinstance(plugin, PluginContainer): + return {"plugins": list(plugin)} + + if isinstance(plugin, Convolution): + if plugin.impulse_response_filename is not None: + return {"impulse_response_filename": plugin.impulse_response_filename} + if plugin.impulse_response is None: + raise TypeError(f"Unable to pickle {plugin!r}: it has no impulse response.") + # Convolution normalizes and resamples its impulse response internally, + # but keeps a copy of the original audio, which we pickle here: + return { + "impulse_response_filename": plugin.impulse_response, + "sample_rate": plugin.impulse_response_sample_rate, + } + + if isinstance(plugin, ExternalPlugin): + return { + "path_to_plugin_file": plugin.path_to_plugin_file, # type: ignore + "plugin_name": plugin.name, # type: ignore + } + + return {} + + +def _reduce_plugin(plugin: Plugin): + """ + Implementation of ``__reduce__`` for all Pedalboard plugins, which allows + them to be pickled, copied with :py:mod:`copy`, and passed to other + processes (i.e.: via :py:mod:`multiprocessing`). + """ + cls = type(plugin) + properties = {} + if isinstance(plugin, ExternalPlugin): + # The raw state of an external plugin includes all of its parameters, + # so there's no need to save each parameter individually: + properties["raw_state"] = plugin.raw_state # type: ignore + else: + for name in _writable_property_names(cls): + properties[name] = getattr(plugin, name) + return (_restore_plugin, (cls, _get_init_kwargs(plugin), properties)) + + +Plugin.__reduce__ = _reduce_plugin # type: ignore diff --git a/pedalboard/plugins/Convolution.h b/pedalboard/plugins/Convolution.h index 283127e92..9699a32e4 100644 --- a/pedalboard/plugins/Convolution.h +++ b/pedalboard/plugins/Convolution.h @@ -190,6 +190,14 @@ inline void init_convolution(py::module &m) { return {}; } }) + .def_property_readonly( + "impulse_response_sample_rate", + [](JucePlugin &plugin) { + return plugin.getDSP().getSampleRate(); + }, + "The sample rate of the impulse response, if it was provided as a " + "NumPy array, or ``None`` if it was loaded from a file.\n\n" + "*Introduced in v0.9.27.*") .def_property( "mix", [](JucePlugin &plugin) { diff --git a/pedalboard_native/__init__.pyi b/pedalboard_native/__init__.pyi index 206ae5a44..0f47baf65 100644 --- a/pedalboard_native/__init__.pyi +++ b/pedalboard_native/__init__.pyi @@ -386,6 +386,16 @@ class Convolution(Plugin): def impulse_response_filename(self) -> typing.Optional[str]: """ """ + @property + def impulse_response_sample_rate(self) -> typing.Optional[float]: + """ + The sample rate of the impulse response, if it was provided as a NumPy array, or ``None`` if it was loaded from a file. + + *Introduced in v0.9.27.* + + + """ + @property def mix(self) -> float: """ """ @@ -1493,6 +1503,16 @@ class AudioUnitPlugin(ExternalPlugin): The name of this plugin. + """ + + @property + def path_to_plugin_file(self) -> str: + """ + The path to the plugin file or bundle that this plugin was loaded from. + + *Introduced in v0.9.27.* + + """ @property @@ -2187,6 +2207,16 @@ class VST3Plugin(ExternalPlugin): The name of this plugin. + """ + + @property + def path_to_plugin_file(self) -> str: + """ + The path to the plugin file or bundle that this plugin was loaded from. + + *Introduced in v0.9.27.* + + """ @property diff --git a/tests/test_pickle.py b/tests/test_pickle.py new file mode 100644 index 000000000..f39966806 --- /dev/null +++ b/tests/test_pickle.py @@ -0,0 +1,314 @@ +#! /usr/bin/env python +# +# Copyright 2026 Spotify AB +# +# Licensed under the GNU Public License, Version 3.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.gnu.org/licenses/gpl-3.0.html +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import copy +import os +import pickle +import re +import subprocess +import sys +import time +from typing import Any + +import numpy as np +import pytest + +import pedalboard +from pedalboard._pedalboard import _writable_property_names +from pedalboard_native._internal import AddLatency + +from .test_external_plugins import ( + AVAILABLE_EFFECT_PLUGINS_IN_TEST_ENVIRONMENT, + load_test_plugin, +) + +SAMPLE_RATE = 44100 +IMPULSE_RESPONSE_PATH = os.path.join(os.path.dirname(__file__), "impulse_response.wav") + +# Non-default constructor arguments for every built-in plugin, to make sure that +# pickling preserves each plugin's configuration (rather than just its type). +NON_DEFAULT_PLUGINS = [ + pedalboard.Bitcrush(bit_depth=4), + pedalboard.BrickwallLimiter(ceiling_db=-3, release_ms=50, lookahead_ms=2, true_peak=True), + pedalboard.Chorus(rate_hz=2.5, depth=0.5, centre_delay_ms=9, feedback=0.3, mix=0.7), + pedalboard.Clipping(threshold_db=-12), + pedalboard.Compressor(threshold_db=-20, ratio=4, attack_ms=5, release_ms=200), + pedalboard.Delay(delay_seconds=0.25, feedback=0.4, mix=0.3), + pedalboard.Distortion(drive_db=12), + pedalboard.GSMFullRateCompressor(quality=pedalboard.Resample.Quality.Linear), + pedalboard.Gain(gain_db=-6), + pedalboard.HighShelfFilter(cutoff_frequency_hz=2000, gain_db=3, q=1.5), + pedalboard.HighpassFilter(cutoff_frequency_hz=120), + pedalboard.Invert(), + pedalboard.LadderFilter( + mode=pedalboard.LadderFilter.Mode.HPF24, cutoff_hz=800, resonance=0.3, drive=2 + ), + pedalboard.Limiter(threshold_db=-3, release_ms=50), + pedalboard.LowShelfFilter(cutoff_frequency_hz=200, gain_db=-3, q=0.5), + pedalboard.LowpassFilter(cutoff_frequency_hz=5000), + pedalboard.MP3Compressor(vbr_quality=5.5), + pedalboard.NoiseGate(threshold_db=-40, ratio=4, attack_ms=2, release_ms=50), + pedalboard.PeakFilter(cutoff_frequency_hz=1000, gain_db=6, q=2), + pedalboard.Phaser(rate_hz=0.5, depth=0.8, centre_frequency_hz=900, feedback=0.2, mix=0.6), + pedalboard.PitchShift(semitones=3), + pedalboard.Resample( + target_sample_rate=16000, quality=pedalboard.Resample.Quality.ZeroOrderHold + ), + pedalboard.Reverb(room_size=0.8, damping=0.2, wet_level=0.5, dry_level=0.6, width=0.7), + pedalboard.Convolution(IMPULSE_RESPONSE_PATH, mix=0.5), + pedalboard.Convolution( + np.random.default_rng(0).random((2, 1000), dtype=np.float32), sample_rate=22050, mix=0.8 + ), +] + + +def all_plugin_classes(): + def subclasses(klass): + for subclass in klass.__subclasses__(): + yield subclass + yield from subclasses(subclass) + + return sorted(set(subclasses(pedalboard.Plugin)), key=lambda klass: klass.__name__) + + +def constructor_argument_names(klass): + """Parse the argument names out of a Pybind11-generated constructor signature.""" + names = set() + for line in (klass.__init__.__doc__ or "").splitlines(): + if line.startswith("__init__(") or re.match(r"^\d+\. __init__\(", line): + names.update(re.findall(r"[(,] ?(\w+): ", line)) + return names - {"self"} + + +def get_properties(plugin): + return {name: getattr(plugin, name) for name in _writable_property_names(type(plugin))} + + +def noise(num_channels: int = 2, seconds: float = 1.0) -> np.ndarray: + return ( + np.random.default_rng(1234) + .uniform(-0.5, 0.5, (num_channels, int(SAMPLE_RATE * seconds))) + .astype(np.float32) + ) + + +@pytest.mark.parametrize("plugin", NON_DEFAULT_PLUGINS, ids=lambda p: type(p).__name__) +def test_pickle_round_trip_preserves_parameters(plugin): + restored = pickle.loads(pickle.dumps(plugin)) + assert type(restored) is type(plugin) + assert restored is not plugin + assert get_properties(restored) == get_properties(plugin) + + +@pytest.mark.parametrize("plugin", NON_DEFAULT_PLUGINS, ids=lambda p: type(p).__name__) +def test_pickled_plugin_produces_identical_audio(plugin): + audio = noise() + restored = pickle.loads(pickle.dumps(plugin)) + np.testing.assert_allclose(plugin(audio, SAMPLE_RATE), restored(audio, SAMPLE_RATE)) + + +@pytest.mark.parametrize("protocol", range(pickle.HIGHEST_PROTOCOL + 1)) +def test_pickle_protocols(protocol): + board = pedalboard.Pedalboard([pedalboard.Gain(gain_db=-3), pedalboard.Reverb(room_size=0.9)]) + restored = pickle.loads(pickle.dumps(board, protocol=protocol)) + assert [get_properties(p) for p in restored] == [get_properties(p) for p in board] + + +def test_pickle_nested_containers(): + board = pedalboard.Pedalboard( + [ + pedalboard.Gain(gain_db=-6), + pedalboard.Mix( + [ + pedalboard.Chain([pedalboard.Delay(delay_seconds=0.1), pedalboard.Gain(-3)]), + pedalboard.PitchShift(semitones=-2), + ] + ), + pedalboard.Compressor(threshold_db=-18, ratio=3), + ] + ) + restored: Any = pickle.loads(pickle.dumps(board)) + + assert type(restored) is pedalboard.Pedalboard + mix: Any = restored[1] + chain: Any = mix[0] + delay: Any = chain[0] + pitch_shift: Any = mix[1] + assert type(mix) is pedalboard.Mix + assert type(chain) is pedalboard.Chain + assert delay.delay_seconds == pytest.approx(0.1) + assert pitch_shift.semitones == -2 + assert repr(restored).startswith(" Any: + # Loading external plugins occasionally fails in test; see load_test_plugin. + for attempt in range(attempts - 1): + try: + return pickle.loads(data) + except ImportError: + time.sleep(attempt) + return pickle.loads(data) + + +@pytest.mark.parametrize("plugin_filename", AVAILABLE_EFFECT_PLUGINS_IN_TEST_ENVIRONMENT) +def test_pickle_external_plugin(plugin_filename): + plugin: Any = load_test_plugin(plugin_filename, disable_caching=True) + assert os.path.exists(plugin.path_to_plugin_file) + + # Change a parameter, so that we can tell whether the state was restored: + name, parameter = next( + (name, parameter) + for name, parameter in plugin.parameters.items() + if parameter.type is float + ) + parameter.raw_value = 0.2 if abs(parameter.raw_value - 0.2) > 0.05 else 0.8 + expected_value = parameter.raw_value + + restored = loads_with_retries(pickle.dumps(plugin)) + assert type(restored) is type(plugin) + assert restored is not plugin + assert restored.name == plugin.name + assert restored.path_to_plugin_file == plugin.path_to_plugin_file + assert restored.parameters[name].raw_value == pytest.approx(expected_value, abs=1e-4) + + +@pytest.mark.parametrize("plugin_filename", AVAILABLE_EFFECT_PLUGINS_IN_TEST_ENVIRONMENT) +def test_pickle_pedalboard_containing_external_plugin(plugin_filename): + plugin = load_test_plugin(plugin_filename, disable_caching=True) + board = pedalboard.Pedalboard([pedalboard.Gain(gain_db=-3), plugin]) + restored: Any = loads_with_retries(pickle.dumps(board)) + assert type(restored[1]) is type(plugin) + assert restored[1].raw_state == plugin.raw_state