Skip to content

Commit

Permalink
Add VST3 preset_data property to get/set .vstpreset data directly (#…
Browse files Browse the repository at this point in the history
…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
  • Loading branch information
kmontag authored Jul 30, 2024
1 parent e41edc8 commit d1b27ab
Show file tree
Hide file tree
Showing 2 changed files with 130 additions and 18 deletions.
91 changes: 73 additions & 18 deletions pedalboard/ExternalPlugin.h
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -1633,9 +1670,27 @@ example: a Windows VST3 plugin bundle will not load on Linux or macOS.)
return ss.str();
})
.def("load_preset",
&ExternalPlugin<juce::PatchedVST3PluginFormat>::loadPresetData,
&ExternalPlugin<juce::PatchedVST3PluginFormat>::loadPresetFile,
"Load a VST3 preset file in .vstpreset format.",
py::arg("preset_file_path"))
.def_property(
"preset_data",
[](const ExternalPlugin<juce::PatchedVST3PluginFormat> &plugin) {
juce::MemoryBlock presetData;
plugin.getPreset(presetData);
return py::bytes((const char *)presetData.getData(),
presetData.getSize());
},
[](ExternalPlugin<juce::PatchedVST3PluginFormat> &plugin,
const py::bytes &presetData) {
py::buffer_info info(py::buffer(presetData).request());
plugin.setPreset(info.ptr, static_cast<size_t>(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) {
Expand Down
57 changes: 57 additions & 0 deletions tests/test_external_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 []
)
Expand Down Expand Up @@ -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 = {
Expand Down

0 comments on commit d1b27ab

Please sign in to comment.