From 9e7a2a2820f900a775f6fe0c8314003524691a5e Mon Sep 17 00:00:00 2001 From: Erik Grahn Date: Thu, 30 Jul 2026 15:06:14 +0200 Subject: [PATCH] add vst3 program list data --- .github/workflows/build.yaml | 6 +- BuiltInProgram.h | 50 ++ CMakeLists.txt | 6 + README.md | 93 ++ SingularityPlugin.h | 1 + .../ExampleInstrument/ExampleInstrument.h | 113 ++- vst3/SingularityVst3.cmake | 29 + vst3/Vst3ComponentState.h | 322 +++++++ vst3/Vst3ParameterSupport.h | 67 ++ vst3/Vst3ProgramData.h | 154 ++++ vst3/Vst3ProgramLayout.h | 95 ++ vst3/Vst3ProgramModel.h | 200 +++++ vst3/tests/Vst3ProgramDataTests.cpp | 848 ++++++++++++++++++ vst3/vst3controller.cpp | 374 +++++++- vst3/vst3controller.h | 101 ++- vst3/vst3processor.h | 731 +++++++++++++-- 16 files changed, 3057 insertions(+), 133 deletions(-) create mode 100644 BuiltInProgram.h create mode 100644 vst3/Vst3ComponentState.h create mode 100644 vst3/Vst3ParameterSupport.h create mode 100644 vst3/Vst3ProgramData.h create mode 100644 vst3/Vst3ProgramLayout.h create mode 100644 vst3/Vst3ProgramModel.h create mode 100644 vst3/tests/Vst3ProgramDataTests.cpp diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 91d9d39..bd82db7 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -52,12 +52,15 @@ jobs: deps-${{ runner.os }}-ninja- - name: Configure - run: cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DSINGULARITY_BUILD_EXAMPLES=ON + run: cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DSINGULARITY_BUILD_EXAMPLES=ON -DSINGULARITY_BUILD_TESTS=ON - name: Build run: | cmake --build build --config Release + - name: Test + run: ctest --test-dir build -C Release --output-on-failure + - name: Upload VST3 artifacts uses: actions/upload-artifact@v7 with: @@ -89,4 +92,3 @@ jobs: uses: softprops/action-gh-release@v2 with: files: artifacts/*.zip - diff --git a/BuiltInProgram.h b/BuiltInProgram.h new file mode 100644 index 0000000..ec510f3 --- /dev/null +++ b/BuiltInProgram.h @@ -0,0 +1,50 @@ +#pragma once + +#include "IParameterProvider.h" +#include +#include +#include +#include +#include + +struct BuiltInProgram +{ + // IDs and ordering are persistent. Adapters may expose programs to hosts by + // index, so existing programs must not be reordered between releases. + std::string id; + std::string name; + std::string category; + std::vector parameters; + + // Optional format-neutral plug-in data. Singularity never interprets these + // bytes: they may contain any serialization or resource reference chosen + // by the plug-in. + std::vector data; +}; + +struct ProgramCollection +{ + std::string id; + std::string name; + + // Parameters owned by this collection. Every program begins with the + // declared parameter defaults and may override a subset of these IDs. + std::vector parameterIds; + std::vector programs; +}; + +template +concept HasProgramCollections = requires +{ + { P::getProgramCollections() }; +}; + +template +concept HandlesProgramData = requires( + P& plugin, + std::string_view collectionId, + std::string_view programId, + std::span data) +{ + { plugin.loadProgramData(collectionId, programId, data) }; +}; diff --git a/CMakeLists.txt b/CMakeLists.txt index 5c8e030..5a85115 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -18,6 +18,12 @@ list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}/cmake") include(SingularityPlugin) option(SINGULARITY_BUILD_EXAMPLES "Build the bundled example projects" OFF) +option(SINGULARITY_BUILD_TESTS "Build Singularity adapter tests" OFF) +if(SINGULARITY_BUILD_TESTS) + include(CTest) + enable_testing() +endif() + if(SINGULARITY_BUILD_EXAMPLES) add_subdirectory(examples) endif() diff --git a/README.md b/README.md index b20d32b..b6fecc0 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,99 @@ public: static_assert(SingularityPlugin); ``` +### VST3 Preset Files + +Singularity exposes component state so a VST3 host can save and restore normal +`.vstpreset` files. Factory preset files should be authored in a host by the +plug-in developer and copied to the standard VST3 factory-preset location by +the product installer. The framework does not generate preset files. + +### Built-in Programs and VST3 Program Lists + +Use a program collection when the plug-in has persistent, numbered built-in +programs—for example, a multitimbral instrument with a collection per MIDI +part. Ordinary named presets should remain host-managed preset files. + +```cpp +static auto getProgramCollections() +{ + return std::to_array({ + { + .id = "performance-bank", + .name = "Performance Bank", + .parameterIds = {13}, + .programs = { + { + .id = "soft", + .name = "Soft", + .category = "Synth", + .parameters = {{13, 0.2}}, + .data = {std::byte {1}}, + }, + }, + }, + }); +} + +// VST3-only host layout. This is separate from the reusable program data. +static auto getVst3ProgramUnits() +{ + return std::to_array({ + { + .id = 1, + .parentId = 0, + .name = "Instrument", + .eventBusIndex = 0, + .midiChannel = 0, + }, + }); +} + +static auto getVst3ProgramListBindings() +{ + return std::to_array({ + {.collectionId = "performance-bank", .unitId = 1}, + }); +} + +void loadProgramData( + std::string_view collectionId, + std::string_view programId, + std::span data) +{ + // Apply optional non-parameter program data. +} +``` + +`ProgramCollection` and `BuiltInProgram` are adapter-neutral. The VST3-only +`Vst3ProgramUnit` and `Vst3ProgramListBinding` types map collections to +`IUnitInfo`, program-selector parameters, and `IProgramListData`. A unit can +own one collection. Collection and program IDs must be non-empty and unique; +collection IDs and program ordering must remain stable because hosts persist +the resulting list ID and program index. Parameters listed in +`ProgramCollection::parameterIds` are assigned to the collection's VST3 unit +without requiring framework parameter groups to mirror VST3 unit IDs. +For instrument plug-ins, `BuiltInProgram::category` is exposed as the VST3 +musical-instrument attribute. It is left unset for effect plug-ins. + +Program payloads are optional. If any program supplies `data`, the plug-in must +implement `loadProgramData()`. The callback runs when the processor applies a +program, including at a process-block boundary for host program changes, so it +must be safe for the audio-processing thread. Plug-ins without +`getVst3ProgramListBindings()` do not expose `IProgramListData`. + +The payload bytes are opaque to Singularity. `getProgramCollections()` can +populate them from compiled resources, generated headers, JSON or another +serialization, compressed data, or references to separately installed +content. Singularity wraps the payload with the program's parameter snapshot +for VST3 transport and returns the payload unchanged to `loadProgramData()`. +Modified program slots are included in VST3 component state so project restore +does not fall back to the original built-in payload. Program lists are fixed +after initialization, and the current VST3 transport limits one program +payload to 64 MiB and all modified payloads in component state to 256 MiB; +large sample libraries should therefore store lightweight identifiers or paths +rather than sample data in each slot. + The UI exports a component from `App.js`: ```js diff --git a/SingularityPlugin.h b/SingularityPlugin.h index d05015d..5c064d2 100644 --- a/SingularityPlugin.h +++ b/SingularityPlugin.h @@ -1,6 +1,7 @@ #pragma once #include #include "IParameterProvider.h" +#include "BuiltInProgram.h" #include "AudioDataExchange.h" using Singularity::AudioDataExchange::sendAudioDataToUI; diff --git a/examples/ExampleInstrument/ExampleInstrument.h b/examples/ExampleInstrument/ExampleInstrument.h index 33b1b9e..1dfa8b5 100644 --- a/examples/ExampleInstrument/ExampleInstrument.h +++ b/examples/ExampleInstrument/ExampleInstrument.h @@ -1,6 +1,7 @@ #pragma once #include "SingularityPlugin.h" +#include "vst3/Vst3ProgramLayout.h" #include class ExampleInstrument { @@ -11,10 +12,117 @@ class ExampleInstrument { static auto getParameters() { return std::to_array({ - { .id = 13, .name = "Volume", .type = ParamType::Float, .minValue = 0.0, .maxValue = 1.0, .defaultValue = 0.5 } + { + .id = 13, + .name = "Volume", + .type = ParamType::Float, + .minValue = 0.0, + .maxValue = 1.0, + .defaultValue = 0.5, + }, + { + .id = 14, + .name = "Brightness", + .type = ParamType::Float, + .minValue = 0.0, + .maxValue = 1.0, + .defaultValue = 0.5, + }, }); } + static auto getVst3ProgramUnits() + { + return std::to_array({ + { + .id = 1, + .parentId = 0, + .name = "Instrument", + .eventBusIndex = 0, + .midiChannel = 0, + }, + { + .id = 2, + .parentId = 1, + .name = "Tone", + .eventBusIndex = 0, + .midiChannel = 1, + }, + }); + } + + static auto getVst3ProgramListBindings() + { + return std::to_array({ + {.collectionId = "performance-bank", .unitId = 1}, + {.collectionId = "tone-bank", .unitId = 2}, + }); + } + + static auto getProgramCollections() + { + return std::to_array({ + { + .id = "performance-bank", + .name = "Performance Bank", + .parameterIds = {13}, + .programs = { + { + .id = "default", + .name = "Default", + .category = "Synth", + .parameters = {{13, 0.5}}, + .data = {std::byte {0}}, + }, + { + .id = "soft", + .name = "Soft", + .category = "Synth", + .parameters = {{13, 0.2}}, + .data = {std::byte {1}}, + }, + { + .id = "full", + .name = "Full", + .category = "Synth", + .parameters = {{13, 0.9}}, + .data = {std::byte {2}}, + }, + }, + }, + { + .id = "tone-bank", + .name = "Tone Bank", + .parameterIds = {14}, + .programs = { + { + .id = "dark", + .name = "Dark", + .category = "Synth", + .parameters = {{14, 0.2}}, + .data = {std::byte {3}}, + }, + { + .id = "bright", + .name = "Bright", + .category = "Synth", + .parameters = {{14, 0.8}}, + .data = {std::byte {4}}, + }, + }, + }, + }); + } + + void loadProgramData( + std::string_view, + std::string_view, + std::span data) + { + programVariant_ = + data.empty() ? 0 : std::to_integer(data.front()); + } + void prepare(double sampleRate, int maxBlockSize) {} template @@ -26,6 +134,9 @@ class ExampleInstrument { for (auto* output : outputs) std::fill_n(output, numSamples, SampleType{}); } + +private: + int programVariant_ = 0; }; static_assert(SingularityPlugin); diff --git a/vst3/SingularityVst3.cmake b/vst3/SingularityVst3.cmake index 690c23e..9646a48 100644 --- a/vst3/SingularityVst3.cmake +++ b/vst3/SingularityVst3.cmake @@ -98,6 +98,11 @@ function(singularity_create_vst3_plugin target) smtg_add_vst3plugin(${target}_VST3 PACKAGE_NAME "${VST3_PLUGIN_TITLE}" ${SINGULARITY_ROOT_DIR}/vst3/vst3version.h + ${SINGULARITY_ROOT_DIR}/vst3/Vst3ComponentState.h + ${SINGULARITY_ROOT_DIR}/vst3/Vst3ParameterSupport.h + ${SINGULARITY_ROOT_DIR}/vst3/Vst3ProgramData.h + ${SINGULARITY_ROOT_DIR}/vst3/Vst3ProgramLayout.h + ${SINGULARITY_ROOT_DIR}/vst3/Vst3ProgramModel.h ${SINGULARITY_ROOT_DIR}/vst3/vst3processor.h ${SINGULARITY_ROOT_DIR}/vst3/vst3controller.h ${SINGULARITY_ROOT_DIR}/vst3/vst3controller.cpp @@ -161,6 +166,30 @@ function(singularity_create_vst3_plugin target) ${VST3_SOURCE_DIR} ) + if(SINGULARITY_BUILD_TESTS) + add_executable(${target}_VST3_ProgramDataTests + ${SINGULARITY_ROOT_DIR}/vst3/tests/Vst3ProgramDataTests.cpp + ${SINGULARITY_ROOT_DIR}/vst3/vst3controller.cpp + ${SINGULARITY_ROOT_DIR}/vst3/SingularityView.cpp + ${SINGULARITY_VST3_PUBLIC_SDK_DIR}/source/common/memorystream.cpp) + target_compile_features( + ${target}_VST3_ProgramDataTests PRIVATE cxx_std_23) + target_compile_definitions(${target}_VST3_ProgramDataTests PRIVATE + PLUGIN_CLASS=${VST3_PLUGIN_CLASS} + PLUGIN_CLASS_HEADER="${VST3_PLUGIN_CLASS_HEADER}") + target_include_directories(${target}_VST3_ProgramDataTests PRIVATE + ${SINGULARITY_ROOT_DIR}/platform + ${SINGULARITY_ROOT_DIR} + ${SINGULARITY_ROOT_DIR}/vst3 + ${VST3_BINARY_DIR} + ${VST3_SOURCE_DIR}) + target_link_libraries(${target}_VST3_ProgramDataTests + PRIVATE sdk ${VST3_BASE_TARGET}) + add_test( + NAME ${target}_VST3_ProgramDataTests + COMMAND ${target}_VST3_ProgramDataTests) + endif() + smtg_target_configure_version_file(${target}_VST3) if(SMTG_MAC) diff --git a/vst3/Vst3ComponentState.h b/vst3/Vst3ComponentState.h new file mode 100644 index 0000000..0acfdf5 --- /dev/null +++ b/vst3/Vst3ComponentState.h @@ -0,0 +1,322 @@ +#pragma once + +#include "IParameterProvider.h" +#include "Vst3ProgramData.h" +#include "base/source/fstreamer.h" +#include "pluginterfaces/base/ibstream.h" +#include "pluginterfaces/vst/ivstunits.h" +#include +#include +#include +#include +#include +#include + +namespace Steinberg::SingularityVst3 { + +inline constexpr int32 kComponentStateMagic = 0x53475354; // "SGST" +inline constexpr int32 kComponentStateVersion = 1; +inline constexpr int32 kMaximumStateProgramEntries = 1024; +inline constexpr int32 kMaximumStateProgramParameters = 65536; +inline constexpr int32 kMaximumStateProgramPayloadBytes = + 256 * 1024 * 1024; + +struct ProgramSelection +{ + Vst::ProgramListID listId = Vst::kNoProgramListId; + int32 programIndex = 0; +}; + +struct ProgramSlotState +{ + Vst::ProgramListID listId = Vst::kNoProgramListId; + int32 programIndex = 0; + ProgramData data; +}; + +struct ComponentState +{ + bool bypass = false; + std::vector parameterValues; + std::vector programSelections; + std::vector modifiedPrograms; +}; + +inline bool isWritableParameter( + std::span parameters, + Vst::ParamID id) +{ + return std::ranges::any_of( + parameters, + [id](const auto& parameter) + { + return parameter.id == id && !parameter.readOnly; + }); +} + +inline bool writeProgramState( + IBStream* stream, + IBStreamer& streamer, + const ComponentState& state) +{ + std::size_t serializedParameters = 0; + std::size_t serializedPayloadBytes = 0; + if (state.programSelections.size() > + static_cast(kMaximumStateProgramEntries) || + state.modifiedPrograms.size() > + static_cast(kMaximumStateProgramEntries) || + !streamer.writeInt32( + static_cast(state.programSelections.size()))) + return false; + + for (const auto& selection : state.programSelections) + { + if (selection.listId < 0 || selection.programIndex < 0 || + !streamer.writeInt32(selection.listId) || + !streamer.writeInt32(selection.programIndex)) + return false; + } + + if (!streamer.writeInt32( + static_cast(state.modifiedPrograms.size()))) + return false; + for (const auto& program : state.modifiedPrograms) + { + serializedParameters += program.data.parameters.size(); + serializedPayloadBytes += program.data.payload.size(); + if (program.listId < 0 || program.programIndex < 0 || + serializedParameters > + static_cast( + kMaximumStateProgramParameters) || + serializedPayloadBytes > + static_cast( + kMaximumStateProgramPayloadBytes) || + !streamer.writeInt32(program.listId) || + !streamer.writeInt32(program.programIndex) || + !writeProgramData(stream, program.data)) + return false; + } + return true; +} + +inline bool readProgramState( + IBStream* stream, + IBStreamer& streamer, + ComponentState& state, + bool includesModifiedPrograms) +{ + int32 selectionCount = 0; + if (!streamer.readInt32(selectionCount) || selectionCount < 0 || + selectionCount > kMaximumStateProgramEntries) + return false; + state.programSelections.reserve( + static_cast(selectionCount)); + for (int32 index = 0; index < selectionCount; ++index) + { + ProgramSelection selection; + if (!streamer.readInt32(selection.listId) || + !streamer.readInt32(selection.programIndex) || + selection.listId < 0 || selection.programIndex < 0) + return false; + state.programSelections.push_back(selection); + } + + if (!includesModifiedPrograms) + return true; + + int32 modifiedCount = 0; + if (!streamer.readInt32(modifiedCount) || modifiedCount < 0 || + modifiedCount > kMaximumStateProgramEntries) + return false; + state.modifiedPrograms.reserve(static_cast(modifiedCount)); + int32 remainingParameters = kMaximumStateProgramParameters; + int32 remainingPayloadBytes = kMaximumStateProgramPayloadBytes; + for (int32 index = 0; index < modifiedCount; ++index) + { + ProgramSlotState program; + if (!streamer.readInt32(program.listId) || + !streamer.readInt32(program.programIndex) || + program.listId < 0 || program.programIndex < 0 || + !readProgramData( + stream, + program.data, + remainingParameters, + std::min( + remainingPayloadBytes, + kMaximumSerializedPayloadBytes))) + return false; + remainingParameters -= + static_cast(program.data.parameters.size()); + remainingPayloadBytes -= + static_cast(program.data.payload.size()); + state.modifiedPrograms.push_back(std::move(program)); + } + return true; +} + +inline bool writeComponentState( + IBStream* stream, + std::span parameters, + const ComponentState& state) +{ + if (!stream) + return false; + + IBStreamer streamer(stream, kLittleEndian); + if (!streamer.writeInt32(kComponentStateMagic) || + !streamer.writeInt32(kComponentStateVersion) || + !streamer.writeInt32(state.bypass ? 1 : 0)) + return false; + + const auto writableCount = std::ranges::count_if( + parameters, + [](const auto& parameter) { return !parameter.readOnly; }); + if (writableCount > kMaximumSerializedParameters || + !streamer.writeInt32(static_cast(writableCount))) + return false; + + std::unordered_set writtenIds; + for (const auto& parameter : parameters) + { + if (parameter.readOnly) + continue; + + auto value = 0.0; + auto found = false; + for (const auto& [id, candidate] : state.parameterValues) + { + if (id == parameter.id) + { + value = candidate; + found = true; + break; + } + } + if (!found || !std::isfinite(value) || + !writtenIds.insert(parameter.id).second || + !streamer.writeInt32(static_cast(parameter.id)) || + !streamer.writeDouble(value)) + return false; + } + + return writeProgramState(stream, streamer, state); +} + +inline bool readVersionedComponentState( + IBStream* stream, + std::span parameters, + IBStreamer& streamer, + ComponentState& state) +{ + int32 version = 0; + int32 bypass = 0; + int32 parameterCount = 0; + if (!streamer.readInt32(version) || + version != kComponentStateVersion || + !streamer.readInt32(bypass) || + (bypass != 0 && bypass != 1) || + !streamer.readInt32(parameterCount) || + parameterCount < 0 || + parameterCount > kMaximumSerializedParameters) + return false; + + ComponentState decoded; + decoded.bypass = bypass != 0; + std::unordered_set decodedIds; + decodedIds.reserve(static_cast(parameterCount)); + for (int32 index = 0; index < parameterCount; ++index) + { + int32 rawId = 0; + double value = 0.0; + if (!streamer.readInt32(rawId) || rawId < 0 || + !streamer.readDouble(value) || !std::isfinite(value)) + return false; + + const auto id = static_cast(rawId); + if (id > Vst::kMaxParamId || !decodedIds.insert(id).second) + return false; + if (isWritableParameter(parameters, id)) + decoded.parameterValues.emplace_back( + id, std::clamp(value, 0.0, 1.0)); + } + + if (!readProgramState(stream, streamer, decoded, true)) + return false; + state = std::move(decoded); + return true; +} + +inline bool readLegacyComponentState( + IBStream* stream, + std::span parameters, + IBStreamer& streamer, + int32 bypass, + ComponentState& state) +{ + ComponentState decoded; + decoded.bypass = bypass != 0; + for (const auto& parameter : parameters) + { + if (parameter.readOnly) + continue; + + double value = 0.0; + if (!streamer.readDouble(value)) + break; + if (!std::isfinite(value)) + return false; + decoded.parameterValues.emplace_back( + parameter.id, std::clamp(value, 0.0, 1.0)); + } + + int32 magic = 0; + if (streamer.readInt32(magic) && magic == kStateExtensionMagic) + { + int32 version = 0; + if (!streamer.readInt32(version)) + return false; + if (version == 1) + { + int32 programIndex = 0; + if (!streamer.readInt32(programIndex) || programIndex < 0) + return false; + decoded.programSelections.push_back( + {Vst::kNoProgramListId, programIndex}); + } + else if (version == 2 || version == kStateExtensionVersion) + { + if (!readProgramState( + stream, streamer, decoded, version >= 3)) + return false; + } + else + { + return false; + } + } + + state = std::move(decoded); + return true; +} + +inline bool readComponentState( + IBStream* stream, + std::span parameters, + ComponentState& state) +{ + if (!stream) + return false; + + IBStreamer streamer(stream, kLittleEndian); + int32 magicOrLegacyBypass = 0; + if (!streamer.readInt32(magicOrLegacyBypass)) + return false; + + if (magicOrLegacyBypass == kComponentStateMagic) + return readVersionedComponentState( + stream, parameters, streamer, state); + return readLegacyComponentState( + stream, parameters, streamer, magicOrLegacyBypass, state); +} + +} // namespace Steinberg::SingularityVst3 diff --git a/vst3/Vst3ParameterSupport.h b/vst3/Vst3ParameterSupport.h new file mode 100644 index 0000000..411cc02 --- /dev/null +++ b/vst3/Vst3ParameterSupport.h @@ -0,0 +1,67 @@ +#pragma once + +#include "IParameterProvider.h" +#include +#include + +namespace Steinberg::SingularityVst3 { + +inline double plainToNormalized( + const ::Parameter& parameter, + double plainValue) +{ + if (parameter.type == ParamType::Bool) + return plainValue >= 0.5 ? 1.0 : 0.0; + + if (parameter.type == ParamType::Choice && !parameter.choices.empty()) + { + const auto maxIndex = + static_cast(parameter.choices.size() - 1); + if (maxIndex <= 0.0) + return 0.0; + return std::clamp(std::round(plainValue) / maxIndex, 0.0, 1.0); + } + + if (parameter.type == ParamType::Stepped && parameter.steps > 1) + { + const auto maxStep = static_cast(parameter.steps - 1); + return std::clamp(std::round(plainValue) / maxStep, 0.0, 1.0); + } + + if (parameter.maxValue == parameter.minValue) + return 0.0; + + return std::clamp( + (plainValue - parameter.minValue) / + (parameter.maxValue - parameter.minValue), + 0.0, + 1.0); +} + +inline double normalizedToPlain( + const ::Parameter& parameter, + double normalizedValue) +{ + const auto clamped = std::clamp(normalizedValue, 0.0, 1.0); + + if (parameter.type == ParamType::Bool) + return clamped >= 0.5 ? 1.0 : 0.0; + + if (parameter.type == ParamType::Choice && !parameter.choices.empty()) + return std::round( + clamped * static_cast(parameter.choices.size() - 1)); + + if (parameter.type == ParamType::Stepped && parameter.steps > 1) + return std::round( + clamped * static_cast(parameter.steps - 1)); + + const auto plain = + parameter.minValue + + clamped * (parameter.maxValue - parameter.minValue); + if (parameter.type == ParamType::Stepped) + return std::round(plain); + + return plain; +} + +} // namespace Steinberg::SingularityVst3 diff --git a/vst3/Vst3ProgramData.h b/vst3/Vst3ProgramData.h new file mode 100644 index 0000000..3d7ac4f --- /dev/null +++ b/vst3/Vst3ProgramData.h @@ -0,0 +1,154 @@ +#pragma once + +#include "base/source/fstreamer.h" +#include "pluginterfaces/base/ibstream.h" +#include "pluginterfaces/vst/vsttypes.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Steinberg::SingularityVst3 { + +inline constexpr int32 kProgramDataMagic = 0x53505247; // "SPRG" +inline constexpr int32 kProgramDataVersion = 2; +inline constexpr int32 kStateExtensionMagic = 0x53505354; // "SPST" +inline constexpr int32 kStateExtensionVersion = 3; +inline constexpr int32 kMaximumSerializedParameters = 65536; +inline constexpr int32 kMaximumSerializedPayloadBytes = 64 * 1024 * 1024; + +using SerializedParameter = std::pair; + +struct ProgramData +{ + std::vector parameters; + std::vector payload; +}; + +// Program-list IDs and their selector parameter IDs share a stable value. +// Keeping them in the upper half of the positive ParamID range avoids the +// small IDs normally chosen by plug-in authors; collisions are still checked +// during initialization. +inline Vst::ParamID programListId(std::string_view stableBankId) +{ + uint32_t hash = 2166136261u; + for (const auto character : stableBankId) + { + hash ^= static_cast(character); + hash *= 16777619u; + } + return static_cast( + 0x40000000u | (hash & 0x3ffffffeu)); +} + +inline bool writeProgramData( + IBStream* stream, + const ProgramData& data) +{ + if (!stream || + data.parameters.size() > + static_cast(kMaximumSerializedParameters) || + data.payload.size() > + static_cast(kMaximumSerializedPayloadBytes)) + return false; + + IBStreamer streamer(stream, kLittleEndian); + if (!streamer.writeInt32(kProgramDataMagic) || + !streamer.writeInt32(kProgramDataVersion) || + !streamer.writeInt32(static_cast(data.parameters.size()))) + return false; + + for (const auto& [id, value] : data.parameters) + { + if (id > Vst::kMaxParamId || !std::isfinite(value) || + !streamer.writeInt32(static_cast(id)) || + !streamer.writeDouble(value)) + return false; + } + + if (!streamer.writeInt32(static_cast(data.payload.size()))) + return false; + if (data.payload.empty()) + return true; + + int32 bytesWritten = 0; + return stream->write( + const_cast(data.payload.data()), + static_cast(data.payload.size()), + &bytesWritten) == kResultTrue && + bytesWritten == static_cast(data.payload.size()); +} + +inline bool readProgramData( + IBStream* stream, + ProgramData& data, + int32 maximumParameters = kMaximumSerializedParameters, + int32 maximumPayloadBytes = kMaximumSerializedPayloadBytes) +{ + if (!stream || maximumParameters < 0 || + maximumParameters > kMaximumSerializedParameters || + maximumPayloadBytes < 0 || + maximumPayloadBytes > kMaximumSerializedPayloadBytes) + return false; + + IBStreamer streamer(stream, kLittleEndian); + int32 magic = 0; + int32 version = 0; + int32 count = 0; + if (!streamer.readInt32(magic) || magic != kProgramDataMagic || + !streamer.readInt32(version) || + (version != 1 && version != kProgramDataVersion) || + !streamer.readInt32(count) || count < 0 || + count > maximumParameters) + return false; + + std::vector decoded; + decoded.reserve(static_cast(count)); + std::unordered_set decodedIds; + decodedIds.reserve(static_cast(count)); + for (int32 index = 0; index < count; ++index) + { + int32 rawId = 0; + double value = 0.0; + if (!streamer.readInt32(rawId) || rawId < 0 || + !streamer.readDouble(value) || !std::isfinite(value)) + return false; + + const auto id = static_cast(rawId); + if (id > Vst::kMaxParamId) + return false; + if (!decodedIds.insert(id).second) + return false; + + decoded.emplace_back(id, std::clamp(value, 0.0, 1.0)); + } + + std::vector payload; + if (version >= 2) + { + int32 payloadSize = 0; + if (!streamer.readInt32(payloadSize) || payloadSize < 0 || + payloadSize > maximumPayloadBytes) + return false; + + payload.resize(static_cast(payloadSize)); + if (payloadSize > 0) + { + int32 bytesRead = 0; + if (stream->read(payload.data(), payloadSize, &bytesRead) != + kResultTrue || + bytesRead != payloadSize) + return false; + } + } + + data.parameters = std::move(decoded); + data.payload = std::move(payload); + return true; +} + +} // namespace Steinberg::SingularityVst3 diff --git a/vst3/Vst3ProgramLayout.h b/vst3/Vst3ProgramLayout.h new file mode 100644 index 0000000..363425d --- /dev/null +++ b/vst3/Vst3ProgramLayout.h @@ -0,0 +1,95 @@ +#pragma once + +#include "BuiltInProgram.h" +#include +#include +#include +#include +#include +#include + +struct Vst3ProgramUnit +{ + // Unit zero is implicit and reserved for the root. + int32_t id = 0; + int32_t parentId = 0; + std::string name; + + // Optional zero-based event-bus and MIDI-channel association. Set either + // value to -1 when the unit is not tied to a MIDI input channel. + int32_t eventBusIndex = -1; + int32_t midiChannel = -1; +}; + +struct Vst3ProgramListBinding +{ + std::string collectionId; + int32_t unitId = 0; +}; + +inline bool validateVst3ProgramUnits(std::span units) +{ + std::unordered_set ids {0}; + std::unordered_set midiMappings; + for (const auto& unit : units) + { + if (unit.id <= 0 || unit.name.empty() || + unit.eventBusIndex < -1 || unit.midiChannel < -1 || + unit.midiChannel > 15 || + ((unit.eventBusIndex < 0) != (unit.midiChannel < 0)) || + !ids.insert(unit.id).second) + return false; + + if (unit.midiChannel >= 0) + { + const auto mapping = + (static_cast( + static_cast(unit.eventBusIndex)) << 32u) | + static_cast(unit.midiChannel); + if (!midiMappings.insert(mapping).second) + return false; + } + } + + for (const auto& unit : units) + { + if (!ids.contains(unit.parentId)) + return false; + + std::unordered_set ancestors; + auto parentId = unit.parentId; + while (parentId != 0) + { + if (!ancestors.insert(parentId).second) + return false; + + const auto parent = std::find_if( + units.begin(), + units.end(), + [parentId](const auto& candidate) + { + return candidate.id == parentId; + }); + if (parent == units.end()) + return false; + parentId = parent->parentId; + } + } + return true; +} + +template +concept HasVst3ProgramUnits = requires +{ + { P::getVst3ProgramUnits() }; +}; + +template +concept HasVst3ProgramListBindings = requires +{ + { P::getVst3ProgramListBindings() }; +}; + +template +concept HasVst3ProgramLists = + HasProgramCollections

&& HasVst3ProgramListBindings

; diff --git a/vst3/Vst3ProgramModel.h b/vst3/Vst3ProgramModel.h new file mode 100644 index 0000000..079b2ad --- /dev/null +++ b/vst3/Vst3ProgramModel.h @@ -0,0 +1,200 @@ +#pragma once + +#include "Vst3ParameterSupport.h" +#include "Vst3ProgramData.h" +#include "Vst3ProgramLayout.h" +#include "pluginterfaces/vst/ivstunits.h" +#include +#include +#include +#include +#include +#include +#include + +namespace Steinberg::SingularityVst3 { + +struct ProgramModelBank +{ + ::ProgramCollection definition; + int32 unitId = Vst::kRootUnitId; + Vst::ProgramListID listId = Vst::kNoProgramListId; + Vst::ParamID selectorId = Vst::kNoParamId; + std::vector programs; +}; + +struct ProgramModel +{ + std::vector<::Vst3ProgramUnit> units; + std::vector banks; +}; + +template +std::optional buildProgramModel() +{ + ProgramModel model; + if constexpr (!HasVst3ProgramLists) + { + return model; + } + else + { + if constexpr (HasVst3ProgramUnits) + for (const auto& unit : PluginType::getVst3ProgramUnits()) + model.units.push_back(unit); + if (!validateVst3ProgramUnits(model.units)) + return std::nullopt; + + std::vector<::Vst3ProgramListBinding> bindings; + for (const auto& binding : PluginType::getVst3ProgramListBindings()) + bindings.push_back(binding); + + const auto pluginParameters = PluginType::getParameters(); + std::unordered_set parameterIds; + for (const auto& parameter : pluginParameters) + if (parameter.id >= Vst::kMaxParamId || + !parameterIds.insert(parameter.id).second) + return std::nullopt; + + std::unordered_set unitIds {Vst::kRootUnitId}; + for (const auto& unit : model.units) + unitIds.insert(unit.id); + + std::unordered_set collectionIds; + std::unordered_set listIds; + std::unordered_set unitsWithBanks; + std::unordered_set parametersInCollections; + std::size_t collectionCount = 0; + for (const auto& definition : PluginType::getProgramCollections()) + { + ++collectionCount; + if (definition.id.empty() || definition.name.empty() || + definition.programs.empty() || + definition.programs.size() > + static_cast( + std::numeric_limits::max() - 1) || + definition.parameterIds.size() > + static_cast( + kMaximumSerializedParameters) || + !collectionIds.insert(definition.id).second) + return std::nullopt; + + const auto binding = std::find_if( + bindings.begin(), + bindings.end(), + [&definition](const auto& candidate) + { + return candidate.collectionId == definition.id; + }); + if (binding == bindings.end() || + !unitIds.contains(binding->unitId) || + !unitsWithBanks.insert(binding->unitId).second) + return std::nullopt; + + ProgramModelBank bank; + bank.definition = definition; + bank.unitId = binding->unitId; + bank.listId = programListId(definition.id); + bank.selectorId = bank.listId; + if (!listIds.insert(bank.listId).second) + return std::nullopt; + for (const auto& parameter : pluginParameters) + if (parameter.id == bank.selectorId || + parameter.id == Vst::kMaxParamId) + return std::nullopt; + + std::unordered_set collectionParameterIds; + for (const auto id : definition.parameterIds) + { + const auto parameter = std::find_if( + pluginParameters.begin(), + pluginParameters.end(), + [id](const auto& candidate) + { + return candidate.id == id; + }); + if (parameter == pluginParameters.end() || + parameter->readOnly || + !collectionParameterIds.insert(id).second || + !parametersInCollections.insert(id).second) + return std::nullopt; + } + + std::unordered_set programIds; + for (const auto& program : definition.programs) + { + if (program.id.empty() || program.name.empty() || + program.data.size() > + static_cast( + kMaximumSerializedPayloadBytes) || + !programIds.insert(program.id).second) + return std::nullopt; + if constexpr (!HandlesProgramData) + if (!program.data.empty()) + return std::nullopt; + + ProgramData data; + data.payload = program.data; + for (const auto id : definition.parameterIds) + { + const auto parameter = std::find_if( + pluginParameters.begin(), + pluginParameters.end(), + [id](const auto& candidate) + { + return candidate.id == id; + }); + if (parameter == pluginParameters.end()) + return std::nullopt; + data.parameters.emplace_back( + parameter->id, + plainToNormalized( + *parameter, parameter->defaultValue)); + } + + std::unordered_set changedParameterIds; + for (const auto& change : program.parameters) + { + if (!std::isfinite(change.value) || + !changedParameterIds.insert(change.id).second) + return std::nullopt; + + const auto stored = std::find_if( + data.parameters.begin(), + data.parameters.end(), + [&change](const auto& candidate) + { + return static_cast(candidate.first) == + change.id; + }); + if (stored == data.parameters.end()) + return std::nullopt; + + const auto parameter = std::find_if( + pluginParameters.begin(), + pluginParameters.end(), + [&stored](const auto& candidate) + { + return candidate.id == stored->first; + }); + if (parameter == pluginParameters.end()) + return std::nullopt; + stored->second = + plainToNormalized(*parameter, change.value); + } + bank.programs.push_back(std::move(data)); + auto& storedDefinition = + bank.definition.programs[bank.programs.size() - 1]; + storedDefinition.parameters.clear(); + storedDefinition.data.clear(); + } + model.banks.push_back(std::move(bank)); + } + + if (bindings.size() != collectionCount) + return std::nullopt; + return model; + } +} + +} // namespace Steinberg::SingularityVst3 diff --git a/vst3/tests/Vst3ProgramDataTests.cpp b/vst3/tests/Vst3ProgramDataTests.cpp new file mode 100644 index 0000000..7675037 --- /dev/null +++ b/vst3/tests/Vst3ProgramDataTests.cpp @@ -0,0 +1,848 @@ +#include PLUGIN_CLASS_HEADER + +#include "BuiltInProgram.h" +#include "Vst3ComponentState.h" +#include "Vst3ParameterSupport.h" +#include "Vst3ProgramData.h" +#include "Vst3ProgramLayout.h" +#include "plugincids.h" +#include "public.sdk/source/common/memorystream.h" +#include "public.sdk/source/vst/vstpresetfile.h" +#include "vst3controller.h" +#include "vst3processor.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace Steinberg; +using namespace Steinberg::SingularityVst3; + +int failures = 0; + +void expect(bool condition, std::string_view message) +{ + if (condition) + return; + ++failures; + std::cerr << "FAIL: " << message << '\n'; +} + +bool approximatelyEqual(double first, double second) +{ + return std::abs(first - second) < 1.0e-9; +} + +const Parameter* findParameter( + std::span parameters, + unsigned int id) +{ + const auto iterator = std::find_if( + parameters.begin(), + parameters.end(), + [id](const auto& parameter) { return parameter.id == id; }); + return iterator == parameters.end() ? nullptr : &*iterator; +} + +double stateParameterValue( + const ComponentState& state, + Vst::ParamID id, + double fallback = -1.0) +{ + const auto value = std::find_if( + state.parameterValues.begin(), + state.parameterValues.end(), + [id](const auto& candidate) { return candidate.first == id; }); + return value == state.parameterValues.end() ? fallback : value->second; +} + +void testComponentStateSchema() +{ + const auto originalParameters = std::to_array({ + {.id = 10, .name = "First"}, + {.id = 20, .name = "Second"}, + }); + ComponentState original; + original.bypass = true; + original.parameterValues = {{10, 0.25}, {20, 0.75}}; + + MemoryStream versionedStream; + expect( + writeComponentState( + &versionedStream, originalParameters, original), + "could not write versioned component state"); + + const auto changedParameters = std::to_array({ + {.id = 30, .name = "Added"}, + {.id = 20, .name = "Second"}, + {.id = 10, .name = "First"}, + }); + versionedStream.seek(0, IBStream::kIBSeekSet, nullptr); + ComponentState migrated; + expect( + readComponentState( + &versionedStream, changedParameters, migrated), + "could not read versioned state after a parameter schema change"); + expect( + migrated.bypass && + migrated.parameterValues.size() == 2 && + approximatelyEqual(stateParameterValue(migrated, 10), 0.25) && + approximatelyEqual(stateParameterValue(migrated, 20), 0.75) && + stateParameterValue(migrated, 30) < 0.0, + "versioned state did not map parameter values by stable ID"); + + const auto reducedParameters = std::to_array({ + {.id = 20, .name = "Second"}, + }); + versionedStream.seek(0, IBStream::kIBSeekSet, nullptr); + ComponentState reduced; + expect( + readComponentState( + &versionedStream, reducedParameters, reduced) && + reduced.parameterValues.size() == 1 && + approximatelyEqual(stateParameterValue(reduced, 20), 0.75), + "versioned state did not ignore a removed parameter"); + + MemoryStream legacyStream; + IBStreamer legacyWriter(&legacyStream, kLittleEndian); + expect( + legacyWriter.writeInt32(1) && + legacyWriter.writeDouble(0.125) && + legacyWriter.writeDouble(0.875), + "could not create legacy positional component state"); + legacyStream.seek(0, IBStream::kIBSeekSet, nullptr); + ComponentState legacy; + expect( + readComponentState( + &legacyStream, originalParameters, legacy) && + legacy.bypass && + approximatelyEqual(stateParameterValue(legacy, 10), 0.125) && + approximatelyEqual(stateParameterValue(legacy, 20), 0.875), + "legacy positional component state is no longer readable"); + + ComponentState oversizedState = original; + oversizedState.modifiedPrograms.resize(2); + for (std::size_t slot = 0; + slot < oversizedState.modifiedPrograms.size(); + ++slot) + { + auto& program = oversizedState.modifiedPrograms[slot]; + program.listId = 1; + program.programIndex = static_cast(slot); + program.data.parameters.reserve(32769); + for (Vst::ParamID id = 0; id < 32769; ++id) + program.data.parameters.emplace_back(id, 0.0); + } + MemoryStream oversizedStream; + expect( + !writeComponentState( + &oversizedStream, originalParameters, oversizedState), + "component state accepted excessive cumulative program data"); + + ProgramData limitedProgram{ + { + {100, 0.25}, + {200, 0.75}, + }, + { + std::byte{0x01}, + std::byte{0x02}, + }, + }; + MemoryStream limitedStream; + expect( + writeProgramData(&limitedStream, limitedProgram), + "limited program data did not serialize"); + + ProgramData rejectedProgram; + limitedStream.seek(0, IBStream::kIBSeekSet, nullptr); + expect( + !readProgramData(&limitedStream, rejectedProgram, 1, 2), + "program data reader ignored its parameter limit"); + limitedStream.seek(0, IBStream::kIBSeekSet, nullptr); + expect( + !readProgramData(&limitedStream, rejectedProgram, 2, 1), + "program data reader ignored its payload limit"); +} + +template +void testProgramLists( + VST3Processor& processor, + VST3Controller& controller) +{ + if constexpr (!HasVst3ProgramLists) + { + void* interfacePointer = nullptr; + expect( + processor.queryInterface( + getTUID(), + &interfacePointer) == kNoInterface, + "plug-in without program banks exposes IProgramListData"); + expect( + controller.getProgramListCount() == 0, + "plug-in without program banks exposes a program list"); + } + else + { + const auto banks = PluginClass::getProgramCollections(); + const auto units = []() + { + if constexpr (HasVst3ProgramUnits) + return PluginClass::getVst3ProgramUnits(); + else + return std::array {}; + }(); + const auto bindings = PluginClass::getVst3ProgramListBindings(); + expect( + controller.getProgramListCount() == + static_cast(banks.size()), + "controller exposes the wrong program-list count"); + expect( + controller.getUnitCount() == + static_cast(units.size() + 1), + "controller exposes the wrong VST3 unit count"); + + for (int32 bankIndex = 0; + bankIndex < static_cast(banks.size()); + ++bankIndex) + { + const auto& bank = + banks[static_cast(bankIndex)]; + Vst::ProgramListInfo info {}; + expect( + controller.getProgramListInfo(bankIndex, info) == + kResultTrue, + "controller could not describe program list"); + const auto expectedListId = + static_cast(programListId(bank.id)); + expect( + info.id == expectedListId, + "program-list ID is not derived from the stable bank ID"); + expect( + info.programCount == + static_cast(bank.programs.size()), + "program-list count does not match ProgramCollection"); + expect( + processor.programDataSupported(info.id) == kResultTrue, + "processor does not support controller program list"); + + const auto sourceIndex = + bank.programs.size() > 1 ? int32 {1} : int32 {0}; + MemoryStream presetStream; + Vst::PresetFile writer(&presetStream); + expect( + writer.storeProgramData( + static_cast(&processor), + info.id, + sourceIndex), + "could not store IProgramListData in a VST preset stream"); + expect( + writer.writeChunkList(), + "could not finish VST program-data preset"); + + presetStream.seek(0, IBStream::kIBSeekSet, nullptr); + Vst::PresetFile reader(&presetStream); + expect( + reader.readChunkList(), + "could not read VST program-data preset"); + auto restoredListId = info.id; + expect( + reader.restoreProgramData( + static_cast(&processor), + &restoredListId, + 0), + "processor could not restore IProgramListData"); + // VST3 SDK 3.8's IUnitInfo overload converts the successful + // kResultTrue value (zero) directly to bool and therefore reports + // false even though setUnitProgramData succeeded. Verify the + // observable controller state below instead. + reader.restoreProgramData( + static_cast(&controller), + info.id, + 0); + + MemoryStream restoredData; + expect( + processor.getProgramData( + info.id, 0, &restoredData) == kResultTrue, + "processor could not return restored program data"); + restoredData.seek(0, IBStream::kIBSeekSet, nullptr); + ProgramData decoded; + expect( + readProgramData(&restoredData, decoded), + "restored program data is not decodable"); + expect( + decoded.payload == + bank.programs[ + static_cast(sourceIndex)].data, + "opaque program payload did not round-trip"); + + const auto parameters = PluginClass::getParameters(); + for (const auto& change : + bank.programs[ + static_cast(sourceIndex)].parameters) + { + const auto* parameter = + findParameter(parameters, change.id); + expect(parameter != nullptr, "test program parameter missing"); + if (!parameter) + continue; + expect( + approximatelyEqual( + controller.getParamNormalized(parameter->id), + plainToNormalized(*parameter, change.value)), + "controller did not apply synchronized program data"); + } + + MemoryStream corrupt; + int32 invalidHeader = 0; + corrupt.write( + &invalidHeader, + sizeof(invalidHeader), + nullptr); + corrupt.seek(0, IBStream::kIBSeekSet, nullptr); + expect( + processor.setProgramData(info.id, 0, &corrupt) == + kResultFalse, + "processor accepted corrupt program data"); + } + + for (std::size_t index = 0; index < units.size(); ++index) + { + Vst::UnitInfo info {}; + expect( + controller.getUnitInfo( + static_cast(index + 1), info) == kResultTrue, + "controller could not describe a VST3 program unit"); + expect( + info.id == units[index].id && + info.parentUnitId == units[index].parentId, + "controller exposed the wrong VST3 unit topology"); + + if (units[index].midiChannel >= 0) + { + Vst::UnitID mappedUnit = Vst::kRootUnitId; + expect( + controller.getUnitByBus( + Vst::kEvent, + Vst::kInput, + units[index].eventBusIndex, + units[index].midiChannel, + mappedUnit) == kResultTrue && + mappedUnit == units[index].id, + "controller exposed the wrong MIDI-to-unit mapping"); + } + } + + for (const auto& binding : bindings) + { + const auto collection = std::find_if( + banks.begin(), + banks.end(), + [&binding](const auto& candidate) + { + return candidate.id == binding.collectionId; + }); + expect( + collection != banks.end(), + "VST3 binding refers to a missing program collection"); + } + + MemoryStream componentState; + expect( + processor.getState(&componentState) == kResultOk, + "processor could not save modified program slots"); + componentState.seek(0, IBStream::kIBSeekSet, nullptr); + + VST3Processor restoredProcessor; + expect( + restoredProcessor.initialize(nullptr) == kResultOk, + "restored processor initialization failed"); + expect( + restoredProcessor.setState(&componentState) == kResultOk, + "restored processor rejected component state"); + + for (int32 bankIndex = 0; + bankIndex < static_cast(banks.size()); + ++bankIndex) + { + Vst::ProgramListInfo info {}; + expect( + controller.getProgramListInfo(bankIndex, info) == kResultTrue, + "controller lost program-list information"); + const auto& bank = + banks[static_cast(bankIndex)]; + const auto sourceIndex = + bank.programs.size() > 1 ? std::size_t {1} : std::size_t {0}; + + MemoryStream restoredData; + expect( + restoredProcessor.getProgramData( + info.id, 0, &restoredData) == kResultTrue, + "component state did not restore modified program data"); + restoredData.seek(0, IBStream::kIBSeekSet, nullptr); + ProgramData decoded; + expect( + readProgramData(&restoredData, decoded) && + decoded.payload == bank.programs[sourceIndex].data, + "component state lost an opaque program payload"); + } + expect( + restoredProcessor.terminate() == kResultOk, + "restored processor termination failed"); + + ComponentState cleanState; + const auto pluginParameters = PluginClass::getParameters(); + for (const auto& parameter : pluginParameters) + { + if (!parameter.readOnly) + cleanState.parameterValues.emplace_back( + parameter.id, + plainToNormalized(parameter, parameter.defaultValue)); + } + for (const auto& bank : banks) + { + cleanState.programSelections.push_back({ + static_cast(programListId(bank.id)), + 0, + }); + } + + MemoryStream cleanStateStream; + expect( + writeComponentState( + &cleanStateStream, pluginParameters, cleanState), + "could not write clean component state"); + cleanStateStream.seek(0, IBStream::kIBSeekSet, nullptr); + expect( + processor.setState(&cleanStateStream) == kResultOk, + "processor rejected clean component state"); + cleanStateStream.seek(0, IBStream::kIBSeekSet, nullptr); + expect( + controller.setComponentState(&cleanStateStream) == kResultOk, + "controller rejected clean component state"); + + for (int32 bankIndex = 0; + bankIndex < static_cast(banks.size()); + ++bankIndex) + { + Vst::ProgramListInfo info {}; + expect( + controller.getProgramListInfo(bankIndex, info) == kResultTrue, + "controller lost a program list during clean restore"); + const auto& bank = + banks[static_cast(bankIndex)]; + + MemoryStream factoryData; + expect( + processor.getProgramData( + info.id, 0, &factoryData) == kResultTrue, + "processor could not return reset factory program data"); + factoryData.seek(0, IBStream::kIBSeekSet, nullptr); + ProgramData decodedFactory; + expect( + readProgramData(&factoryData, decodedFactory) && + decodedFactory.payload == bank.programs.front().data, + "clean state retained a prior processor program override"); + + controller.setParamNormalized(info.id, 1.0); + controller.setParamNormalized(info.id, 0.0); + for (const auto& change : bank.programs.front().parameters) + { + const auto* parameter = + findParameter(pluginParameters, change.id); + expect( + parameter && + approximatelyEqual( + controller.getParamNormalized(change.id), + plainToNormalized(*parameter, change.value)), + "clean state retained a prior controller program override"); + } + } + } +} + +class LifecyclePlugin +{ +public: + static constexpr bool isInstrument = false; + static inline int prepareCalls = 0; + static inline int loadCalls = 0; + static inline int loadedValue = -1; + static inline bool loadedBeforePrepare = false; + + static auto getParameters() + { + return std::to_array({ + { + .id = 100, + .name = "Lifecycle", + .type = ParamType::Float, + .minValue = 0.0, + .maxValue = 1.0, + .defaultValue = 0.0, + }, + }); + } + + static auto getProgramCollections() + { + return std::to_array({ + { + .id = "lifecycle", + .name = "Lifecycle", + .parameterIds = {100}, + .programs = { + { + .id = "first", + .name = "First", + .parameters = {{100, 0.0}}, + .data = {std::byte {1}}, + }, + { + .id = "second", + .name = "Second", + .parameters = {{100, 1.0}}, + .data = {std::byte {2}}, + }, + }, + }, + }); + } + + static auto getVst3ProgramListBindings() + { + return std::to_array({ + {.collectionId = "lifecycle", .unitId = 0}, + }); + } + + void prepare(double, int) + { + prepared_ = true; + ++prepareCalls; + } + + void loadProgramData( + std::string_view, + std::string_view, + std::span data) + { + loadedBeforePrepare = loadedBeforePrepare || !prepared_; + loadedValue = + data.empty() ? -1 : std::to_integer(data.front()); + ++loadCalls; + } + + template + void process( + std::span, + std::span, + int, + ParamList) + { + } + +private: + bool prepared_ = false; +}; + +static_assert(SingularityPlugin); + +class InspectableLifecycleProcessor + : public VST3Processor +{ +public: + double processingParameter(Vst::ParamID id) const + { + const auto parameter = std::find_if( + mParams.begin(), + mParams.end(), + [id](const auto& candidate) + { + return candidate.metadata.id == id; + }); + return parameter == mParams.end() ? -1.0 : parameter->smoothed; + } + + bool processingBypass() const + { + return mBypassProcessorFloat.isActive(); + } +}; + +void testProgramLifecycle() +{ + LifecyclePlugin::prepareCalls = 0; + LifecyclePlugin::loadCalls = 0; + LifecyclePlugin::loadedValue = -1; + LifecyclePlugin::loadedBeforePrepare = false; + + InspectableLifecycleProcessor processor; + expect( + processor.initialize(nullptr) == kResultOk, + "lifecycle processor initialization failed"); + expect( + LifecyclePlugin::loadCalls == 0, + "program data was loaded before processor preparation"); + + ComponentState selectedState; + selectedState.parameterValues = {{100, 0.25}}; + selectedState.programSelections = {{ + static_cast(programListId("lifecycle")), + 1, + }}; + MemoryStream stateStream; + const auto lifecycleParameters = LifecyclePlugin::getParameters(); + expect( + writeComponentState( + &stateStream, lifecycleParameters, selectedState), + "could not create lifecycle component state"); + stateStream.seek(0, IBStream::kIBSeekSet, nullptr); + expect( + processor.setState(&stateStream) == kResultOk, + "lifecycle processor rejected pre-prepare state"); + expect( + LifecyclePlugin::loadCalls == 0, + "setState loaded program data before processor preparation"); + + Vst::ProcessSetup setup {}; + setup.processMode = Vst::kRealtime; + setup.symbolicSampleSize = Vst::kSample32; + setup.maxSamplesPerBlock = 64; + setup.sampleRate = 48000.0; + expect( + processor.setupProcessing(setup) == kResultOk, + "lifecycle processor setup failed"); + expect( + LifecyclePlugin::prepareCalls == 1 && + LifecyclePlugin::loadCalls == 1 && + LifecyclePlugin::loadedValue == 2 && + !LifecyclePlugin::loadedBeforePrepare, + "selected program was not applied after preparation"); + + MemoryStream savedAfterSetup; + expect( + processor.getState(&savedAfterSetup) == kResultOk, + "lifecycle processor could not save state after setup"); + savedAfterSetup.seek(0, IBStream::kIBSeekSet, nullptr); + ComponentState decodedAfterSetup; + expect( + readComponentState( + &savedAfterSetup, lifecycleParameters, decodedAfterSetup) && + decodedAfterSetup.parameterValues.size() == 1 && + approximatelyEqual( + decodedAfterSetup.parameterValues.front().second, 0.25), + "deferred program loading overwrote restored working parameters"); + + ComponentState processingRestore; + processingRestore.bypass = true; + processingRestore.parameterValues = {{100, 0.75}}; + processingRestore.programSelections = {{ + static_cast(programListId("lifecycle")), + 1, + }}; + MemoryStream processingState; + expect( + writeComponentState( + &processingState, lifecycleParameters, processingRestore), + "could not create processing-time component state"); + processingState.seek(0, IBStream::kIBSeekSet, nullptr); + expect( + processor.setState(&processingState) == kResultOk, + "processor rejected state while processing was configured"); + expect( + approximatelyEqual(processor.processingParameter(100), 0.25) && + !processor.processingBypass(), + "setState mutated processing objects on the UI thread"); + + MemoryStream savedBeforeBoundary; + expect( + processor.getState(&savedBeforeBoundary) == kResultOk, + "processor could not publish pending state"); + savedBeforeBoundary.seek(0, IBStream::kIBSeekSet, nullptr); + ComponentState decodedBeforeBoundary; + expect( + readComponentState( + &savedBeforeBoundary, + lifecycleParameters, + decodedBeforeBoundary) && + decodedBeforeBoundary.bypass && + approximatelyEqual( + stateParameterValue(decodedBeforeBoundary, 100), 0.75), + "getState did not expose the pending restored state"); + + Vst::ProcessData emptyBlock {}; + expect( + processor.process(emptyBlock) == kResultOk && + approximatelyEqual(processor.processingParameter(100), 0.75) && + processor.processingBypass(), + "pending state was not applied at the next process boundary"); + + std::atomic keepProcessing {true}; + std::thread processingThread( + [&processor, &keepProcessing]() + { + Vst::ProcessData block {}; + while (keepProcessing.load(std::memory_order_acquire)) + processor.process(block); + }); + for (int iteration = 0; iteration < 100; ++iteration) + { + const auto expectedValue = + iteration % 2 == 0 ? 0.2 : 0.8; + ComponentState concurrentRestore; + concurrentRestore.bypass = iteration % 2 != 0; + concurrentRestore.parameterValues = {{100, expectedValue}}; + concurrentRestore.programSelections = {{ + static_cast( + programListId("lifecycle")), + 1, + }}; + MemoryStream concurrentState; + expect( + writeComponentState( + &concurrentState, + lifecycleParameters, + concurrentRestore), + "could not create concurrent component state"); + concurrentState.seek(0, IBStream::kIBSeekSet, nullptr); + expect( + processor.setState(&concurrentState) == kResultOk, + "concurrent setState failed"); + + MemoryStream concurrentSaved; + expect( + processor.getState(&concurrentSaved) == kResultOk, + "concurrent getState failed"); + concurrentSaved.seek(0, IBStream::kIBSeekSet, nullptr); + ComponentState decodedConcurrent; + expect( + readComponentState( + &concurrentSaved, + lifecycleParameters, + decodedConcurrent) && + decodedConcurrent.bypass == concurrentRestore.bypass && + approximatelyEqual( + stateParameterValue(decodedConcurrent, 100), + expectedValue), + "concurrent state publication returned a torn or stale state"); + } + + const auto lifecycleListId = + static_cast(programListId("lifecycle")); + for (int iteration = 0; iteration < 100; ++iteration) + { + const auto expectedValue = + iteration % 2 == 0 ? 0.3 : 0.7; + ProgramData replacement; + replacement.parameters = {{100, expectedValue}}; + replacement.payload = { + static_cast(iteration % 2 == 0 ? 1 : 2), + }; + MemoryStream replacementStream; + expect( + writeProgramData(&replacementStream, replacement), + "could not create concurrent program data"); + replacementStream.seek(0, IBStream::kIBSeekSet, nullptr); + expect( + processor.setProgramData( + lifecycleListId, 1, &replacementStream) == kResultTrue, + "concurrent setProgramData failed"); + + MemoryStream savedProgram; + expect( + processor.getProgramData( + lifecycleListId, 1, &savedProgram) == kResultTrue, + "concurrent getProgramData failed"); + savedProgram.seek(0, IBStream::kIBSeekSet, nullptr); + ProgramData decodedProgram; + expect( + readProgramData(&savedProgram, decodedProgram) && + approximatelyEqual( + stateParameterValue( + ComponentState { + .parameterValues = decodedProgram.parameters, + }, + 100), + expectedValue) && + decodedProgram.payload == replacement.payload, + "concurrent program replacement returned stale data"); + } + keepProcessing.store(false, std::memory_order_release); + processingThread.join(); + + ComponentState staleLifecycleState; + staleLifecycleState.bypass = true; + staleLifecycleState.parameterValues = {{100, 0.9}}; + staleLifecycleState.programSelections = {{ + lifecycleListId, + 1, + }}; + MemoryStream staleLifecycleStream; + expect( + writeComponentState( + &staleLifecycleStream, + lifecycleParameters, + staleLifecycleState), + "could not create stale lifecycle state"); + staleLifecycleStream.seek(0, IBStream::kIBSeekSet, nullptr); + expect( + processor.setState(&staleLifecycleStream) == kResultOk, + "processor rejected state queued before termination"); + expect( + processor.terminate() == kResultOk, + "lifecycle processor termination failed"); + + LifecyclePlugin::loadedValue = -1; + expect( + processor.initialize(nullptr) == kResultOk, + "lifecycle processor reinitialization failed"); + expect( + processor.setupProcessing(setup) == kResultOk, + "reinitialized lifecycle processor setup failed"); + expect( + approximatelyEqual(processor.processingParameter(100), 0.0) && + !processor.processingBypass() && + LifecyclePlugin::loadedValue == 1, + "queued state survived processor termination"); + expect( + processor.terminate() == kResultOk, + "reinitialized lifecycle processor termination failed"); +} + +} // namespace + +int main() +{ + VST3Processor processor; + VST3Controller controller; + expect( + processor.initialize(nullptr) == kResultOk, + "processor initialization failed"); + expect( + controller.initialize(nullptr) == kResultOk, + "controller initialization failed"); + + testProgramLists(processor, controller); + testComponentStateSchema(); + testProgramLifecycle(); + + expect( + controller.terminate() == kResultOk, + "controller termination failed"); + expect( + processor.terminate() == kResultOk, + "processor termination failed"); + + if (failures == 0) + std::cout << "all VST3 program-data tests passed\n"; + return failures == 0 ? 0 : 1; +} diff --git a/vst3/vst3controller.cpp b/vst3/vst3controller.cpp index 7d6f476..4905784 100644 --- a/vst3/vst3controller.cpp +++ b/vst3/vst3controller.cpp @@ -1,14 +1,16 @@ #include "vst3controller.h" #include "plugincids.h" #include "SingularityView.h" +#include "Vst3ComponentState.h" +#include "Vst3ParameterSupport.h" +#include "Vst3ProgramData.h" +#include "Vst3ProgramModel.h" #include "base/source/fstreamer.h" #include "pluginterfaces/base/ibstream.h" +#include "pluginterfaces/vst/vstpresetkeys.h" #include "SingularityPlugin.h" #include PLUGIN_CLASS_HEADER -#include -#include - -using namespace Steinberg; +#include namespace Steinberg { @@ -25,12 +27,17 @@ tresult PLUGIN_API VST3Controller::initialize (FUnknown* context) } parameters.addParameter (STR16 ("Bypass"), nullptr, 1, 0, - Vst::ParameterInfo::kCanAutomate | Vst::ParameterInfo::kIsBypass, - Steinberg::Vst::kMaxParamId); + Vst::ParameterInfo::kCanAutomate | Vst::ParameterInfo::kIsBypass, + Steinberg::Vst::kMaxParamId); + + if (!initializeProgramBanks()) + return kInvalidArgument; for (auto& p : PLUGIN_CLASS::getParameters ()) - addSingularityParameter (p); + addSingularityParameter (p, unitIdForParameter(p)); + for (auto& bank : programBanks_) + applyProgram(bank, 0, false); return result; } @@ -50,24 +57,78 @@ tresult PLUGIN_API VST3Controller::setComponentState (IBStream* state) if (!state) return kResultFalse; - IBStreamer streamer (state, kLittleEndian); - - // Read bypass first (written as int32 by processor) - int32 bypassState = 0; - if (!streamer.readInt32(bypassState)) return kResultFalse; - setParamNormalized(Steinberg::Vst::kMaxParamId, bypassState ? 1 : 0); - - // Read remaining plugin parameters in the same order the processor wrote them - for (int32 i = 0; i < parameters.getParameterCount(); ++i) { - auto* param = parameters.getParameterByIndex(i); - if (!param) continue; - if (param->getInfo().id == Steinberg::Vst::kMaxParamId) continue; // bypass already read - if (param->getInfo().flags & Vst::ParameterInfo::kIsReadOnly) continue; - double value = 0.0; - if (!streamer.readDouble(value)) break; - setParamNormalized(param->getInfo().id, value); + SingularityVst3::ComponentState restored; + const auto pluginParameters = PLUGIN_CLASS::getParameters(); + if (!SingularityVst3::readComponentState( + state, pluginParameters, restored)) + return kResultFalse; + + setParamNormalized( + Steinberg::Vst::kMaxParamId, restored.bypass ? 1.0 : 0.0); + + for (auto& bank : programBanks_) + { + for (auto& program : bank.programOverrides) + program.reset(); + auto* selector = getParameterObject(bank.selectorId); + if (selector) + EditControllerEx1::setParamNormalized( + bank.selectorId, selector->toNormalized(0)); + applyProgram(bank, 0, false); + } + + for (auto& saved : restored.modifiedPrograms) + { + auto* bank = findProgramBank(saved.listId); + if (!bank || saved.programIndex < 0 || + saved.programIndex >= static_cast(bank->programs.size())) + continue; + + auto updated = + bank->programs[static_cast(saved.programIndex)]; + for (const auto& [id, value] : saved.data.parameters) + { + const auto parameter = std::find_if( + updated.parameters.begin(), + updated.parameters.end(), + [id](const auto& candidate) + { + return candidate.first == id; + }); + if (parameter == updated.parameters.end()) + continue; + parameter->second = value; + } + updated.payload = std::move(saved.data.payload); + bank->programOverrides[ + static_cast(saved.programIndex)] = + std::move(updated); } + for (const auto& selection : restored.programSelections) + { + auto* bank = selection.listId == Vst::kNoProgramListId + ? (programBanks_.empty() ? nullptr : &programBanks_.front()) + : findProgramBank(selection.listId); + if (!bank || selection.programIndex < 0 || + selection.programIndex >= + static_cast(bank->programs.size())) + continue; + + auto* selector = getParameterObject(bank->selectorId); + if (!selector) + continue; + EditControllerEx1::setParamNormalized( + bank->selectorId, + selector->toNormalized(selection.programIndex)); + applyProgram(*bank, selection.programIndex, false); + } + + // Program selection loads a bank entry into working memory. Reapply the + // saved working values afterwards so edits survive project restoration. + for (const auto& [id, value] : restored.parameterValues) + setParamNormalized(id, value); + return kResultOk; } @@ -104,6 +165,15 @@ tresult PLUGIN_API VST3Controller::setParamNormalized (Vst::ParamID tag, Vst::Pa { // called by host to update your parameters tresult result = EditControllerEx1::setParamNormalized (tag, value); + if (result == kResultOk) + { + if (auto* bank = findProgramBankBySelector(tag)) + { + const auto index = programIndex(*bank, value); + if (index >= 0) + applyProgram(*bank, index, true); + } + } return result; } @@ -130,6 +200,264 @@ tresult PLUGIN_API VST3Controller::notify (Vst::IMessage* message) return EditControllerEx1::notify(message); } +bool VST3Controller::initializeProgramBanks() +{ + programBanks_.clear(); + auto model = SingularityVst3::buildProgramModel(); + if (!model) + return false; + programUnits_ = std::move(model->units); + for (auto& bank : model->banks) + { + ControllerProgramBank runtime; + static_cast(runtime) = + std::move(bank); + runtime.programOverrides.resize(runtime.programs.size()); + programBanks_.push_back(std::move(runtime)); + } + + auto listForUnit = [&] (int32 unitId) + { + for (const auto& bank : programBanks_) + if (bank.unitId == unitId) + return bank.listId; + return Vst::kNoProgramListId; + }; + + Vst::String128 rootName {}; + if (!copyUtf8ToString128("Root", rootName)) + return false; + addUnit(new Vst::Unit( + rootName, + Vst::kRootUnitId, + Vst::kNoParentUnitId, + listForUnit(Vst::kRootUnitId))); + for (const auto& unit : programUnits_) + { + Vst::String128 unitName {}; + if (!copyUtf8ToString128(unit.name, unitName)) + return false; + addUnit(new Vst::Unit( + unitName, + unit.id, + unit.parentId, + listForUnit(unit.id))); + } + + for (auto& bank : programBanks_) + { + Vst::String128 listName {}; + if (!copyUtf8ToString128(bank.definition.name, listName)) + return false; + auto* programList = new Vst::ProgramList( + listName, + bank.listId, + bank.unitId); + for (const auto& program : bank.definition.programs) + { + Vst::String128 programName {}; + if (!copyUtf8ToString128(program.name, programName)) + { + programList->release(); + return false; + } + const auto index = programList->addProgram(programName); + if constexpr (PLUGIN_CLASS::isInstrument) + { + if (!program.category.empty()) + { + Vst::String128 category {}; + if (!copyUtf8ToString128( + program.category, category)) + { + programList->release(); + return false; + } + programList->setProgramInfo( + index, + Vst::PresetAttributes::kInstrument, + category); + } + } + } + if (!addProgramList(programList)) + { + programList->release(); + return false; + } + + auto* selector = new Vst::StringListParameter( + listName, + bank.selectorId, + nullptr, + Vst::ParameterInfo::kIsList | + Vst::ParameterInfo::kIsProgramChange, + bank.unitId); + for (const auto& program : bank.definition.programs) + { + Vst::String128 programName {}; + if (!copyUtf8ToString128(program.name, programName)) + { + selector->release(); + return false; + } + selector->appendString(programName); + } + parameters.addParameter(selector); + } + return true; +} + +int32 VST3Controller::programIndex( + const ControllerProgramBank& bank, + Vst::ParamValue normalizedValue) const +{ + if (bank.programs.empty()) + return -1; + const auto maxIndex = static_cast(bank.programs.size() - 1); + return static_cast(std::clamp( + std::round(std::clamp(normalizedValue, 0.0, 1.0) * maxIndex), + 0.0, + maxIndex)); +} + +bool VST3Controller::applyProgram( + ControllerProgramBank& bank, + int32 index, + bool notifyHost) +{ + if (index < 0 || index >= static_cast(bank.programs.size())) + return false; + + bank.currentProgram = index; + const auto programIndex = static_cast(index); + const auto& program = bank.programOverrides[programIndex] + ? *bank.programOverrides[programIndex] + : bank.programs[programIndex]; + for (const auto& [id, value] : + program.parameters) + { + EditControllerEx1::setParamNormalized(id, value); + } + + if (notifyHost && componentHandler) + componentHandler->restartComponent(Vst::kParamValuesChanged); + return true; +} + +VST3Controller::ControllerProgramBank* VST3Controller::findProgramBank( + Vst::ProgramListID listId) +{ + for (auto& bank : programBanks_) + if (bank.listId == listId) + return &bank; + return nullptr; +} + +VST3Controller::ControllerProgramBank* +VST3Controller::findProgramBankBySelector(Vst::ParamID selectorId) +{ + for (auto& bank : programBanks_) + if (bank.selectorId == selectorId) + return &bank; + return nullptr; +} + +Vst::UnitID VST3Controller::unitIdForParameter( + const ::Parameter& parameter) const +{ + for (const auto& bank : programBanks_) + { + if (std::find( + bank.definition.parameterIds.begin(), + bank.definition.parameterIds.end(), + parameter.id) != bank.definition.parameterIds.end()) + return bank.unitId; + } + return static_cast(parameter.groupId); +} + +tresult PLUGIN_API VST3Controller::getUnitByBus( + Vst::MediaType type, + Vst::BusDirection direction, + int32 busIndex, + int32 channel, + Vst::UnitID& unitId) +{ + if (type != Vst::kEvent || direction != Vst::kInput) + return kResultFalse; + + for (const auto& unit : programUnits_) + { + if (unit.eventBusIndex == busIndex && unit.midiChannel == channel) + { + unitId = unit.id; + return kResultTrue; + } + } + if (busIndex == 0 && channel == 0) + { + unitId = Vst::kRootUnitId; + return kResultTrue; + } + return kResultFalse; +} + +tresult PLUGIN_API VST3Controller::setUnitProgramData( + int32 listOrUnitId, int32 programIndex, IBStream* data) +{ + auto* bank = findProgramBank(listOrUnitId); + if (!bank || programIndex < 0 || + programIndex >= static_cast(bank->programs.size())) + return kInvalidArgument; + + SingularityVst3::ProgramData decoded; + if (!SingularityVst3::readProgramData(data, decoded)) + return kResultFalse; + if constexpr (!HandlesProgramData) + if (!decoded.payload.empty()) + return kInvalidArgument; + + const auto slot = static_cast(programIndex); + auto updated = bank->programOverrides[slot] + ? *bank->programOverrides[slot] + : bank->programs[slot]; + const auto pluginParameters = PLUGIN_CLASS::getParameters(); + for (const auto& [id, value] : decoded.parameters) + { + auto found = false; + for (std::size_t index = 0; index < pluginParameters.size(); ++index) + { + if (pluginParameters[index].id == id && + !pluginParameters[index].readOnly && + std::find( + bank->definition.parameterIds.begin(), + bank->definition.parameterIds.end(), + id) != bank->definition.parameterIds.end()) + { + for (auto& [updatedId, updatedValue] : updated.parameters) + { + if (updatedId == id) + { + updatedValue = value; + found = true; + break; + } + } + break; + } + } + if (!found) + return kInvalidArgument; + } + updated.payload = std::move(decoded.payload); + + bank->programOverrides[slot] = std::move(updated); + if (bank->currentProgram == programIndex) + applyProgram(*bank, programIndex, true); + return kResultTrue; +} + void PLUGIN_API VST3Controller::queueOpened (Vst::DataExchangeUserContextID userContextID, uint32, TBool& dispatchOnBackgroundThread) { if (userContextID == Singularity::AudioDataExchange::kDefaultContextID) diff --git a/vst3/vst3controller.h b/vst3/vst3controller.h index 075c222..47fbafd 100644 --- a/vst3/vst3controller.h +++ b/vst3/vst3controller.h @@ -7,12 +7,18 @@ #include "public.sdk/source/vst/vsteditcontroller.h" #include "public.sdk/source/vst/vstparameters.h" #include "public.sdk/source/vst/utility/dataexchange.h" +#include "public.sdk/source/vst/utility/stringconvert.h" #include "AudioDataExchange.h" +#include "Vst3ParameterSupport.h" +#include "Vst3ProgramData.h" +#include "Vst3ProgramLayout.h" +#include "Vst3ProgramModel.h" #include "IParameterProvider.h" #include "pluginterfaces/vst/vsttypes.h" #include #include #include +#include namespace Steinberg { @@ -53,28 +59,37 @@ class VST3Controller : public Steinberg::Vst::EditControllerEx1, Steinberg::tresult PLUGIN_API getParamValueByString (Steinberg::Vst::ParamID tag, Steinberg::Vst::TChar* string, Steinberg::Vst::ParamValue& valueNormalized) SMTG_OVERRIDE; + Steinberg::tresult PLUGIN_API getUnitByBus ( + Steinberg::Vst::MediaType type, + Steinberg::Vst::BusDirection direction, + Steinberg::int32 busIndex, + Steinberg::int32 channel, + Steinberg::Vst::UnitID& unitId) SMTG_OVERRIDE; + Steinberg::tresult PLUGIN_API setUnitProgramData ( + Steinberg::int32 listOrUnitId, + Steinberg::int32 programIndex, + Steinberg::IBStream* data) SMTG_OVERRIDE; //---Interface--------- DEFINE_INTERFACES - // Here you can add more supported VST3 interfaces DEF_INTERFACE (Vst::IDataExchangeReceiver) - END_DEFINE_INTERFACES (EditController) - DELEGATE_REFCOUNT (EditController) + END_DEFINE_INTERFACES (EditControllerEx1) + DELEGATE_REFCOUNT (EditControllerEx1) - void addSingularityParameter(const ::Parameter& parameter) + void addSingularityParameter( + const ::Parameter& parameter, + Vst::UnitID unitId) { Vst::String128 title{}; Vst::String128 shortTitle{}; Vst::String128 units{}; - copyAsciiToString128(parameter.name, title); - copyAsciiToString128(parameter.shortName, shortTitle); - copyAsciiToString128(parameter.units, units); + copyUtf8ToString128(parameter.name, title); + copyUtf8ToString128(parameter.shortName, shortTitle); + copyUtf8ToString128(parameter.units, units); auto* unitString = parameter.units.empty() ? nullptr : units; auto* shortTitleString = parameter.shortName.empty() ? nullptr : shortTitle; const auto flags = flagsFor(parameter); - const auto groupId = static_cast(parameter.groupId); - if (parameter.type == ParamType::Float) { parameters.addParameter(new Vst::RangeParameter( @@ -86,7 +101,7 @@ class VST3Controller : public Steinberg::Vst::EditControllerEx1, parameter.defaultValue, stepCountFor(parameter), flags, - groupId, + unitId, shortTitleString)); return; } @@ -103,7 +118,7 @@ class VST3Controller : public Steinberg::Vst::EditControllerEx1, std::clamp(std::round(parameter.defaultValue), 0.0, maxIndex), stepCountFor(parameter), flags, - groupId, + unitId, shortTitleString)); return; } @@ -120,14 +135,15 @@ class VST3Controller : public Steinberg::Vst::EditControllerEx1, std::clamp(std::round(parameter.defaultValue), 0.0, maxStep), stepCountFor(parameter), flags, - groupId, + unitId, shortTitleString)); return; } parameters.addParameter(title, unitString, stepCountFor(parameter), - plainToNormalized(parameter, parameter.defaultValue), - flags, parameter.id, groupId, shortTitleString); + SingularityVst3::plainToNormalized( + parameter, parameter.defaultValue), + flags, parameter.id, unitId, shortTitleString); } void PLUGIN_API queueOpened (Steinberg::Vst::DataExchangeUserContextID userContextID, Steinberg::uint32 blockSize, Steinberg::TBool& dispatchOnBackgroundThread) SMTG_OVERRIDE; @@ -156,16 +172,37 @@ class VST3Controller : public Steinberg::Vst::EditControllerEx1, const auto normalizedValue = parameter->toNormalized(value); beginEdit(id); - EditControllerEx1::setParamNormalized(id, normalizedValue); + setParamNormalized(id, normalizedValue); performEdit(id, normalizedValue); endEdit(id); } private: - static void copyAsciiToString128(const std::string& source, Vst::String128 target) + struct ControllerProgramBank + : SingularityVst3::ProgramModelBank + { + std::vector> + programOverrides; + int32 currentProgram = 0; + }; + + bool initializeProgramBanks(); + bool applyProgram( + ControllerProgramBank& bank, + int32 programIndex, + bool notifyHost); + int32 programIndex( + const ControllerProgramBank& bank, + Vst::ParamValue normalizedValue) const; + ControllerProgramBank* findProgramBank(Vst::ProgramListID listId); + ControllerProgramBank* findProgramBankBySelector(Vst::ParamID selectorId); + Vst::UnitID unitIdForParameter(const ::Parameter& parameter) const; + + static bool copyUtf8ToString128( + const std::string& source, + Vst::String128 target) { - for (int i = 0; i < 127 && i < static_cast(source.size()); ++i) - target[i] = source[static_cast(i)]; + return Vst::StringConvert::convert(source, target); } static int32 stepCountFor(const ::Parameter& parameter) @@ -199,34 +236,10 @@ class VST3Controller : public Steinberg::Vst::EditControllerEx1, } - static double plainToNormalized(const ::Parameter& parameter, double plainValue) - { - if (parameter.type == ParamType::Bool) - return plainValue >= 0.5 ? 1.0 : 0.0; - - if (parameter.type == ParamType::Choice && !parameter.choices.empty()) - { - const auto maxIndex = static_cast(parameter.choices.size() - 1); - if (maxIndex <= 0.0) - return 0.0; - return std::clamp(std::round(plainValue) / maxIndex, 0.0, 1.0); - } - - if (parameter.type == ParamType::Stepped && parameter.steps > 1) - { - const auto maxStep = static_cast(parameter.steps - 1); - return std::clamp(std::round(plainValue) / maxStep, 0.0, 1.0); - } - - if (parameter.maxValue == parameter.minValue) - return 0.0; - - return std::clamp((plainValue - parameter.minValue) / - (parameter.maxValue - parameter.minValue), 0.0, 1.0); - } - Vst::DataExchangeReceiverHandler dataExchange_ {this}; Singularity::AudioDataExchange::AudioDataQueue audioDataQueue_; + std::vector<::Vst3ProgramUnit> programUnits_; + std::vector programBanks_; protected: }; diff --git a/vst3/vst3processor.h b/vst3/vst3processor.h index 85605ae..bbc471e 100644 --- a/vst3/vst3processor.h +++ b/vst3/vst3processor.h @@ -11,9 +11,16 @@ #include "base/source/fstreamer.h" #include "pluginterfaces/vst/ivstparameterchanges.h" #include "pluginterfaces/vst/ivstevents.h" +#include "pluginterfaces/vst/ivstunits.h" #include "plugincids.h" #include PLUGIN_CLASS_HEADER #include "SingularityPlugin.h" +#include "Vst3ComponentState.h" +#include "Vst3ParameterSupport.h" +#include "Vst3ProgramData.h" +#include "Vst3ProgramLayout.h" +#include "Vst3ProgramModel.h" +#include #include #include #include @@ -27,6 +34,7 @@ namespace Steinberg { template<::SingularityPlugin PluginType> class VST3Processor : public Steinberg::Vst::AudioEffect, + public Steinberg::Vst::IProgramListData, public Singularity::AudioDataExchange::IDataSink { public: @@ -38,29 +46,49 @@ class VST3Processor : public Steinberg::Vst::AudioEffect, return (Steinberg::Vst::IAudioProcessor*)new VST3Processor; } - tresult PLUGIN_API initialize (FUnknown* context) SMTG_OVERRIDE + tresult PLUGIN_API queryInterface (const TUID iid, void** obj) SMTG_OVERRIDE { - tresult result = AudioEffect::initialize (context); - if (result != kResultOk) return result; - - if constexpr (!PluginType::isInstrument) - addAudioInput (STR16 ("Stereo In"), Vst::SpeakerArr::kStereo); + if constexpr (HasVst3ProgramLists) + { + QUERY_INTERFACE ( + iid, obj, getTUID (), Vst::IProgramListData) + } + return AudioEffect::queryInterface (iid, obj); + } + REFCOUNT_METHODS (AudioEffect) + + tresult PLUGIN_API initialize (FUnknown* context) SMTG_OVERRIDE + { + tresult result = AudioEffect::initialize (context); + if (result != kResultOk) return result; + resetPendingProcessorState(); + mBypassProcessorFloat.setActive(false); + mBypassProcessorDouble.setActive(false); + + if constexpr (!PluginType::isInstrument) + addAudioInput (STR16 ("Stereo In"), Vst::SpeakerArr::kStereo); addAudioOutput (STR16 ("Stereo Out"), Vst::SpeakerArr::kStereo); addEventInput (STR16 ("Event In"), 1); mParams.clear(); for (auto& parameter : PluginType::getParameters ()) { - const auto normalizedDefault = plainToNormalized(parameter, parameter.defaultValue); + const auto normalizedDefault = + SingularityVst3::plainToNormalized( + parameter, parameter.defaultValue); mParams.push_back ({parameter, { parameter.id, normalizedDefault}, normalizedDefault, 0.0}); - } + } + initializePublishedProcessorState(); + if (!initializeProgramBanks()) + return kInvalidArgument; return kResultOk; } - tresult PLUGIN_API terminate () SMTG_OVERRIDE - { - return AudioEffect::terminate (); - } + tresult PLUGIN_API terminate () SMTG_OVERRIDE + { + resetPendingProcessorState(); + return AudioEffect::terminate (); + } tresult PLUGIN_API connect (Vst::IConnectionPoint* other) SMTG_OVERRIDE { @@ -105,6 +133,23 @@ class VST3Processor : public Steinberg::Vst::AudioEffect, mBypassProcessorFloat.setup (*this, newSetup, getLatencySamples ()); mBypassProcessorDouble.setup (*this, newSetup, getLatencySamples ()); mPlugin.prepare (newSetup.sampleRate, newSetup.maxSamplesPerBlock); + applyPendingProcessorState(); + for (auto& bank : mProgramBanks) + { + const auto pending = + bank->pendingProgram.exchange( + RuntimeProgramBank::kNoPendingProgram, + std::memory_order_acq_rel); + const auto selected = + pending == RuntimeProgramBank::kNoPendingProgram + ? bank->currentProgram.load(std::memory_order_acquire) + : RuntimeProgramBank::programIndex(pending); + const auto applyParameters = + pending == RuntimeProgramBank::kNoPendingProgram || + RuntimeProgramBank::appliesParameters(pending); + applyProgram(*bank, selected, applyParameters); + } + publishProcessorState(); mMidiEvents.reserve (32); @@ -124,9 +169,29 @@ class VST3Processor : public Steinberg::Vst::AudioEffect, void handleParameterChanges (Vst::IParameterChanges* inputParameterChanges) { + if (!mProgramBanks.empty()) + { + Vst::Algo::foreach (inputParameterChanges, [&] (Vst::IParamValueQueue& queue) + { + auto* bank = findProgramBankBySelector( + queue.getParameterId()); + if (!bank) + return; + + Vst::ParamValue value = 0.0; + int32 offset = 0; + if (queue.getPointCount () > 0 && + queue.getPoint (queue.getPointCount () - 1, offset, value) == + kResultTrue) + applyProgram(*bank, programIndex(*bank, value)); + }); + } + Vst::Algo::foreach (inputParameterChanges, [&] (Vst::IParamValueQueue& queue) { Vst::ParamID paramID = queue.getParameterId (); + if (findProgramBankBySelector(paramID)) + return; if (paramID == Steinberg::Vst::kMaxParamId) // Bypass parameter id { Vst::ParamValue value; @@ -159,12 +224,30 @@ class VST3Processor : public Steinberg::Vst::AudioEffect, tresult PLUGIN_API process (Vst::ProcessData& data) SMTG_OVERRIDE { + applyPendingProcessorState(); + for (auto& bank : mProgramBanks) + { + const auto pendingProgram = + bank->pendingProgram.exchange( + RuntimeProgramBank::kNoPendingProgram, + std::memory_order_acq_rel); + if (pendingProgram != RuntimeProgramBank::kNoPendingProgram) + { + applyProgram( + *bank, + RuntimeProgramBank::programIndex(pendingProgram), + RuntimeProgramBank::appliesParameters( + pendingProgram)); + } + } + // Output parameters are calculated afresh for each process block. This // also guarantees that host-declared silence publishes zero/default. for (auto& parameter : mParams) { if (!parameter.metadata.readOnly) continue; - const auto normalizedDefault = plainToNormalized( + const auto normalizedDefault = + SingularityVst3::plainToNormalized( parameter.metadata, parameter.metadata.defaultValue); parameter.smoothed = normalizedDefault; parameter.rampTarget = normalizedDefault; @@ -220,6 +303,7 @@ class VST3Processor : public Steinberg::Vst::AudioEffect, for (auto& parameter : mParams) if (!parameter.metadata.readOnly) parameter.saParam.endChanges (); + publishProcessorState(); return kResultOk; } @@ -266,14 +350,18 @@ class VST3Processor : public Steinberg::Vst::AudioEffect, if (mParams[i].metadata.readOnly) { params[i] = {mParams[i].metadata.id, - normalizedToPlain(mParams[i].metadata, mParams[i].smoothed)}; + SingularityVst3::normalizedToPlain( + mParams[i].metadata, mParams[i].smoothed)}; continue; } double target = mParams[i].saParam.advance (slice.numSamples); if (mParams[i].metadata.type != ParamType::Float) { mParams[i].smoothed = target; - params[i] = { mParams[i].saParam.getParamID(), normalizedToPlain(mParams[i].metadata, mParams[i].smoothed) }; + params[i] = { + mParams[i].saParam.getParamID(), + SingularityVst3::normalizedToPlain( + mParams[i].metadata, mParams[i].smoothed)}; continue; } if (target != mParams[i].rampTarget) @@ -291,7 +379,10 @@ class VST3Processor : public Steinberg::Vst::AudioEffect, mParams[i].rampPerStep = 0.0; } } - params[i] = { mParams[i].saParam.getParamID(), normalizedToPlain(mParams[i].metadata, mParams[i].smoothed) }; + params[i] = { + mParams[i].saParam.getParamID(), + SingularityVst3::normalizedToPlain( + mParams[i].metadata, mParams[i].smoothed)}; } Vst::AudioBusBuffers* outputs = slice.outputs; @@ -314,7 +405,9 @@ class VST3Processor : public Steinberg::Vst::AudioEffect, for (int i = 0; i < static_cast(mParams.size()); ++i) { if (!mParams[i].metadata.readOnly) continue; - mParams[i].smoothed = plainToNormalized(mParams[i].metadata, params[i].second); + mParams[i].smoothed = + SingularityVst3::plainToNormalized( + mParams[i].metadata, params[i].second); mParams[i].rampTarget = mParams[i].smoothed; } }; @@ -352,35 +445,355 @@ class VST3Processor : public Steinberg::Vst::AudioEffect, tresult PLUGIN_API setState (IBStream* state) SMTG_OVERRIDE { - if (!state) return kResultFalse; - IBStreamer streamer (state, kLittleEndian); - int32 savedBypass = 0; - if (!streamer.readInt32 (savedBypass)) return kResultFalse; - bool bypass = savedBypass > 0; - mBypassProcessorFloat.setActive (bypass); - mBypassProcessorDouble.setActive (bypass); - for (auto& parameter : mParams) + const auto pluginParameters = PluginType::getParameters(); + SingularityVst3::ComponentState restored; + if (!SingularityVst3::readComponentState( + state, pluginParameters, restored)) + return kResultFalse; + + for (auto& bank : mProgramBanks) { - if (parameter.metadata.readOnly) continue; - double value = 0.0; - if (!streamer.readDouble (value)) break; - parameter.smoothed = value; - parameter.rampTarget = value; - parameter.saParam.setValue (value); + for (auto& program : bank->programs) + program->resetToFactory(); + bank->currentProgram.store(0, std::memory_order_release); + bank->pendingProgram.store(0, std::memory_order_release); } + + for (auto& saved : restored.modifiedPrograms) + { + auto* bank = findProgramBank(saved.listId); + if (!bank || saved.programIndex < 0 || + saved.programIndex >= + static_cast(bank->programs.size())) + continue; + + auto& program = + *bank->programs[ + static_cast(saved.programIndex)]; + const auto* current = program.snapshotForUi(); + if (!current) + return kResultFalse; + auto updated = + std::make_unique(*current); + for (const auto& [id, value] : saved.data.parameters) + { + const auto parameter = std::find_if( + updated->parameters.begin(), + updated->parameters.end(), + [id](const auto& candidate) + { + return candidate.first == id; + }); + if (parameter == updated->parameters.end()) + continue; + parameter->second = value; + } + updated->payload = std::move(saved.data.payload); + program.replaceSnapshot(std::move(updated), true); + } + + for (const auto& selection : restored.programSelections) + { + auto* bank = selection.listId == Vst::kNoProgramListId + ? (mProgramBanks.empty() ? nullptr : mProgramBanks.front().get()) + : findProgramBank(selection.listId); + if (!bank || selection.programIndex < 0 || + selection.programIndex >= + static_cast(bank->programs.size())) + continue; + bank->currentProgram.store( + selection.programIndex, std::memory_order_release); + bank->pendingProgram.store( + selection.programIndex, std::memory_order_release); + } + + auto pending = std::make_unique(); + pending->bypass = restored.bypass; + pending->parameterValues = std::move(restored.parameterValues); + const auto* pendingState = queueProcessorState(std::move(pending)); + publishRestoredProcessorState(*pendingState); return kResultOk; } tresult PLUGIN_API getState (IBStream* state) SMTG_OVERRIDE { - IBStreamer streamer (state, kLittleEndian); - streamer.writeInt32 (mBypassProcessorFloat.isActive () ? 1 : 0); - for (auto& parameter : mParams) - if (!parameter.metadata.readOnly) streamer.writeDouble (parameter.smoothed); - return kResultOk; + SingularityVst3::ComponentState current; + { + PublishedStateUiGuard guard(mPublishedProcessorStateLock); + current.bypass = mPublishedProcessorState.bypass; + current.parameterValues = + mPublishedProcessorState.parameterValues; + } + for (const auto& bank : mProgramBanks) + { + current.programSelections.push_back({ + bank->listId, + bank->currentProgram.load(std::memory_order_relaxed), + }); + for (std::size_t index = 0; + index < bank->programs.size(); + ++index) + { + const auto& program = *bank->programs[index]; + if (!program.modified) + continue; + const auto* snapshot = program.snapshotForUi(); + if (!snapshot) + return kResultFalse; + current.modifiedPrograms.push_back({ + bank->listId, + static_cast(index), + *snapshot, + }); + } + } + + const auto pluginParameters = PluginType::getParameters(); + return SingularityVst3::writeComponentState( + state, pluginParameters, current) + ? kResultOk + : kResultFalse; + } + + tresult PLUGIN_API programDataSupported ( + Vst::ProgramListID listId) SMTG_OVERRIDE + { + return findProgramBank(listId) ? kResultTrue : kResultFalse; + } + + tresult PLUGIN_API getProgramData ( + Vst::ProgramListID listId, + int32 programIndex, + IBStream* data) SMTG_OVERRIDE + { + auto* bank = findProgramBank(listId); + if (!bank || programIndex < 0 || + programIndex >= static_cast(bank->programs.size()) || + !data) + return kInvalidArgument; + + const auto* snapshot = + bank->programs[static_cast(programIndex)] + ->snapshotForUi(); + return snapshot && + SingularityVst3::writeProgramData(data, *snapshot) + ? kResultTrue + : kResultFalse; + } + + tresult PLUGIN_API setProgramData ( + Vst::ProgramListID listId, + int32 programIndex, + IBStream* data) SMTG_OVERRIDE + { + auto* bank = findProgramBank(listId); + if (!bank || programIndex < 0 || + programIndex >= static_cast(bank->programs.size()) || + !data) + return kInvalidArgument; + + SingularityVst3::ProgramData decoded; + if (!SingularityVst3::readProgramData(data, decoded)) + return kResultFalse; + if constexpr (!HandlesProgramData) + if (!decoded.payload.empty()) + return kInvalidArgument; + + auto& program = + *bank->programs[static_cast(programIndex)]; + const auto* current = program.snapshotForUi(); + if (!current) + return kResultFalse; + auto updated = + std::make_unique(*current); + for (const auto& [id, value] : decoded.parameters) + { + auto found = false; + for (auto& [updatedId, updatedValue] : updated->parameters) + { + if (updatedId == id) + { + updatedValue = value; + found = true; + break; + } + } + if (!found) + return kInvalidArgument; + } + updated->payload = std::move(decoded.payload); + program.replaceSnapshot(std::move(updated), true); + + if (bank->currentProgram.load(std::memory_order_acquire) == programIndex) + { + bank->pendingProgram.store( + RuntimeProgramBank::encodePendingProgram( + programIndex, true), + std::memory_order_release); + } + return kResultTrue; } protected: + struct PendingProcessorState + { + bool bypass = false; + std::vector parameterValues; + }; + static_assert( + std::atomic::is_always_lock_free); + + struct PublishedProcessorState + { + bool bypass = false; + std::vector parameterValues; + }; + + class PublishedStateUiGuard + { + public: + explicit PublishedStateUiGuard(std::atomic_flag& lock) + : lock_(lock) + { + while (lock_.test_and_set(std::memory_order_acquire)) + { + } + } + + ~PublishedStateUiGuard() + { + lock_.clear(std::memory_order_release); + } + + private: + std::atomic_flag& lock_; + }; + + void initializePublishedProcessorState() + { + mPublishedProcessorState.bypass = false; + mPublishedProcessorState.parameterValues.clear(); + mPublishedProcessorState.parameterValues.reserve(mParams.size()); + for (const auto& parameter : mParams) + { + if (!parameter.metadata.readOnly) + mPublishedProcessorState.parameterValues.emplace_back( + parameter.metadata.id, parameter.smoothed); + } + } + + void resetPendingProcessorState() + { + mPendingProcessorState.store(nullptr, std::memory_order_release); + mProcessorStateHazard.store(nullptr, std::memory_order_release); + mOwnedProcessorStates.clear(); + } + + const PendingProcessorState* queueProcessorState( + std::unique_ptr state) + { + const auto* active = state.get(); + mOwnedProcessorStates.push_back(std::move(state)); + mPendingProcessorState.store(active); + + const auto* hazard = mProcessorStateHazard.load(); + std::erase_if( + mOwnedProcessorStates, + [active, hazard](const auto& candidate) + { + return candidate.get() != active && + candidate.get() != hazard; + }); + return active; + } + + void applyPendingProcessorState() + { + const PendingProcessorState* pending = nullptr; + for (;;) + { + pending = mPendingProcessorState.load(); + mProcessorStateHazard.store(pending); + if (pending != mPendingProcessorState.load()) + continue; + if (!pending || + mPendingProcessorState.compare_exchange_strong( + pending, nullptr)) + break; + } + if (pending) + { + mBypassProcessorFloat.setActive(pending->bypass); + mBypassProcessorDouble.setActive(pending->bypass); + for (const auto& [id, value] : pending->parameterValues) + { + for (auto& parameter : mParams) + { + if (parameter.metadata.id != id || + parameter.metadata.readOnly) + continue; + parameter.smoothed = value; + parameter.rampTarget = value; + parameter.rampPerStep = 0.0; + parameter.saParam.setValue(value); + break; + } + } + } + mProcessorStateHazard.store(nullptr); + } + + void publishRestoredProcessorState( + const PendingProcessorState& restored) + { + PublishedStateUiGuard guard(mPublishedProcessorStateLock); + mPublishedProcessorState.bypass = restored.bypass; + for (const auto& [id, value] : restored.parameterValues) + { + for (auto& [publishedId, publishedValue] : + mPublishedProcessorState.parameterValues) + { + if (publishedId == id) + { + publishedValue = value; + break; + } + } + } + } + + void publishProcessorState() + { + if (mPendingProcessorState.load(std::memory_order_acquire) || + mPublishedProcessorStateLock.test_and_set( + std::memory_order_acquire)) + return; + if (mPendingProcessorState.load(std::memory_order_acquire)) + { + mPublishedProcessorStateLock.clear(std::memory_order_release); + return; + } + + mPublishedProcessorState.bypass = + mBypassProcessorFloat.isActive(); + for (std::size_t index = 0; index < mParams.size(); ++index) + { + const auto& parameter = mParams[index]; + if (parameter.metadata.readOnly) + continue; + for (auto& [id, value] : + mPublishedProcessorState.parameterValues) + { + if (id == parameter.metadata.id) + { + value = parameter.smoothed; + break; + } + } + } + mPublishedProcessorStateLock.clear(std::memory_order_release); + } + void publishOutputParameters(Vst::ProcessData& data) { if (!data.outputParameterChanges) @@ -413,55 +826,247 @@ class VST3Processor : public Steinberg::Vst::AudioEffect, double rampPerStep = 0.0; }; - static double plainToNormalized(const ::Parameter& parameter, double plainValue) + struct RuntimeProgram { - if (parameter.type == ParamType::Bool) - return plainValue >= 0.5 ? 1.0 : 0.0; + static_assert( + std::atomic:: + is_always_lock_free); + + std::vector> + ownedSnapshots; + const SingularityVst3::ProgramData* factorySnapshot = nullptr; + std::atomic activeSnapshot { + nullptr}; + std::atomic snapshotHazard { + nullptr}; + bool modified = false; + + const SingularityVst3::ProgramData* acquireSnapshot() + { + const SingularityVst3::ProgramData* snapshot = nullptr; + do + { + snapshot = activeSnapshot.load(); + snapshotHazard.store(snapshot); + } + while (snapshot != activeSnapshot.load()); + return snapshot; + } + + void releaseSnapshot() + { + snapshotHazard.store(nullptr); + } - if (parameter.type == ParamType::Choice && !parameter.choices.empty()) + const SingularityVst3::ProgramData* snapshotForUi() const { - const auto maxIndex = static_cast(parameter.choices.size() - 1); - if (maxIndex <= 0.0) - return 0.0; - return std::clamp(std::round(plainValue) / maxIndex, 0.0, 1.0); + return activeSnapshot.load(std::memory_order_acquire); } - if (parameter.type == ParamType::Stepped && parameter.steps > 1) + void setFactorySnapshot( + std::unique_ptr snapshot) { - const auto maxStep = static_cast(parameter.steps - 1); - return std::clamp(std::round(plainValue) / maxStep, 0.0, 1.0); + factorySnapshot = snapshot.get(); + ownedSnapshots.push_back(std::move(snapshot)); + activeSnapshot.store(factorySnapshot, std::memory_order_release); + modified = false; } - if (parameter.maxValue == parameter.minValue) - return 0.0; + void resetToFactory() + { + activeSnapshot.store(factorySnapshot, std::memory_order_release); + modified = false; - return std::clamp((plainValue - parameter.minValue) / - (parameter.maxValue - parameter.minValue), 0.0, 1.0); + const auto* hazard = + snapshotHazard.load(std::memory_order_acquire); + std::erase_if( + ownedSnapshots, + [this, hazard](const auto& candidate) + { + return candidate.get() != factorySnapshot && + candidate.get() != hazard; + }); + } + + void replaceSnapshot( + std::unique_ptr snapshot, + bool markModified) + { + const auto* active = snapshot.get(); + ownedSnapshots.push_back(std::move(snapshot)); + activeSnapshot.store(active); + modified = modified || markModified; + + const auto* hazard = snapshotHazard.load(); + std::erase_if( + ownedSnapshots, + [this, active, hazard](const auto& candidate) + { + return candidate.get() != active && + candidate.get() != factorySnapshot && + candidate.get() != hazard; + }); + } + }; + + struct RuntimeProgramBank + { + static constexpr int32 kNoPendingProgram = -1; + + static int32 encodePendingProgram( + int32 index, + bool applyParameters) + { + return applyParameters ? -index - 2 : index; + } + + static int32 programIndex(int32 pending) + { + return pending < kNoPendingProgram ? -pending - 2 : pending; + } + + static bool appliesParameters(int32 pending) + { + return pending < kNoPendingProgram; + } + + ::ProgramCollection definition; + int32 unitId = Vst::kRootUnitId; + Vst::ProgramListID listId = Vst::kNoProgramListId; + Vst::ParamID selectorId = Vst::kNoParamId; + std::vector> programs; + std::atomic currentProgram {0}; + std::atomic pendingProgram {kNoPendingProgram}; + }; + + bool initializeProgramBanks() + { + mProgramBanks.clear(); + auto model = SingularityVst3::buildProgramModel(); + if (!model) + return false; + + for (auto& definition : model->banks) + { + auto bank = std::make_unique(); + bank->definition = std::move(definition.definition); + bank->unitId = definition.unitId; + bank->listId = definition.listId; + bank->selectorId = definition.selectorId; + for (auto& data : definition.programs) + { + auto runtimeProgram = std::make_unique(); + runtimeProgram->setFactorySnapshot( + std::make_unique( + std::move(data))); + bank->programs.push_back(std::move(runtimeProgram)); + } + mProgramBanks.push_back(std::move(bank)); + } + return true; + } + + int32 programIndex( + const RuntimeProgramBank& bank, + Vst::ParamValue normalizedValue) const + { + if (bank.programs.empty()) + return -1; + const auto maxIndex = static_cast(bank.programs.size() - 1); + return static_cast(std::clamp( + std::round(std::clamp(normalizedValue, 0.0, 1.0) * maxIndex), + 0.0, + maxIndex)); } - static double normalizedToPlain(const ::Parameter& parameter, double normalizedValue) + bool applyProgram( + RuntimeProgramBank& bank, + int32 index, + bool applyParameters = true) { - const auto clamped = std::clamp(normalizedValue, 0.0, 1.0); + if (index < 0 || + index >= static_cast(bank.programs.size())) + return false; + + auto& program = + *bank.programs[static_cast(index)]; + const auto* snapshot = program.acquireSnapshot(); + if (!snapshot) + { + program.releaseSnapshot(); + return false; + } + if (applyParameters) + { + for (const auto& [id, value] : snapshot->parameters) + { + for (auto& parameter : mParams) + { + if (parameter.metadata.id != id || + parameter.metadata.readOnly) + continue; + parameter.smoothed = value; + parameter.rampTarget = value; + parameter.rampPerStep = 0.0; + parameter.saParam.setValue(value); + break; + } + } + } - if (parameter.type == ParamType::Bool) - return clamped >= 0.5 ? 1.0 : 0.0; + if constexpr (HandlesProgramData) + { + const auto& program = + bank.definition.programs[static_cast(index)]; + mPlugin.loadProgramData( + bank.definition.id, + program.id, + std::span( + snapshot->payload.data(), snapshot->payload.size())); + } - if (parameter.type == ParamType::Choice && !parameter.choices.empty()) - return std::round(clamped * static_cast(parameter.choices.size() - 1)); + program.releaseSnapshot(); + bank.currentProgram.store(index, std::memory_order_release); + return true; + } - if (parameter.type == ParamType::Stepped && parameter.steps > 1) - return std::round(clamped * static_cast(parameter.steps - 1)); + RuntimeProgramBank* findProgramBank(Vst::ProgramListID listId) + { + for (auto& bank : mProgramBanks) + if (bank->listId == listId) + return bank.get(); + return nullptr; + } - const auto plain = parameter.minValue + clamped * (parameter.maxValue - parameter.minValue); - if (parameter.type == ParamType::Stepped) - return std::round(plain); + const RuntimeProgramBank* findProgramBank( + Vst::ProgramListID listId) const + { + for (const auto& bank : mProgramBanks) + if (bank->listId == listId) + return bank.get(); + return nullptr; + } - return plain; + RuntimeProgramBank* findProgramBankBySelector(Vst::ParamID selectorId) + { + for (auto& bank : mProgramBanks) + if (bank->selectorId == selectorId) + return bank.get(); + return nullptr; } std::vector mParams; + std::vector> mProgramBanks; std::vector mMidiEvents; std::unique_ptr mDataExchange; + std::vector> + mOwnedProcessorStates; + std::atomic mPendingProcessorState { + nullptr}; + std::atomic mProcessorStateHazard { + nullptr}; + std::atomic_flag mPublishedProcessorStateLock = ATOMIC_FLAG_INIT; + PublishedProcessorState mPublishedProcessorState; int mSmoothSteps = 0; };