Skip to content

Commit

Permalink
Add support for instrument plugins that require event loops to run on…
Browse files Browse the repository at this point in the history
… start. (#234)

* Add initialization_delay to support loading plugins with background tasks.

* Pump the message thread a couple times when calling process().

* Remove initialization delay in favour of an explicit test on plugin instantiation.

* Remove unnecessary extra message thread pumps.

* Revert docs changes.

* Remove stray newline.

* Add explicit initialization_timeout parameter.
  • Loading branch information
psobot authored Jul 28, 2023
1 parent 2841914 commit 4e12405
Show file tree
Hide file tree
Showing 6 changed files with 171 additions and 25 deletions.
30 changes: 18 additions & 12 deletions docs/reference/pedalboard.html

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/searchindex.js

Large diffs are not rendered by default.

144 changes: 135 additions & 9 deletions pedalboard/ExternalPlugin.h
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ namespace Pedalboard {
static std::mutex EXTERNAL_PLUGIN_MUTEX;
static int NUM_ACTIVE_EXTERNAL_PLUGINS = 0;

static const float DEFAULT_INITIALIZATION_TIMEOUT_SECONDS = 10.0f;

static const std::string AUDIO_UNIT_NOT_INSTALLED_ERROR =
"macOS requires plugin files to be moved to "
"/Library/Audio/Plug-Ins/Components/ or "
Expand Down Expand Up @@ -424,9 +426,12 @@ class AbstractExternalPlugin : public Plugin {
template <typename ExternalPluginType>
class ExternalPlugin : public AbstractExternalPlugin {
public:
ExternalPlugin(std::string &_pathToPluginFile,
std::optional<std::string> pluginName = {})
: pathToPluginFile(_pathToPluginFile) {
ExternalPlugin(
std::string &_pathToPluginFile,
std::optional<std::string> pluginName = {},
float initializationTimeout = DEFAULT_INITIALIZATION_TIMEOUT_SECONDS)
: pathToPluginFile(_pathToPluginFile),
initializationTimeout(initializationTimeout) {
py::gil_scoped_release release;
// Ensure we have a MessageManager, which is required by the VST wrapper
// Without this, we get an assert(false) from JUCE at runtime
Expand Down Expand Up @@ -676,6 +681,11 @@ class ExternalPlugin : public AbstractExternalPlugin {
}

pluginInstance->reset();

// Try to warm up the plugin.
// Some plugins (mostly instrument plugins) may load resources on start;
// this call attempts to give them time to load those resources.
attemptToWarmUp();
}

void setNumChannels(int numChannels) {
Expand Down Expand Up @@ -758,6 +768,115 @@ class ExternalPlugin : public AbstractExternalPlugin {
return mainInputBus->getNumberOfChannels();
}

/**
* Send a MIDI note into this plugin in an attempt to wait for the plugin to
* "warm up". Many plugins do asynchronous background tasks on launch (such as
* loading assets from disk, etc). These background tasks may depend on the
* event loop, which Pedalboard does not pump by default.
*
* Returns true if the plugin rendered audio within the alloted timeout; false
* if no audio was received before the timeout expired.
*/
bool attemptToWarmUp() {
if (!pluginInstance || initializationTimeout <= 0)
return false;

auto endTime = juce::Time::currentTimeMillis() +
(long)(initializationTimeout * 1000.0);

const int numInputChannels = pluginInstance->getMainBusNumInputChannels();
const float sampleRate = 44100.0f;
const int bufferSize = 2048;

if (numInputChannels != 0) {
// TODO: For effect plugins, do this check as well!
return false;
}

// Set input and output busses/channels appropriately:
int numOutputChannels =
std::max(pluginInstance->getMainBusNumInputChannels(),
pluginInstance->getMainBusNumOutputChannels());
setNumChannels(numOutputChannels);
pluginInstance->setNonRealtime(true);
pluginInstance->prepareToPlay(sampleRate, bufferSize);

// Prepare an empty MIDI buffer to measure the background noise of the
// plugin:
juce::MidiBuffer emptyNoteBuffer;

// Send in a MIDI buffer containing a single middle C at full velocity:
auto noteOn = juce::MidiMessage::noteOn(
/* channel */ 1, /* note number */ 60, /* velocity */ (juce::uint8)127);

// And prepare an all-notes-off buffer:
auto allNotesOff = juce::MidiMessage::allNotesOff(/* channel */ 1);

if (juce::MessageManager::getInstance()->isThisTheMessageThread()) {
for (int i = 0; i < 10; i++) {
if (juce::Time::currentTimeMillis() >= endTime)
return false;
juce::MessageManager::getInstance()->runDispatchLoopUntil(1);
}
}

juce::AudioBuffer<float> audioBuffer(numOutputChannels, bufferSize);
audioBuffer.clear();

pluginInstance->processBlock(audioBuffer, emptyNoteBuffer);
auto noiseFloor = audioBuffer.getMagnitude(0, bufferSize);

audioBuffer.clear();

// Now pass in a middle C:
// Note: we create a new MidiBuffer every time here, as unlike AudioBuffer,
// the messages in a MidiBuffer get erased every time we call processBlock!
{
juce::MidiBuffer noteOnBuffer(noteOn);
pluginInstance->processBlock(audioBuffer, noteOnBuffer);
}

// Then keep pumping the message thread until we get some louder output:
bool magnitudeIncreased = false;
while (true) {
auto magnitudeWithNoteHeld = audioBuffer.getMagnitude(0, bufferSize);
if (magnitudeWithNoteHeld > noiseFloor * 5) {
magnitudeIncreased = true;
break;
}

if (juce::MessageManager::getInstance()->isThisTheMessageThread()) {
for (int i = 0; i < 10; i++) {
juce::MessageManager::getInstance()->runDispatchLoopUntil(1);
}
}

if (juce::Time::currentTimeMillis() >= endTime)
break;

audioBuffer.clear();
{
juce::MidiBuffer noteOnBuffer(noteOn);
pluginInstance->processBlock(audioBuffer, noteOnBuffer);
}

if (juce::Time::currentTimeMillis() >= endTime)
break;
}

// Send in an All Notes Off and then reset, just to make sure we clear any
// note trails:
audioBuffer.clear();
{
juce::MidiBuffer allNotesOffBuffer(allNotesOff);
pluginInstance->processBlock(audioBuffer, allNotesOffBuffer);
}
pluginInstance->reset();
pluginInstance->releaseResources();

return magnitudeIncreased;
}

/**
* Send some audio through the plugin to detect if the ->reset() call
* actually resets internal buffers. This determines how quickly we
Expand Down Expand Up @@ -1125,6 +1244,7 @@ class ExternalPlugin : public AbstractExternalPlugin {
std::unique_ptr<juce::AudioPluginInstance> pluginInstance;

long samplesProvided = 0;
float initializationTimeout = DEFAULT_INITIALIZATION_TIMEOUT_SECONDS;

ExternalPluginReloadType reloadType = ExternalPluginReloadType::Unknown;
};
Expand Down Expand Up @@ -1325,17 +1445,20 @@ example: a Windows VST3 plugin bundle will not load on Linux or macOS.)
)")
.def(
py::init([](std::string &pathToPluginFile, py::object parameterValues,
std::optional<std::string> pluginName) {
std::optional<std::string> pluginName,
float initializationTimeout) {
std::shared_ptr<ExternalPlugin<juce::VST3PluginFormat>> plugin =
std::make_shared<ExternalPlugin<juce::VST3PluginFormat>>(
pathToPluginFile, pluginName);
pathToPluginFile, pluginName, initializationTimeout);
py::cast(plugin).attr("__set_initial_parameter_values__")(
parameterValues);
return plugin;
}),
py::arg("path_to_plugin_file"),
py::arg("parameter_values") = py::none(),
py::arg("plugin_name") = py::none())
py::arg("plugin_name") = py::none(),
py::arg("initialization_timeout") =
DEFAULT_INITIALIZATION_TIMEOUT_SECONDS)
.def("__repr__",
[](ExternalPlugin<juce::VST3PluginFormat> &plugin) {
std::ostringstream ss;
Expand Down Expand Up @@ -1440,18 +1563,21 @@ see :class:`pedalboard.VST3Plugin`.)
)")
.def(
py::init([](std::string &pathToPluginFile, py::object parameterValues,
std::optional<std::string> pluginName) {
std::optional<std::string> pluginName,
float initializationTimeout) {
std::shared_ptr<ExternalPlugin<juce::AudioUnitPluginFormat>>
plugin = std::make_shared<
ExternalPlugin<juce::AudioUnitPluginFormat>>(
pathToPluginFile, pluginName);
pathToPluginFile, pluginName, initializationTimeout);
py::cast(plugin).attr("__set_initial_parameter_values__")(
parameterValues);
return plugin;
}),
py::arg("path_to_plugin_file"),
py::arg("parameter_values") = py::none(),
py::arg("plugin_name") = py::none())
py::arg("plugin_name") = py::none(),
py::arg("initialization_timeout") =
DEFAULT_INITIALIZATION_TIMEOUT_SECONDS)
.def("__repr__",
[](const ExternalPlugin<juce::AudioUnitPluginFormat> &plugin) {
std::ostringstream ss;
Expand Down
16 changes: 13 additions & 3 deletions pedalboard/_pedalboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -710,6 +710,7 @@ def load_plugin(
path_to_plugin_file: str,
parameter_values: Dict[str, Union[str, int, float, bool]] = {},
plugin_name: Union[str, None] = None,
initialization_timeout: float = 10.0,
) -> ExternalPlugin:
"""
Load an audio plugin.
Expand All @@ -719,21 +720,29 @@ def load_plugin(
- Audio Units are supported on macOS
Args:
path_to_plugin_file (str): The path of a VST3® or Audio Unit plugin file or bundle.
path_to_plugin_file (``str``): The path of a VST3® or Audio Unit plugin file or bundle.
parameter_values (Dict[str, Union[str, int, float, bool]]):
parameter_values (``Dict[str, Union[str, int, float, bool]]``):
An optional dictionary of initial values to provide to the plugin
after loading. Keys in this dictionary are expected to match the
parameter names reported by the plugin, but normalized to strings
that can be used as Python identifiers. (These are the same
identifiers that are used as keys in the ``.parameters`` dictionary
of a loaded plugin.)
plugin_name (Optional[str]):
plugin_name (``Optional[str]``):
An optional plugin name that can be used to load a specific plugin
from a multi-plugin package. If a package is loaded but a
``plugin_name`` is not provided, an exception will be thrown.
initialization_timeout (``float``):
The number of seconds that Pedalboard will spend trying to load this plugin.
Some plugins load resources asynchronously in the background on startup;
using larger values for this parameter can give these plugins time to
load properly.
*Introduced in v0.7.6.*
Returns:
an instance of :class:`pedalboard.VST3Plugin` or :class:`pedalboard.AudioUnitPlugin`
Expand All @@ -756,6 +765,7 @@ def load_plugin(
path_to_plugin_file=path_to_plugin_file, # type: ignore
parameter_values=parameter_values, # type: ignore
plugin_name=plugin_name, # type: ignore
initialization_timeout=initialization_timeout, # type: ignore
) # type: ignore
except ImportError as e:
exceptions.append(e)
Expand Down
2 changes: 2 additions & 0 deletions pedalboard_native/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -974,6 +974,7 @@ class AudioUnitPlugin(ExternalPlugin):
path_to_plugin_file: str,
parameter_values: object = None,
plugin_name: typing.Optional[str] = None,
initialization_timeout: float = 10.0,
) -> None: ...
def __repr__(self) -> str: ...
def _get_parameter(self, arg0: str) -> _AudioProcessorParameter: ...
Expand Down Expand Up @@ -1384,6 +1385,7 @@ class VST3Plugin(ExternalPlugin):
path_to_plugin_file: str,
parameter_values: object = None,
plugin_name: typing.Optional[str] = None,
initialization_timeout: float = 10.0,
) -> None: ...
def __repr__(self) -> str: ...
def _get_parameter(self, arg0: str) -> _AudioProcessorParameter: ...
Expand Down
2 changes: 2 additions & 0 deletions tests/test_external_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,8 @@ def test_instrument_plugin_accepts_notes(
plugin = load_test_plugin(plugin_filename)
assert plugin.is_instrument
assert not plugin.is_effect
output = plugin([], 6.0, sample_rate, num_channels=num_channels)
assert np.amax(np.abs(output)) < 1e-5
output = plugin(notes, 6.0, sample_rate, num_channels=num_channels)
assert np.amax(np.abs(output)) > 0

Expand Down

0 comments on commit 4e12405

Please sign in to comment.