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
40 changes: 40 additions & 0 deletions docs/source/faq.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
16 changes: 16 additions & 0 deletions pedalboard/ExternalPlugin.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<juce::PatchedVST3PluginFormat> &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<juce::PatchedVST3PluginFormat> &plugin) {
Expand Down Expand Up @@ -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<juce::AudioUnitPluginFormat> &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<juce::AudioUnitPluginFormat> &plugin) {
Expand Down
94 changes: 94 additions & 0 deletions pedalboard/_pedalboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
8 changes: 8 additions & 0 deletions pedalboard/plugins/Convolution.h
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,14 @@ inline void init_convolution(py::module &m) {
return {};
}
})
.def_property_readonly(
"impulse_response_sample_rate",
[](JucePlugin<ConvolutionWithMix> &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<ConvolutionWithMix> &plugin) {
Expand Down
30 changes: 30 additions & 0 deletions pedalboard_native/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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:
""" """
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading