Skip to content

Commit

Permalink
Allow passing an Event to show_editor() to close editor windows. (#260)
Browse files Browse the repository at this point in the history
* Allow passing an Event to show_editor() to close editor windows.

Fixes #253.

* Fix tests on CI.

* Fix use of pytest.raises.
  • Loading branch information
psobot authored Oct 7, 2023
1 parent ce16742 commit a213531
Show file tree
Hide file tree
Showing 5 changed files with 112 additions and 18 deletions.
5 changes: 4 additions & 1 deletion docs/source/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,10 @@
"m2r2",
]

intersphinx_mapping = {"mido": ("https://mido.readthedocs.io/en/latest/", None)}
intersphinx_mapping = {
"mido": ("https://mido.readthedocs.io/en/latest/", None),
"python": ("https://docs.python.org/3", None),
}

autosummary_generate = True
autodoc_docstring_signature = True
Expand Down
77 changes: 63 additions & 14 deletions pedalboard/ExternalPlugin.h
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,38 @@ Rendering MIDI via an external instrument plugin::
*Support for instrument plugins introduced in v0.7.4.*
)";

static constexpr const char *SHOW_EDITOR_DOCSTRING = R"(
Show the UI of this plugin as a native window.
This method may only be called on the main thread, and will block
the main thread until any of the following things happens:
- the window is closed by clicking the close button
- the window is closed by pressing the appropriate (OS-specific) keyboard shortcut
- a KeyboardInterrupt (Ctrl-C) is sent to the program
- the :py:meth:`threading.Event.set` method is called (by another thread)
on a provided :py:class:`threading.Event` object
An example of how to programmatically close an editor window::
import pedalboard
from threading import Event, Thread
plugin = pedalboard.load_plugin("../path-to-my-plugin-file")
close_window_event = Event()
def other_thread():
# do something to determine when to close the window
if should_close_window:
close_window_event.set()
thread = Thread(target=other_thread)
thread.run()
# This will block until the other thread calls .set():
plugin.show_editor(close_window_event)
)";

inline std::vector<std::string> findInstalledVSTPluginPaths() {
// 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 @@ -338,22 +370,36 @@ class StandalonePluginWindow : public juce::DocumentWindow {
* Open a native window to show a given AudioProcessor's editor UI,
* pumping the juce::MessageManager run loop as necessary to service
* UI events.
*
* Check the passed threading.Event object every 10ms to close the
* window if necessary.
*/
static void openWindowAndWait(juce::AudioProcessor &processor) {
static void openWindowAndWait(juce::AudioProcessor &processor,
py::object optionalEvent) {
bool shouldThrowErrorAlreadySet = false;

// Check the provided Event object before even opening the window:
if (optionalEvent != py::none() &&
optionalEvent.attr("is_set")().cast<bool>()) {
return;
}

JUCE_AUTORELEASEPOOL {
StandalonePluginWindow window(processor);
window.show();

// Run in a tight loop so that we don't have to call ->stopDispatchLoop(),
// which causes the MessageManager to become unusable in the future.
// The window can be closed by sending a KeyboardInterrupt or closing
// the window in the UI.
// The window can be closed by sending a KeyboardInterrupt, closing
// the window in the UI, or setting the provided Event object.
while (window.isVisible()) {
if (PyErr_CheckSignals() != 0) {
bool errorThrown = PyErr_CheckSignals() != 0;
bool eventSet = optionalEvent != py::none() &&
optionalEvent.attr("is_set")().cast<bool>();

if (errorThrown || eventSet) {
window.closeButtonPressed();
shouldThrowErrorAlreadySet = true;
shouldThrowErrorAlreadySet = errorThrown;
break;
}

Expand Down Expand Up @@ -1215,7 +1261,7 @@ class ExternalPlugin : public AbstractExternalPlugin {
return pluginInstance && pluginInstance->getMainBusNumInputChannels() > 0;
}

void showEditor() {
void showEditor(py::object optionalEvent) {
if (!pluginInstance) {
throw std::runtime_error(
"Editor cannot be shown - plugin not loaded. This is an internal "
Expand All @@ -1232,7 +1278,15 @@ class ExternalPlugin : public AbstractExternalPlugin {
"Plugin UI windows can only be shown from the main thread.");
}

StandalonePluginWindow::openWindowAndWait(*pluginInstance);
if (optionalEvent != py::none() && !py::hasattr(optionalEvent, "is_set")) {
throw py::type_error(
"Pedalboard expected a threading.Event object to be "
"passed to show_editor, but the provided object (\"" +
py::repr(optionalEvent).cast<std::string>() +
"\") does not have an 'is_set' method.");
}

StandalonePluginWindow::openWindowAndWait(*pluginInstance, optionalEvent);
}

private:
Expand Down Expand Up @@ -1501,10 +1555,7 @@ example: a Windows VST3 plugin bundle will not load on Linux or macOS.)
&ExternalPlugin<juce::VST3PluginFormat>::getParameter,
py::return_value_policy::reference_internal)
.def("show_editor", &ExternalPlugin<juce::VST3PluginFormat>::showEditor,
"Show the UI of this plugin as a native window. This method "
"will "
"block until the window is closed or a KeyboardInterrupt is "
"received.")
SHOW_EDITOR_DOCSTRING, py::arg("close_event") = py::none())
.def(
"process",
[](std::shared_ptr<Plugin> self, const py::array inputArray,
Expand Down Expand Up @@ -1624,9 +1675,7 @@ see :class:`pedalboard.VST3Plugin`.)
py::return_value_policy::reference_internal)
.def("show_editor",
&ExternalPlugin<juce::AudioUnitPluginFormat>::showEditor,
"Show the UI of this plugin as a native window. This method will "
"block until the window is closed or a KeyboardInterrupt is "
"received.")
SHOW_EDITOR_DOCSTRING, py::arg("close_event") = py::none())
.def(
"process",
[](std::shared_ptr<Plugin> self, const py::array inputArray,
Expand Down
Empty file added scripts/__init__.py
Empty file.
11 changes: 8 additions & 3 deletions scripts/postprocess_type_hints.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,15 @@
("mode: str = 'w'", r'mode: Literal["w"]'),
# ndarrays need to be corrected as well:
(r"numpy\.ndarray\[(.*?)\]", r"numpy.ndarray[typing.Any, numpy.dtype[\1]]"),
# None of our enums are properly detected by pybind11-stubgen:
(
# For Python 3.6 compatibility:
r"import typing",
"\n".join(
["import typing", "from typing_extensions import Literal", "from enum import Enum"]
[
"import typing",
"from typing_extensions import Literal",
"from enum import Enum",
"import threading",
]
),
),
# None of our enums are properly detected by pybind11-stubgen. These are brittle hacks:
Expand All @@ -62,6 +65,8 @@
(r"def __init__\(self\) -> None: ...", ""),
# Sphinx gets confused when inheriting twice from the same base class:
(r"\(ExternalPlugin, Plugin\)", "(ExternalPlugin)"),
# pybind11 has trouble when trying to include a type hint for a Python type from C++:
(r"close_event: object = None", r"close_event: typing.Optional[threading.Event] = None"),
# We allow passing an optional py::object to ExternalPlugin, but in truth,
# that needs to be Dict[str, Union[str, float, int, bool]]:
# (r": object = None", ": typing.Dict[str, typing.Union[str, float, int, bool]] = {}"),
Expand Down
37 changes: 37 additions & 0 deletions tests/test_external_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import random
import shutil
import platform
import threading
import subprocess
from glob import glob
from pathlib import Path
Expand Down Expand Up @@ -896,6 +897,42 @@ def test_show_editor(plugin_filename: str):
pass


@pytest.mark.parametrize("plugin_filename", AVAILABLE_PLUGINS_IN_TEST_ENVIRONMENT)
@pytest.mark.parametrize("delay", [0.0, 0.5, 1.0])
def test_show_editor_in_process(plugin_filename: str, delay: float):
# Run this test in this process:
full_plugin_filename = find_plugin_path(plugin_filename)
try:
cancel = threading.Event()

if delay:
threading.Thread(target=lambda: time.sleep(delay) or cancel.set()).start()
else:
cancel.set()

pedalboard.load_plugin(full_plugin_filename).show_editor(cancel)
except Exception as e:
if "no visual display devices available" in repr(e):
pass
else:
raise


@pytest.mark.parametrize("plugin_filename", AVAILABLE_PLUGINS_IN_TEST_ENVIRONMENT)
@pytest.mark.parametrize(
"bad_input", [False, 1, {"foo": "bar"}, {"is_set": "False"}, threading.Event]
)
def test_show_editor_passed_something_else(plugin_filename: str, bad_input):
# Run this test in this process:
full_plugin_filename = find_plugin_path(plugin_filename)
plugin = pedalboard.load_plugin(full_plugin_filename)

with pytest.raises((TypeError, RuntimeError)) as e:
plugin.show_editor(bad_input)
if e.type is RuntimeError and "no visual display devices available" not in repr(e.value):
raise e.value


@pytest.mark.skipif(
not AVAILABLE_CONTAINER_EFFECT_PLUGINS_IN_TEST_ENVIRONMENT,
reason="No plugin containers installed in test environment!",
Expand Down

0 comments on commit a213531

Please sign in to comment.