From d1b27ab2ae04eba0cf19fc267fb289266071a741 Mon Sep 17 00:00:00 2001 From: Kevin Montag Date: Tue, 30 Jul 2024 17:54:52 +0200 Subject: [PATCH] Add VST3 `preset_data` property to get/set .vstpreset data directly (#351) * Add VST3 `preset_data` property to get/set .vstpreset data directly Setting the property has the same behavior as `load_preset` (but doesn't require a file). Getting the property returns the .vstpreset data representing the current plugin state. Previously, it wasn't quite possible to generate .vstpresets purely from `pedalboard` objects, since they need to include a [class ID](https://forum.juce.com/t/how-to-get-vst3-class-id-aka-cid-aka-component-id/41041/3), which isn't exposed elsewhere. * Fix test formatting --- pedalboard/ExternalPlugin.h | 91 +++++++++++++++++++++++++++------- tests/test_external_plugins.py | 57 +++++++++++++++++++++ 2 files changed, 130 insertions(+), 18 deletions(-) diff --git a/pedalboard/ExternalPlugin.h b/pedalboard/ExternalPlugin.h index 58f1d20f..8243c7b1 100644 --- a/pedalboard/ExternalPlugin.h +++ b/pedalboard/ExternalPlugin.h @@ -640,33 +640,70 @@ class ExternalPlugin : public AbstractExternalPlugin { } } - struct PresetVisitor : public juce::ExtensionsVisitor { - const std::string presetFilePath; + struct SetPresetVisitor : public juce::ExtensionsVisitor { + const juce::MemoryBlock &presetData; + bool didSetPreset; - PresetVisitor(const std::string presetFilePath) - : presetFilePath(presetFilePath) {} + SetPresetVisitor(const juce::MemoryBlock &presetData) + : presetData(presetData), didSetPreset(false) {} void visitVST3Client( const juce::ExtensionsVisitor::VST3Client &client) override { - juce::File presetFile(presetFilePath); - juce::MemoryBlock presetData; + this->didSetPreset = client.setPreset(presetData); + } + }; - if (!presetFile.loadFileAsData(presetData)) { - throw std::runtime_error("Failed to read preset file: " + - presetFilePath); - } + void loadPresetFile(std::string presetFilePath) { + juce::File presetFile(presetFilePath); + juce::MemoryBlock presetData; - if (!client.setPreset(presetData)) { - throw std::runtime_error( - "Plugin returned an error when loading data from preset file: " + - presetFilePath); - } + if (!presetFile.loadFileAsData(presetData)) { + throw std::runtime_error("Failed to read preset file: " + presetFilePath); + } + + SetPresetVisitor visitor{presetData}; + pluginInstance->getExtensions(visitor); + if (!visitor.didSetPreset) { + throw std::runtime_error("Plugin failed to load data from preset file: " + + presetFilePath); + } + } + + void setPreset(const void *data, size_t size) { + juce::MemoryBlock presetData(data, size); + SetPresetVisitor visitor{presetData}; + pluginInstance->getExtensions(visitor); + if (!visitor.didSetPreset) { + throw std::runtime_error("Failed to set preset data for plugin: " + + pathToPluginFile.toStdString()); + } + } + + struct GetPresetVisitor : public juce::ExtensionsVisitor { + // This block will get updated with the current preset data when + // visiting VST3 clients. + juce::MemoryBlock &presetData; + bool didGetPreset; + + GetPresetVisitor(juce::MemoryBlock &presetData) + : presetData(presetData), didGetPreset(false) {} + + void visitVST3Client( + const juce::ExtensionsVisitor::VST3Client &client) override { + this->presetData = client.getPreset(); + this->didGetPreset = true; } }; - void loadPresetData(std::string presetFilePath) { - PresetVisitor visitor{presetFilePath}; + void getPreset(juce::MemoryBlock &dest) const { + // Get the plugin state's .vstpreset representation if possible. + GetPresetVisitor visitor(dest); pluginInstance->getExtensions(visitor); + + if (!visitor.didGetPreset) { + throw std::runtime_error("Failed to get preset data for plugin " + + pathToPluginFile.toStdString()); + } } void reinstantiatePlugin() { @@ -1633,9 +1670,27 @@ example: a Windows VST3 plugin bundle will not load on Linux or macOS.) return ss.str(); }) .def("load_preset", - &ExternalPlugin::loadPresetData, + &ExternalPlugin::loadPresetFile, "Load a VST3 preset file in .vstpreset format.", py::arg("preset_file_path")) + .def_property( + "preset_data", + [](const ExternalPlugin &plugin) { + juce::MemoryBlock presetData; + plugin.getPreset(presetData); + return py::bytes((const char *)presetData.getData(), + presetData.getSize()); + }, + [](ExternalPlugin &plugin, + const py::bytes &presetData) { + py::buffer_info info(py::buffer(presetData).request()); + plugin.setPreset(info.ptr, static_cast(info.size)); + }, + "Get or set the current plugin state as bytes in .vstpreset " + "format.\n\n" + ".. warning::\n This property can be set to change the " + "plugin's internal state, but providing invalid data may cause the " + "plugin to crash, taking the entire Python process down with it.") .def_static( "get_plugin_names_for_file", [](std::string filename) { diff --git a/tests/test_external_plugins.py b/tests/test_external_plugins.py index ac421845..1dbd110c 100644 --- a/tests/test_external_plugins.py +++ b/tests/test_external_plugins.py @@ -85,6 +85,10 @@ AVAILABLE_EFFECT_PLUGINS_IN_TEST_ENVIRONMENT + AVAILABLE_INSTRUMENT_PLUGINS_IN_TEST_ENVIRONMENT ) +AVAILABLE_VST3_PLUGINS_IN_TEST_ENVIRONMENT = [ + f for f in AVAILABLE_PLUGINS_IN_TEST_ENVIRONMENT if "vst3" in f +] + ONE_AVAILABLE_TEST_PLUGIN = ( [AVAILABLE_PLUGINS_IN_TEST_ENVIRONMENT[0]] if AVAILABLE_PLUGINS_IN_TEST_ENVIRONMENT else [] ) @@ -309,6 +313,59 @@ def test_preset_parameters(plugin_filename: str, plugin_preset: str): ), f"Expected attribute {name} to be different from default ({default}), but was {actual}" +@pytest.mark.skipif( + not AVAILABLE_VST3_PLUGINS_IN_TEST_ENVIRONMENT, + reason="No VST3 plugin containers installed in test environment!", +) +@pytest.mark.parametrize("plugin_filename", AVAILABLE_VST3_PLUGINS_IN_TEST_ENVIRONMENT) +def test_get_vst3_preset(plugin_filename: str): + plugin = load_test_plugin(plugin_filename) + preset_data: bytes = plugin.preset_data + + assert ( + preset_data[:4] == b"VST3" + ), "Preset data for {plugin_filename} is not in .vstpreset format" + # Check that the class ID (8 bytes into the data) is a 32-character hex string. + cid = preset_data[8:][:32] + assert all(c in b"0123456789ABCDEF" for c in cid), f"CID contains invalid characters: {cid}" + + +@pytest.mark.skipif(not plugin_named("Magical8BitPlug"), reason="Missing Magical8BitPlug 2 plugin.") +def test_set_vst3_preset(): + plugin_file = plugin_named("Magical8BitPlug") + assert ( + plugin_file is not None and "vst3" in plugin_file + ), f"Expected a vst plugin: {plugin_file}" + plugin = load_test_plugin(plugin_file) + + # Pick a known valid value for one of the plugin parameters. + default_gain_value = plugin.gain + new_gain_value = 1.0 + assert ( + default_gain_value != new_gain_value + ), f"Expected default gain to be different than {new_gain_value}" + + # Update the parameter and get the resulting .vstpreset bytes. + plugin.gain = new_gain_value + preset_data = plugin.preset_data + + # Change the parameter back to the default value. + plugin.gain = default_gain_value + + # Sanity check that the parameter was successfully set. + assert ( + plugin.gain == default_gain_value + ), f"Expected gain to be reset to {default_gain_value}, but got {plugin.gain}" + + # Load the .vstpreset bytes and make sure the parameter was + # updated. + plugin.preset_data = preset_data + + assert ( + plugin.gain == new_gain_value + ), f"Expected gain to be {new_gain_value}, but got {plugin.gain}" + + @pytest.mark.parametrize("plugin_filename", AVAILABLE_PLUGINS_IN_TEST_ENVIRONMENT) def test_initial_parameters(plugin_filename: str): parameters = {