Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow passing an Event to show_editor() to close editor windows. #260

Merged
merged 3 commits into from
Oct 7, 2023
Merged
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
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
Loading