From 309d8ace73a5048cb78bef6060f57ce33e3f71a7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 28 Aug 2026 20:40:26 +0700 Subject: [PATCH 01/11] test: characterize R0-2 HostedPlugin seam --- .github/workflows/r0-2-hosted-plugin.yml | 36 +++ tests/r0_2/CMakeLists.txt | 59 +++++ tests/r0_2/README.md | 14 ++ tests/r0_2/hosted_plugin_characterization.cpp | 176 +++++++++++++++ .../r0_2/hosted_plugin_protocol_neutral.cmake | 24 ++ tests/r0_2/vst3_hosted_plugin_fixture.cpp | 207 ++++++++++++++++++ 6 files changed, 516 insertions(+) create mode 100644 .github/workflows/r0-2-hosted-plugin.yml create mode 100644 tests/r0_2/CMakeLists.txt create mode 100644 tests/r0_2/README.md create mode 100644 tests/r0_2/hosted_plugin_characterization.cpp create mode 100644 tests/r0_2/hosted_plugin_protocol_neutral.cmake create mode 100644 tests/r0_2/vst3_hosted_plugin_fixture.cpp diff --git a/.github/workflows/r0-2-hosted-plugin.yml b/.github/workflows/r0-2-hosted-plugin.yml new file mode 100644 index 0000000..012d4f0 --- /dev/null +++ b/.github/workflows/r0-2-hosted-plugin.yml @@ -0,0 +1,36 @@ +name: R0-2 HostedPlugin Characterization + +on: + pull_request: + paths: + - '.github/workflows/r0-2-hosted-plugin.yml' + - 'tests/r0_2/**' + - 'src/host/hosted_plugin.cpp' + - 'src/host/hosted_plugin.hpp' + - 'src/host/process_block_view.hpp' + - 'src/host/vst3_engine.cpp' + - 'src/host/vst3_engine.hpp' + - 'src/common/**' + - 'CMakeLists.txt' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: r0-2-hosted-plugin-${{ github.ref }} + cancel-in-progress: true + +jobs: + real-vst3-hosted-plugin: + runs-on: windows-2022 + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - name: Configure deterministic HostedPlugin characterization + run: cmake -S tests/r0_2 -B build/r0-2 -A x64 + - name: Build fixture and HostedPlugin characterization + run: cmake --build build/r0-2 --config Release --target r0-2-hosted-plugin-characterization --parallel + - name: Run HostedPlugin characterization + run: ctest --test-dir build/r0-2 -C Release --output-on-failure diff --git a/tests/r0_2/CMakeLists.txt b/tests/r0_2/CMakeLists.txt new file mode 100644 index 0000000..c4b354f --- /dev/null +++ b/tests/r0_2/CMakeLists.txt @@ -0,0 +1,59 @@ +cmake_minimum_required(VERSION 3.25) +project(safevst3-r0-2-characterization VERSION 1.0.0 LANGUAGES C CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +if(NOT WIN32) + message(FATAL_ERROR "R0-2 HostedPlugin characterization is Windows-only") +endif() + +set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") +set(SAFEVST3_BUILD_OBS_PLUGIN OFF CACHE BOOL "" FORCE) +set(SAFEVST3_BUILD_TESTS OFF CACHE BOOL "" FORCE) +set(SAFEVST3_STATIC_MSVC_RUNTIME ON CACHE BOOL "" FORCE) +set(SMTG_RUN_VST_VALIDATOR OFF CACHE BOOL "" FORCE) +set(SMTG_CREATE_MODULE_INFO OFF CACHE BOOL "" FORCE) +set(SMTG_CREATE_PLUGIN_LINK OFF CACHE BOOL "" FORCE) + +get_filename_component(SAFEVST3_ROOT "${CMAKE_CURRENT_LIST_DIR}/../.." ABSOLUTE) +add_subdirectory("${SAFEVST3_ROOT}" safevst3-root) + +smtg_add_vst3plugin(r0-2-hosted-plugin-fixture + "${CMAKE_CURRENT_LIST_DIR}/vst3_hosted_plugin_fixture.cpp" +) +target_link_libraries(r0-2-hosted-plugin-fixture PRIVATE sdk) +target_compile_definitions(r0-2-hosted-plugin-fixture PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX) + +add_executable(r0-2-hosted-plugin-characterization + "${CMAKE_CURRENT_LIST_DIR}/hosted_plugin_characterization.cpp" + "${SAFEVST3_ROOT}/src/host/hosted_plugin.cpp" + "${SAFEVST3_ROOT}/src/host/vst3_engine.cpp" +) +target_include_directories(r0-2-hosted-plugin-characterization PRIVATE "${SAFEVST3_ROOT}/src") +target_link_libraries(r0-2-hosted-plugin-characterization PRIVATE + safevst3_vst3_hosting_runtime + safevst3_lifecycle_policy + safevst3_state_restore_policy + safevst3_latency_restart_transaction + safevst3_parameter_refresh_transaction + safevst3_io_restart_transaction + safevst3_reload_component_transaction + safevst3_process_context_policy +) +target_compile_definitions(r0-2-hosted-plugin-characterization PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX) +add_dependencies(r0-2-hosted-plugin-characterization r0-2-hosted-plugin-fixture) + +enable_testing() +add_test( + NAME r0-2-hosted-plugin-characterization + COMMAND $ + $ +) +add_test( + NAME r0-2-hosted-plugin-protocol-neutral-contract + COMMAND ${CMAKE_COMMAND} + -DHEADER=${SAFEVST3_ROOT}/src/host/hosted_plugin.hpp + -P ${CMAKE_CURRENT_LIST_DIR}/hosted_plugin_protocol_neutral.cmake +) diff --git a/tests/r0_2/README.md b/tests/r0_2/README.md new file mode 100644 index 0000000..24943aa --- /dev/null +++ b/tests/r0_2/README.md @@ -0,0 +1,14 @@ +# R0-2 HostedPlugin characterization + +This focused Windows-only harness is the R0-2 extraction gate for issue #66. + +It builds a deterministic real VST3 effect with a separated edit controller and exercises the new protocol-neutral `HostedPlugin` object directly. The characterization locks: + +- closed/open/close/reopen lifecycle; +- `ProcessBlockView` processing without Single `AudioSlot` API; +- fixed latency exposure and latency-restart transaction; +- component + controller state capture/restore with observable audio state; +- helper-facing edit-controller accessor lifetime; +- public-header source contract rejecting Single transport types/layout includes. + +The fixture is a correctness oracle only. It does not implement Rack transport, Rack helper, multi-plugin processing, graphical Rack UI, scanner changes or any R1 behavior. diff --git a/tests/r0_2/hosted_plugin_characterization.cpp b/tests/r0_2/hosted_plugin_characterization.cpp new file mode 100644 index 0000000..204c2cd --- /dev/null +++ b/tests/r0_2/hosted_plugin_characterization.cpp @@ -0,0 +1,176 @@ +#include "common/protocol.hpp" +#include "common/state_snapshot.hpp" +#include "host/hosted_plugin.hpp" +#include "host/process_block_view.hpp" + +#include "pluginterfaces/base/funknown.h" +#include "pluginterfaces/vst/ivsteditcontroller.h" + +#include +#include +#include +#include +#include + +#ifdef _WIN32 + +namespace { + +using safevst3::AudioSlot; +using safevst3::HostedPlugin; +using safevst3::PluginStateSnapshot; +using safevst3::ProcessBlockView; + +constexpr std::uint32_t kGainParameterId = 100; + +static_assert(requires(HostedPlugin& plugin, ProcessBlockView& block) { + { plugin.process(block) } -> std::same_as; +}); +static_assert(!requires(HostedPlugin& plugin, AudioSlot& slot) { + plugin.process(slot); +}); + +class TestComponentHandler final : public Steinberg::Vst::IComponentHandler { +public: + Steinberg::tresult PLUGIN_API queryInterface(const Steinberg::TUID iid, void** object) override + { + if (!object) + return Steinberg::kInvalidArgument; + *object = nullptr; + if (Steinberg::FUnknownPrivate::iidEqual(iid, Steinberg::FUnknown::iid) || + Steinberg::FUnknownPrivate::iidEqual(iid, Steinberg::Vst::IComponentHandler::iid)) { + *object = static_cast(this); + addRef(); + return Steinberg::kResultTrue; + } + return Steinberg::kNoInterface; + } + + Steinberg::uint32 PLUGIN_API addRef() override { return ++refs_; } + Steinberg::uint32 PLUGIN_API release() override { return --refs_; } + Steinberg::tresult PLUGIN_API beginEdit(Steinberg::Vst::ParamID) override { return Steinberg::kResultTrue; } + Steinberg::tresult PLUGIN_API performEdit(Steinberg::Vst::ParamID, Steinberg::Vst::ParamValue) override { return Steinberg::kResultTrue; } + Steinberg::tresult PLUGIN_API endEdit(Steinberg::Vst::ParamID) override { return Steinberg::kResultTrue; } + Steinberg::tresult PLUGIN_API restartComponent(Steinberg::int32 flags) override + { + restart_flags_.fetch_or(static_cast(flags), std::memory_order_relaxed); + return Steinberg::kResultTrue; + } + +private: + std::atomic refs_{1}; + std::atomic restart_flags_{0}; +}; + +bool close_enough(float actual, float expected) +{ + return std::fabs(actual - expected) <= 1.0e-6f; +} + +bool expect(bool condition, const char* message) +{ + if (!condition) + std::cerr << "FAIL: " << message << '\n'; + return condition; +} + +bool expect_sample(float actual, float expected, const char* message) +{ + if (close_enough(actual, expected)) + return true; + std::cerr << "FAIL: " << message << " expected=" << expected << " actual=" << actual << '\n'; + return false; +} + +bool open_plugin(HostedPlugin& plugin, const char* module_path, TestComponentHandler& handler) +{ + std::string error; + if (plugin.open(module_path, "", 48000, 2, &handler, error)) + return true; + std::cerr << "FAIL: HostedPlugin::open: " << error << '\n'; + return false; +} + +bool process_gain_block(HostedPlugin& plugin, float input_value, float expected, std::uint64_t sequence) +{ + float left_in[1] = {input_value}; + float right_in[1] = {input_value}; + float left_out[1] = {}; + float right_out[1] = {}; + float* inputs[2] = {left_in, right_in}; + float* outputs[2] = {left_out, right_out}; + ProcessBlockView block{inputs, outputs, 2, 1, sequence}; + bool ok = expect(plugin.process(block), "HostedPlugin ProcessBlockView must process"); + ok &= expect_sample(left_out[0], expected, "HostedPlugin left output"); + ok &= expect_sample(right_out[0], expected, "HostedPlugin right output"); + return ok; +} + +bool characterize_deep_hosted_plugin(const char* module_path) +{ + TestComponentHandler handler; + HostedPlugin plugin; + bool ok = true; + + float unopened_in = 1.0f; + float unopened_out = 0.0f; + float* unopened_inputs[1] = {&unopened_in}; + float* unopened_outputs[1] = {&unopened_out}; + ProcessBlockView unopened_block{unopened_inputs, unopened_outputs, 1, 1, 1}; + ok &= expect(!plugin.process(unopened_block), "closed HostedPlugin must reject process"); + + if (!open_plugin(plugin, module_path, handler)) + return false; + + ok &= expect(plugin.latency_samples() == 64, "fixture latency must be exposed by HostedPlugin"); + auto* first_editor_accessor = plugin.edit_controller(); + ok &= expect(first_editor_accessor != nullptr, "helper-owned editor accessor must expose fixture controller"); + ok &= expect(plugin.edit_controller() == first_editor_accessor, + "editor accessor ownership must remain stable while plugin is open"); + + ok &= expect(plugin.queue_parameter(kGainParameterId, 0.75), "queue gain=0.75 must succeed"); + ok &= process_gain_block(plugin, 2.0f, 1.5f, 10); + + PluginStateSnapshot snapshot{}; + std::string error; + ok &= expect(plugin.capture_state(snapshot, error), "component/controller state capture must succeed"); + ok &= expect(!snapshot.component.empty(), "component state must be captured"); + ok &= expect(!snapshot.controller.empty(), "controller state must be captured"); + + ok &= expect(plugin.queue_parameter(kGainParameterId, 0.25), "queue gain=0.25 must succeed"); + ok &= process_gain_block(plugin, 2.0f, 0.5f, 11); + + error.clear(); + ok &= expect(plugin.restore_state(snapshot, error), "component/controller state restore must succeed"); + ok &= process_gain_block(plugin, 2.0f, 1.5f, 12); + + error.clear(); + ok &= expect(plugin.refresh_latency_after_restart(error), "latency restart transaction must succeed"); + ok &= expect(plugin.latency_samples() == 64, "latency must remain coherent after restart transaction"); + + plugin.close(); + ok &= expect(plugin.edit_controller() == nullptr, "editor accessor must clear after close"); + ok &= expect(!plugin.process(unopened_block), "closed HostedPlugin must reject process after explicit close"); + + ok &= expect(open_plugin(plugin, module_path, handler), "HostedPlugin must reopen after close"); + plugin.close(); + return ok; +} + +} // namespace + +int main(int argc, char** argv) +{ + if (argc != 2) { + std::cerr << "usage: r0-2-hosted-plugin-characterization \n"; + return 2; + } + if (!characterize_deep_hosted_plugin(argv[1])) + return 1; + std::cout << "R0-2 HostedPlugin lifecycle/state/process seam characterized successfully\n"; + return 0; +} + +#else +int main() { return 0; } +#endif diff --git a/tests/r0_2/hosted_plugin_protocol_neutral.cmake b/tests/r0_2/hosted_plugin_protocol_neutral.cmake new file mode 100644 index 0000000..6110552 --- /dev/null +++ b/tests/r0_2/hosted_plugin_protocol_neutral.cmake @@ -0,0 +1,24 @@ +if(NOT DEFINED HEADER OR NOT EXISTS "${HEADER}") + message(FATAL_ERROR "R0-2 HostedPlugin header is missing") +endif() + +file(READ "${HEADER}" source) + +foreach(forbidden IN ITEMS "AudioSlot" "SharedAudioRegion" "common/protocol.hpp") + string(FIND "${source}" "${forbidden}" position) + if(NOT position EQUAL -1) + message(FATAL_ERROR "HostedPlugin public seam must remain protocol-neutral; found forbidden token: ${forbidden}") + endif() +endforeach() + +string(FIND "${source}" "class HostedPlugin" class_position) +if(class_position EQUAL -1) + message(FATAL_ERROR "HostedPlugin public seam must declare class HostedPlugin") +endif() + +string(FIND "${source}" "ProcessBlockView" process_view_position) +if(process_view_position EQUAL -1) + message(FATAL_ERROR "HostedPlugin public seam must expose ProcessBlockView processing") +endif() + +message(STATUS "R0-2 HostedPlugin public seam is protocol-neutral") diff --git a/tests/r0_2/vst3_hosted_plugin_fixture.cpp b/tests/r0_2/vst3_hosted_plugin_fixture.cpp new file mode 100644 index 0000000..461b5de --- /dev/null +++ b/tests/r0_2/vst3_hosted_plugin_fixture.cpp @@ -0,0 +1,207 @@ +#include "pluginterfaces/base/ibstream.h" +#include "pluginterfaces/vst/ivstparameterchanges.h" +#include "pluginterfaces/vst/vstspeaker.h" +#include "public.sdk/source/main/pluginfactory.h" +#include "public.sdk/source/vst/vstaudioeffect.h" +#include "public.sdk/source/vst/vsteditcontroller.h" + +#include + +namespace safevst3::test_fixture { + +using namespace Steinberg; +using namespace Steinberg::Vst; + +static const FUID kProcessorUid(0x6EDEB158, 0x7E794DE6, 0xA1C3E02A, 0x19E90D44); +static const FUID kControllerUid(0x39E8D25F, 0xE6914122, 0x8A4E8B59, 0xD9279E16); +constexpr ParamID kGainParameterId = 100; +constexpr auto kPluginName = "SafeVST3 R0-2 HostedPlugin Fixture"; +constexpr SpeakerArrangement kArrangement = SpeakerArr::kStereo; +constexpr uint32 kLatencySamples = 64; + +bool write_double(IBStream* stream, double value) +{ + if (!stream) + return false; + int32 written = 0; + return stream->write(&value, static_cast(sizeof(value)), &written) == kResultTrue && + written == static_cast(sizeof(value)); +} + +bool read_double(IBStream* stream, double& value) +{ + if (!stream) + return false; + int32 read = 0; + return stream->read(&value, static_cast(sizeof(value)), &read) == kResultTrue && + read == static_cast(sizeof(value)); +} + +class HostedPluginFixtureProcessor final : public AudioEffect { +public: + static FUnknown* create_instance(void*) + { + return static_cast(new HostedPluginFixtureProcessor()); + } + + tresult PLUGIN_API initialize(FUnknown* context) override + { + const tresult result = AudioEffect::initialize(context); + if (result != kResultOk) + return result; + addAudioInput(STR16("Input"), kArrangement); + addAudioOutput(STR16("Output"), kArrangement); + return kResultOk; + } + + tresult PLUGIN_API getControllerClassId(TUID class_id) override + { + kControllerUid.toTUID(class_id); + return kResultTrue; + } + + tresult PLUGIN_API setBusArrangements(SpeakerArrangement* inputs, + int32 num_inputs, + SpeakerArrangement* outputs, + int32 num_outputs) override + { + if (!inputs || !outputs || num_inputs != 1 || num_outputs != 1) + return kResultFalse; + return inputs[0] == kArrangement && outputs[0] == kArrangement + ? kResultTrue : kResultFalse; + } + + tresult PLUGIN_API setProcessing(TBool) override { return kResultTrue; } + + tresult PLUGIN_API canProcessSampleSize(int32 symbolic_sample_size) override + { + return symbolic_sample_size == kSample32 ? kResultTrue : kResultFalse; + } + + uint32 PLUGIN_API getLatencySamples() override { return kLatencySamples; } + + tresult PLUGIN_API getState(IBStream* state) override + { + return write_double(state, gain_) ? kResultTrue : kResultFalse; + } + + tresult PLUGIN_API setState(IBStream* state) override + { + double value = 0.0; + if (!read_double(state, value)) + return kResultFalse; + gain_ = std::clamp(value, 0.0, 1.0); + return kResultTrue; + } + + tresult PLUGIN_API process(ProcessData& data) override + { + if (data.inputParameterChanges) { + const int32 count = data.inputParameterChanges->getParameterCount(); + for (int32 index = 0; index < count; ++index) { + auto* queue = data.inputParameterChanges->getParameterData(index); + if (!queue || queue->getParameterId() != kGainParameterId || queue->getPointCount() <= 0) + continue; + int32 sample_offset = 0; + ParamValue value = 0.0; + if (queue->getPoint(queue->getPointCount() - 1, sample_offset, value) == kResultTrue) + gain_ = std::clamp(value, 0.0, 1.0); + } + } + + if (data.numSamples == 0) + return kResultOk; + if (data.numSamples < 0 || data.numInputs != 1 || data.numOutputs != 1 || + !data.inputs || !data.outputs || + data.inputs[0].numChannels != 2 || data.outputs[0].numChannels != 2 || + !data.inputs[0].channelBuffers32 || !data.outputs[0].channelBuffers32) + return kResultFalse; + + for (int32 channel = 0; channel < 2; ++channel) { + auto* input = data.inputs[0].channelBuffers32[channel]; + auto* output = data.outputs[0].channelBuffers32[channel]; + if (!input || !output) + return kResultFalse; + for (int32 frame = 0; frame < data.numSamples; ++frame) + output[frame] = input[frame] * static_cast(gain_); + } + data.outputs[0].silenceFlags = 0; + return kResultOk; + } + +private: + double gain_ = 0.5; +}; + +class HostedPluginFixtureController final : public EditController { +public: + static FUnknown* create_instance(void*) + { + return static_cast(new HostedPluginFixtureController()); + } + + tresult PLUGIN_API initialize(FUnknown* context) override + { + const tresult result = EditController::initialize(context); + if (result != kResultOk) + return result; + parameters.addParameter(STR16("Gain"), nullptr, 0, 0.5, + ParameterInfo::kCanAutomate, kGainParameterId); + return kResultOk; + } + + tresult PLUGIN_API setComponentState(IBStream* state) override + { + double value = 0.0; + if (!read_double(state, value)) + return kResultFalse; + return setParamNormalized(kGainParameterId, std::clamp(value, 0.0, 1.0)); + } + + tresult PLUGIN_API getState(IBStream* state) override + { + return write_double(state, getParamNormalized(kGainParameterId)) ? kResultTrue : kResultFalse; + } + + tresult PLUGIN_API setState(IBStream* state) override + { + double value = 0.0; + if (!read_double(state, value)) + return kResultFalse; + return setParamNormalized(kGainParameterId, std::clamp(value, 0.0, 1.0)); + } +}; + +} // namespace safevst3::test_fixture + +using namespace Steinberg; +using namespace Steinberg::Vst; +using safevst3::test_fixture::HostedPluginFixtureController; +using safevst3::test_fixture::HostedPluginFixtureProcessor; +using safevst3::test_fixture::kControllerUid; +using safevst3::test_fixture::kPluginName; +using safevst3::test_fixture::kProcessorUid; + +BEGIN_FACTORY_DEF("OBS Safe VST3 Tests", "https://github.com/masarray/obs-vst3", "") + +DEF_CLASS2(INLINE_UID_FROM_FUID(kProcessorUid), + PClassInfo::kManyInstances, + kVstAudioEffectClass, + kPluginName, + 0, + "Fx", + "1.0.0", + kVstVersionString, + HostedPluginFixtureProcessor::create_instance) + +DEF_CLASS2(INLINE_UID_FROM_FUID(kControllerUid), + PClassInfo::kManyInstances, + kVstComponentControllerClass, + "SafeVST3 R0-2 HostedPlugin Fixture Controller", + 0, + "", + "1.0.0", + kVstVersionString, + HostedPluginFixtureController::create_instance) + +END_FACTORY From 508263bf9b145fa70961afb3d5851c3f26c0ef4b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 28 Aug 2026 20:43:24 +0700 Subject: [PATCH 02/11] R0-2: add protocol-neutral HostedPlugin facade --- src/host/hosted_plugin.cpp | 156 +++++++++++++++++++++++++++++++++++++ src/host/hosted_plugin.hpp | 89 +++++++++++++++++++++ 2 files changed, 245 insertions(+) create mode 100644 src/host/hosted_plugin.cpp create mode 100644 src/host/hosted_plugin.hpp diff --git a/src/host/hosted_plugin.cpp b/src/host/hosted_plugin.cpp new file mode 100644 index 0000000..0d15ce9 --- /dev/null +++ b/src/host/hosted_plugin.cpp @@ -0,0 +1,156 @@ +#ifdef _WIN32 + +#include "host/hosted_plugin.hpp" + +#include "host/vst3_engine.hpp" + +#include + +namespace safevst3 { + +HostedPlugin::HostedPlugin() + : engine_(std::make_unique()) +{ +} + +HostedPlugin::~HostedPlugin() = default; + +bool HostedPlugin::open(const std::string& path, + const std::string& class_id, + std::uint32_t sample_rate, + std::uint32_t channels, + Steinberg::Vst::IComponentHandler* component_handler, + StartupPhaseSink* startup_phase_sink, + std::string& error) +{ + return engine_->open(path, class_id, sample_rate, channels, + component_handler, startup_phase_sink, error); +} + +bool HostedPlugin::open(const std::string& path, + const std::string& class_id, + std::uint32_t sample_rate, + std::uint32_t channels, + Steinberg::Vst::IComponentHandler* component_handler, + std::string& error) +{ + return engine_->open(path, class_id, sample_rate, channels, + component_handler, error); +} + +void HostedPlugin::close() noexcept +{ + engine_->close(); +} + +bool HostedPlugin::process(const ProcessBlockView& block) noexcept +{ + return engine_->process(block); +} + +bool HostedPlugin::capture_state(PluginStateSnapshot& snapshot, std::string& error) +{ + return engine_->capture_state(snapshot, error); +} + +bool HostedPlugin::restore_state(const PluginStateSnapshot& snapshot, std::string& error) +{ + return engine_->restore_state(snapshot, error); +} + +bool HostedPlugin::refresh_latency_after_restart(std::string& error) +{ + return engine_->refresh_latency_after_restart(error); +} + +bool HostedPlugin::reconfigure_io_after_restart(IoLayout& layout, + std::uint32_t& latency_samples, + std::string& error) +{ + return engine_->reconfigure_io_after_restart(layout, latency_samples, error); +} + +bool HostedPlugin::queue_parameter(std::uint32_t id, double normalized) noexcept +{ + return engine_->queue_parameter(id, normalized); +} + +bool HostedPlugin::queue_parameter_from_controller(std::uint32_t id, double normalized) noexcept +{ + return engine_->queue_parameter_from_controller(id, normalized); +} + +bool HostedPlugin::set_controller_parameter(std::uint32_t id, double normalized) noexcept +{ + return engine_->set_controller_parameter(id, normalized); +} + +bool HostedPlugin::queue_processor_parameter(std::uint32_t id, double normalized) noexcept +{ + return engine_->queue_processor_parameter(id, normalized); +} + +bool HostedPlugin::flush_parameter_changes() noexcept +{ + return engine_->flush_parameter_changes(); +} + +void HostedPlugin::refresh_parameter_values() noexcept +{ + engine_->refresh_parameter_values(); +} + +bool HostedPlugin::refresh_parameter_metadata(std::string& error) +{ + return engine_->refresh_parameter_metadata(error); +} + +std::size_t HostedPlugin::take_parameter_updates(EngineParameterUpdate* destination, + std::size_t capacity) noexcept +{ + return engine_->take_parameter_updates(destination, capacity); +} + +void HostedPlugin::set_component_handler(Steinberg::Vst::IComponentHandler* handler) noexcept +{ + engine_->set_component_handler(handler); +} + +Steinberg::Vst::IEditController* HostedPlugin::edit_controller() const noexcept +{ + return engine_->edit_controller(); +} + +const std::string& HostedPlugin::plugin_name() const noexcept +{ + return engine_->plugin_name(); +} + +const std::string& HostedPlugin::loaded_class_id() const noexcept +{ + return engine_->loaded_class_id(); +} + +std::uint32_t HostedPlugin::latency_samples() const noexcept +{ + return engine_->latency_samples(); +} + +std::uint32_t HostedPlugin::process_context_requirements() const noexcept +{ + return engine_->process_context_requirements(); +} + +std::uint32_t HostedPlugin::unsupported_process_context_requirements() const noexcept +{ + return engine_->unsupported_process_context_requirements(); +} + +const std::vector& HostedPlugin::parameters() const noexcept +{ + return engine_->parameters(); +} + +} // namespace safevst3 + +#endif diff --git a/src/host/hosted_plugin.hpp b/src/host/hosted_plugin.hpp new file mode 100644 index 0000000..b06ffaf --- /dev/null +++ b/src/host/hosted_plugin.hpp @@ -0,0 +1,89 @@ +#pragma once + +#ifdef _WIN32 + +#include "host/process_block_view.hpp" + +#include +#include +#include +#include +#include + +namespace Steinberg::Vst { +class IComponentHandler; +class IEditController; +} + +namespace safevst3 { + +class StartupPhaseSink; +class Vst3Engine; +struct EngineParameter; +struct EngineParameterUpdate; +struct IoLayout; +struct PluginStateSnapshot; + +// Protocol-neutral helper-side facade for exactly one VST3 audio effect. +// Single/Rack transports own their buffer/layout adapters outside this class. +// The implementation intentionally reuses the already-qualified Vst3Engine +// core so R0-2 introduces no second lifecycle/state implementation. +class HostedPlugin final { +public: + HostedPlugin(); + ~HostedPlugin(); + + HostedPlugin(const HostedPlugin&) = delete; + HostedPlugin& operator=(const HostedPlugin&) = delete; + + bool open(const std::string& path, + const std::string& class_id, + std::uint32_t sample_rate, + std::uint32_t channels, + Steinberg::Vst::IComponentHandler* component_handler, + StartupPhaseSink* startup_phase_sink, + std::string& error); + bool open(const std::string& path, + const std::string& class_id, + std::uint32_t sample_rate, + std::uint32_t channels, + Steinberg::Vst::IComponentHandler* component_handler, + std::string& error); + + void close() noexcept; + bool process(const ProcessBlockView& block) noexcept; + + bool capture_state(PluginStateSnapshot& snapshot, std::string& error); + bool restore_state(const PluginStateSnapshot& snapshot, std::string& error); + bool refresh_latency_after_restart(std::string& error); + bool reconfigure_io_after_restart(IoLayout& layout, + std::uint32_t& latency_samples, + std::string& error); + + bool queue_parameter(std::uint32_t id, double normalized) noexcept; + bool queue_parameter_from_controller(std::uint32_t id, double normalized) noexcept; + bool set_controller_parameter(std::uint32_t id, double normalized) noexcept; + bool queue_processor_parameter(std::uint32_t id, double normalized) noexcept; + bool flush_parameter_changes() noexcept; + void refresh_parameter_values() noexcept; + bool refresh_parameter_metadata(std::string& error); + std::size_t take_parameter_updates(EngineParameterUpdate* destination, + std::size_t capacity) noexcept; + + void set_component_handler(Steinberg::Vst::IComponentHandler* handler) noexcept; + Steinberg::Vst::IEditController* edit_controller() const noexcept; + + const std::string& plugin_name() const noexcept; + const std::string& loaded_class_id() const noexcept; + std::uint32_t latency_samples() const noexcept; + std::uint32_t process_context_requirements() const noexcept; + std::uint32_t unsupported_process_context_requirements() const noexcept; + const std::vector& parameters() const noexcept; + +private: + std::unique_ptr engine_; +}; + +} // namespace safevst3 + +#endif From 3af64b2d9f3d85477e4f20202690efcebbe25890 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 28 Aug 2026 20:45:38 +0700 Subject: [PATCH 03/11] test: make HostedPlugin protocol check dependent --- tests/r0_2/hosted_plugin_characterization.cpp | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/r0_2/hosted_plugin_characterization.cpp b/tests/r0_2/hosted_plugin_characterization.cpp index 204c2cd..19e3039 100644 --- a/tests/r0_2/hosted_plugin_characterization.cpp +++ b/tests/r0_2/hosted_plugin_characterization.cpp @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -23,12 +24,18 @@ using safevst3::ProcessBlockView; constexpr std::uint32_t kGainParameterId = 100; -static_assert(requires(HostedPlugin& plugin, ProcessBlockView& block) { +template +concept ProcessesBlockView = requires(Plugin& plugin, ProcessBlockView& block) { { plugin.process(block) } -> std::same_as; -}); -static_assert(!requires(HostedPlugin& plugin, AudioSlot& slot) { +}; + +template +concept ProcessesSingleSlot = requires(Plugin& plugin, AudioSlot& slot) { plugin.process(slot); -}); +}; + +static_assert(ProcessesBlockView); +static_assert(!ProcessesSingleSlot); class TestComponentHandler final : public Steinberg::Vst::IComponentHandler { public: From 201ae49c64b17b9d74e39de92df07c7a6ad45107 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 28 Aug 2026 20:49:02 +0700 Subject: [PATCH 04/11] R0-2: compile HostedPlugin into production helper --- CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index b1aadef..bc11c81 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -115,6 +115,7 @@ if(WIN32) add_executable(obs-safe-vst3-host src/host/main.cpp src/host/native_editor.cpp + src/host/hosted_plugin.cpp src/host/vst3_engine.cpp ) target_include_directories(obs-safe-vst3-host PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src") From 47b66e5252b8c5202d48fe4e874ad7eca20c7544 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 28 Aug 2026 20:49:53 +0700 Subject: [PATCH 05/11] ci: include HostedPlugin in compatibility gate --- .github/workflows/compat-test-build.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/compat-test-build.yml b/.github/workflows/compat-test-build.yml index fa264a4..0cf1ed5 100644 --- a/.github/workflows/compat-test-build.yml +++ b/.github/workflows/compat-test-build.yml @@ -8,6 +8,8 @@ on: - 'src/obs-plugin/obs_compat_floor.hpp' - 'src/obs-plugin/plugin.cpp' - 'src/host/native_editor.cpp' + - 'src/host/hosted_plugin.cpp' + - 'src/host/hosted_plugin.hpp' - 'src/host/process_block_view.hpp' - 'src/host/vst3_engine.cpp' - 'src/host/vst3_engine.hpp' From 14ac1de41346c9fe1ace30f96bf1670c839da1f9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 28 Aug 2026 20:50:11 +0700 Subject: [PATCH 06/11] ci: keep R0-1 seam gate on HostedPlugin changes --- .github/workflows/r0-1-process-seam.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/r0-1-process-seam.yml b/.github/workflows/r0-1-process-seam.yml index 6d23f0f..0af2ad2 100644 --- a/.github/workflows/r0-1-process-seam.yml +++ b/.github/workflows/r0-1-process-seam.yml @@ -5,6 +5,8 @@ on: paths: - '.github/workflows/r0-1-process-seam.yml' - 'tests/r0_1/**' + - 'src/host/hosted_plugin.cpp' + - 'src/host/hosted_plugin.hpp' - 'src/host/process_block_view.hpp' - 'src/host/vst3_engine.cpp' - 'src/host/vst3_engine.hpp' From 72a07382a3221b57819323fb463a0858a41e2476 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 29 Aug 2026 08:48:42 +0700 Subject: [PATCH 07/11] ci(r0-2): bootstrap mechanical deep extraction --- .../workflows/r0-2-deep-extract-bootstrap.yml | 324 ++++++++++++++++++ 1 file changed, 324 insertions(+) create mode 100644 .github/workflows/r0-2-deep-extract-bootstrap.yml diff --git a/.github/workflows/r0-2-deep-extract-bootstrap.yml b/.github/workflows/r0-2-deep-extract-bootstrap.yml new file mode 100644 index 0000000..6adac42 --- /dev/null +++ b/.github/workflows/r0-2-deep-extract-bootstrap.yml @@ -0,0 +1,324 @@ +name: R0-2 Deep Extraction Bootstrap + +on: + pull_request: + branches: [main] + paths: + - '.github/workflows/r0-2-deep-extract-bootstrap.yml' + +permissions: + contents: write + +jobs: + deep-extract: + if: github.event.pull_request.head.repo.full_name == github.repository && github.head_ref == 'r0-2-hosted-plugin' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + + - name: Mechanically move deep one-VST3 ownership into HostedPlugin + shell: bash + run: | + python3 - <<'PY' + from pathlib import Path + import subprocess + + def fail(message): + raise SystemExit(message) + + def require(condition, message): + if not condition: + fail(message) + + def replace_once(text, old, new, label): + count = text.count(old) + require(count == 1, f"{label}: expected one match, found {count}") + return text.replace(old, new, 1) + + root = Path('.') + engine_cpp_path = root / 'src/host/vst3_engine.cpp' + engine_hpp_path = root / 'src/host/vst3_engine.hpp' + hosted_cpp_path = root / 'src/host/hosted_plugin.cpp' + hosted_hpp_path = root / 'src/host/hosted_plugin.hpp' + + blob = subprocess.check_output(['git', 'hash-object', str(engine_cpp_path)], text=True).strip() + require(blob == '9e92d2a74847db407d7a354d668b0cf921ef3e2b', + f'unexpected vst3_engine.cpp blob {blob}; refusing mechanical extraction') + + old_cpp = engine_cpp_path.read_text(encoding='utf-8') + old_hpp = engine_hpp_path.read_text(encoding='utf-8') + + # Protocol-neutral one-plugin bounds/policy. Values are copied exactly + # from the existing Single protocol; serialized layout/version do not change. + contract = '''#pragma once + +#include +#include + +namespace safevst3 { + +inline constexpr std::uint32_t kMaxChannels = 2; +inline constexpr std::uint32_t kMaxFrames = 2048; +inline constexpr std::uint32_t kMaxParameters = 256; +inline constexpr std::size_t kMaxStateBytes = 16u * 1024u * 1024u; + +enum ParameterFlags : std::uint32_t { + ParameterCanAutomate = 1u << 0, + ParameterReadOnly = 1u << 1, + ParameterHidden = 1u << 2, + ParameterList = 1u << 3, + ParameterProgramChange = 1u << 4, + ParameterBypass = 1u << 5, +}; + +} // namespace safevst3 +''' + (root / 'src/common/vst3_host_contract.hpp').write_text(contract, encoding='utf-8') + + protocol_path = root / 'src/common/protocol.hpp' + protocol = protocol_path.read_text(encoding='utf-8') + protocol = replace_once(protocol, '#pragma once\n\n', + '#pragma once\n\n#include "common/vst3_host_contract.hpp"\n\n', + 'protocol host-contract include') + for line in ( + 'inline constexpr std::uint32_t kMaxChannels = 2;\n', + 'inline constexpr std::uint32_t kMaxFrames = 2048;\n', + 'inline constexpr std::uint32_t kMaxParameters = 256;\n', + 'inline constexpr std::size_t kMaxStateBytes = 16u * 1024u * 1024u;\n', + ): + protocol = replace_once(protocol, line, '', f'remove duplicate {line.strip()}') + flags = '''enum ParameterFlags : std::uint32_t { + ParameterCanAutomate = 1u << 0, + ParameterReadOnly = 1u << 1, + ParameterHidden = 1u << 2, + ParameterList = 1u << 3, + ParameterProgramChange = 1u << 4, + ParameterBypass = 1u << 5, +}; + +''' + protocol = replace_once(protocol, flags, '', 'remove duplicate ParameterFlags') + protocol_path.write_text(protocol, encoding='utf-8') + + state_path = root / 'src/common/state_snapshot.hpp' + state = state_path.read_text(encoding='utf-8') + state = replace_once(state, '#include "common/protocol.hpp"', + '#include "common/vst3_host_contract.hpp"', + 'state snapshot host-contract include') + state_path.write_text(state, encoding='utf-8') + + # Deep owner declaration: mechanically preserve every proven VST3 + # responsibility, but remove the Single AudioSlot overload/dependency. + deep_hpp = old_hpp.replace('#include "common/protocol.hpp"', + '#include "common/vst3_host_contract.hpp"') + deep_hpp = deep_hpp.replace('Vst3Engine', 'HostedPlugin') + deep_hpp = replace_once(deep_hpp, + 'class HostedPlugin final : public LatencyRestartTarget, public IoRestartLifecycleTarget {', + 'class HostedPlugin : public LatencyRestartTarget, public IoRestartLifecycleTarget {', + 'remove final from deep HostedPlugin') + deep_hpp = replace_once(deep_hpp, ' bool process(AudioSlot& slot) noexcept;\n', '', + 'remove Single AudioSlot method from HostedPlugin') + require('AudioSlot' not in deep_hpp, 'HostedPlugin header still references AudioSlot') + require('common/protocol.hpp' not in deep_hpp, 'HostedPlugin header still references Single protocol') + hosted_hpp_path.write_text(deep_hpp, encoding='utf-8') + + adapter_block = '''bool Vst3Engine::process(AudioSlot& slot) noexcept +{ + float* input[kMaxChannels] = {slot.input[0], slot.input[1]}; + float* output[kMaxChannels] = {slot.output[0], slot.output[1]}; + const ProcessBlockView block{ + input, + output, + slot.channels, + slot.frames, + slot.sequence, + }; + return process(block); +} + +''' + deep_cpp = replace_once(old_cpp, adapter_block, '', 'remove AudioSlot implementation from deep owner') + deep_cpp = replace_once(deep_cpp, '#include "host/vst3_engine.hpp"', + '#include "host/hosted_plugin.hpp"', + 'deep source include') + deep_cpp = deep_cpp.replace('Vst3Engine', 'HostedPlugin') + require('AudioSlot' not in deep_cpp, 'HostedPlugin source still references AudioSlot') + require('Vst3Engine' not in deep_cpp, 'HostedPlugin source still references Vst3Engine') + hosted_cpp_path.write_text(deep_cpp, encoding='utf-8') + + thin_hpp = '''#pragma once + +#ifdef _WIN32 + +#include "common/protocol.hpp" +#include "host/hosted_plugin.hpp" + +namespace safevst3 { + +// Single-transport compatibility adapter. All VST3 lifecycle/state/process +// ownership lives in HostedPlugin; this class only maps AudioSlot buffers into +// the protocol-neutral ProcessBlockView seam. +class Vst3Engine final : public HostedPlugin { +public: + Vst3Engine() = default; + ~Vst3Engine() = default; + + Vst3Engine(const Vst3Engine&) = delete; + Vst3Engine& operator=(const Vst3Engine&) = delete; + + using HostedPlugin::process; + bool process(AudioSlot& slot) noexcept; +}; + +} // namespace safevst3 + +#endif +''' + engine_hpp_path.write_text(thin_hpp, encoding='utf-8') + + thin_cpp = '''#ifdef _WIN32 + +#include "host/vst3_engine.hpp" + +namespace safevst3 { + +bool Vst3Engine::process(AudioSlot& slot) noexcept +{ + float* input[kMaxChannels] = {slot.input[0], slot.input[1]}; + float* output[kMaxChannels] = {slot.output[0], slot.output[1]}; + const ProcessBlockView block{ + input, + output, + slot.channels, + slot.frames, + slot.sequence, + }; + return HostedPlugin::process(block); +} + +} // namespace safevst3 + +#endif +''' + engine_cpp_path.write_text(thin_cpp, encoding='utf-8') + + # Existing source-contracts remain mandatory; retarget them to the + # deep implementation and rename only class-qualified markers. + root_cmake_path = root / 'CMakeLists.txt' + root_cmake = root_cmake_path.read_text(encoding='utf-8') + root_cmake = replace_once(root_cmake, + '-DSOURCE_FILE=${CMAKE_CURRENT_SOURCE_DIR}/src/host/vst3_engine.cpp', + '-DSOURCE_FILE=${CMAKE_CURRENT_SOURCE_DIR}/src/host/hosted_plugin.cpp', + 'process-context source-contract target') + root_cmake = replace_once(root_cmake, + '-DENGINE_SOURCE=${CMAKE_CURRENT_SOURCE_DIR}/src/host/vst3_engine.cpp', + '-DENGINE_SOURCE=${CMAKE_CURRENT_SOURCE_DIR}/src/host/hosted_plugin.cpp', + 'strict-lifecycle source-contract target') + root_cmake_path.write_text(root_cmake, encoding='utf-8') + + strict_path = root / 'tests/strict_lifecycle_source_contract.cmake' + strict = strict_path.read_text(encoding='utf-8') + strict = replace_once(strict, + 'file(READ "${ENGINE_DIR}/vst3_engine.hpp" ENGINE_HEADER)', + 'file(READ "${ENGINE_DIR}/hosted_plugin.hpp" ENGINE_HEADER)', + 'strict lifecycle deep header') + strict = strict.replace('bool Vst3Engine::configure_buses', 'bool HostedPlugin::configure_buses') + strict = strict.replace('bool Vst3Engine::activate_configured_buses', 'bool HostedPlugin::activate_configured_buses') + strict_path.write_text(strict, encoding='utf-8') + + context_path = root / 'tests/process_context_source_contract.cmake' + context = context_path.read_text(encoding='utf-8') + context = context.replace('Vst3Engine::open', 'HostedPlugin::open') + context = context.replace('bool Vst3Engine::flush_parameter_changes() noexcept', + 'bool HostedPlugin::flush_parameter_changes() noexcept') + context = context.replace('bool Vst3Engine::process(AudioSlot& slot) noexcept', + 'bool HostedPlugin::process(const ProcessBlockView& block) noexcept') + context_path.write_text(context, encoding='utf-8') + + r01_path = root / 'tests/r0_1/CMakeLists.txt' + r01 = r01_path.read_text(encoding='utf-8') + r01 = replace_once(r01, + ' "${SAFEVST3_ROOT}/src/host/vst3_engine.cpp"\n', + ' "${SAFEVST3_ROOT}/src/host/hosted_plugin.cpp"\n "${SAFEVST3_ROOT}/src/host/vst3_engine.cpp"\n', + 'R0-1 deep+adapter sources') + r01_path.write_text(r01, encoding='utf-8') + + r02_path = root / 'tests/r0_2/CMakeLists.txt' + r02 = r02_path.read_text(encoding='utf-8') + r02 = replace_once(r02, ' "${SAFEVST3_ROOT}/src/host/vst3_engine.cpp"\n', '', + 'R0-2 must not link Single adapter') + r02 = replace_once(r02, + ' -DHEADER=${SAFEVST3_ROOT}/src/host/hosted_plugin.hpp\n', + ' -DHEADER=${SAFEVST3_ROOT}/src/host/hosted_plugin.hpp\n -DSOURCE=${SAFEVST3_ROOT}/src/host/hosted_plugin.cpp\n', + 'R0-2 deep source contract input') + r02_path.write_text(r02, encoding='utf-8') + + neutral_contract = '''if(NOT DEFINED HEADER OR NOT EXISTS "${HEADER}") + message(FATAL_ERROR "R0-2 HostedPlugin header is missing") +endif() +if(NOT DEFINED SOURCE OR NOT EXISTS "${SOURCE}") + message(FATAL_ERROR "R0-2 HostedPlugin source is missing") +endif() + +file(READ "${HEADER}" header_source) +file(READ "${SOURCE}" implementation_source) + +foreach(forbidden IN ITEMS "AudioSlot" "SharedAudioRegion" "common/protocol.hpp" "Vst3Engine") + string(FIND "${header_source}" "${forbidden}" header_position) + if(NOT header_position EQUAL -1) + message(FATAL_ERROR "HostedPlugin public seam must remain protocol-neutral; header contains forbidden token: ${forbidden}") + endif() + string(FIND "${implementation_source}" "${forbidden}" source_position) + if(NOT source_position EQUAL -1) + message(FATAL_ERROR "HostedPlugin deep implementation must remain outside Single transport; source contains forbidden token: ${forbidden}") + endif() +endforeach() + +string(FIND "${header_source}" "class HostedPlugin" class_position) +if(class_position EQUAL -1) + message(FATAL_ERROR "HostedPlugin public seam must declare class HostedPlugin") +endif() +string(FIND "${header_source}" "ProcessBlockView" process_view_position) +if(process_view_position EQUAL -1) + message(FATAL_ERROR "HostedPlugin public seam must expose ProcessBlockView processing") +endif() +string(FIND "${implementation_source}" "bool HostedPlugin::process(const ProcessBlockView& block) noexcept" deep_process_position) +if(deep_process_position EQUAL -1) + message(FATAL_ERROR "HostedPlugin must own the deep ProcessBlockView implementation") +endif() + +message(STATUS "R0-2 HostedPlugin deep seam is protocol-neutral and independent of the Single adapter") +''' + (root / 'tests/r0_2/hosted_plugin_protocol_neutral.cmake').write_text(neutral_contract, encoding='utf-8') + + readme_path = root / 'tests/r0_2/README.md' + readme = readme_path.read_text(encoding='utf-8') + readme = readme.replace( + '- public-header source contract rejecting Single transport types/layout includes.', + '- deep header/source contract rejecting Single transport types/layout includes and `Vst3Engine` dependency;\n- focused target links `HostedPlugin` without the Single `vst3_engine.cpp` adapter.') + readme_path.write_text(readme, encoding='utf-8') + + # The bootstrap is intentionally absent from the resulting source tree. + Path('.github/workflows/r0-2-deep-extract-bootstrap.yml').unlink() + + # Fail closed on structural invariants before committing. + require('AudioSlot' not in hosted_hpp_path.read_text(), 'final HostedPlugin header contains AudioSlot') + require('AudioSlot' not in hosted_cpp_path.read_text(), 'final HostedPlugin source contains AudioSlot') + require('Vst3Engine' not in hosted_cpp_path.read_text(), 'final HostedPlugin source contains Vst3Engine') + require('src/host/vst3_engine.cpp' not in r02_path.read_text(), 'R0-2 focused target still links Single adapter') + subprocess.run(['git', 'diff', '--check'], check=True) + PY + + - name: Commit bounded R0-2 architecture correction + shell: bash + run: | + git config user.name "Ari Sulistiono" + git config user.email "ari.sulistiono@gmail.com" + git add -A + git diff --cached --stat + git diff --cached --check + git commit -m "refactor(r0-2): make HostedPlugin the deep lifecycle owner" + git push origin HEAD:r0-2-hosted-plugin From 13863f153bedf1a066afc2af6f1feec13a3809bf Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 29 Aug 2026 08:50:46 +0700 Subject: [PATCH 08/11] ci(r0-2): trigger deep extraction --- .github/workflows/r0-2-deep-extract-push.yml | 30 ++ scripts/r0_2_deep_extract.py | 289 +++++++++++++++++++ 2 files changed, 319 insertions(+) create mode 100644 .github/workflows/r0-2-deep-extract-push.yml create mode 100644 scripts/r0_2_deep_extract.py diff --git a/.github/workflows/r0-2-deep-extract-push.yml b/.github/workflows/r0-2-deep-extract-push.yml new file mode 100644 index 0000000..91079a1 --- /dev/null +++ b/.github/workflows/r0-2-deep-extract-push.yml @@ -0,0 +1,30 @@ +name: R0-2 Deep Extraction Push Bootstrap + +on: + push: + branches: [r0-2-hosted-plugin] + +permissions: + contents: write + +jobs: + deep-extract: + if: github.event.head_commit.message == 'ci(r0-2): trigger deep extraction' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: r0-2-hosted-plugin + fetch-depth: 0 + - name: Transform exact audited source + run: python3 scripts/r0_2_deep_extract.py + - name: Commit corrected deep seam + shell: bash + run: | + git config user.name "Ari Sulistiono" + git config user.email "ari.sulistiono@gmail.com" + git add -A + git diff --cached --stat + git diff --cached --check + git commit -m "refactor(r0-2): make HostedPlugin the deep lifecycle owner" + git push origin HEAD:r0-2-hosted-plugin diff --git a/scripts/r0_2_deep_extract.py b/scripts/r0_2_deep_extract.py new file mode 100644 index 0000000..87477b1 --- /dev/null +++ b/scripts/r0_2_deep_extract.py @@ -0,0 +1,289 @@ +from pathlib import Path +import subprocess + + +def fail(message): + raise SystemExit(message) + + +def require(condition, message): + if not condition: + fail(message) + + +def replace_once(text, old, new, label): + count = text.count(old) + require(count == 1, f"{label}: expected one match, found {count}") + return text.replace(old, new, 1) + + +root = Path('.') +engine_cpp_path = root / 'src/host/vst3_engine.cpp' +engine_hpp_path = root / 'src/host/vst3_engine.hpp' +hosted_cpp_path = root / 'src/host/hosted_plugin.cpp' +hosted_hpp_path = root / 'src/host/hosted_plugin.hpp' + +blob = subprocess.check_output(['git', 'hash-object', str(engine_cpp_path)], text=True).strip() +require(blob == '9e92d2a74847db407d7a354d668b0cf921ef3e2b', + f'unexpected vst3_engine.cpp blob {blob}; refusing mechanical extraction') + +old_cpp = engine_cpp_path.read_text(encoding='utf-8') +old_hpp = engine_hpp_path.read_text(encoding='utf-8') + +contract = '''#pragma once + +#include +#include + +namespace safevst3 { + +inline constexpr std::uint32_t kMaxChannels = 2; +inline constexpr std::uint32_t kMaxFrames = 2048; +inline constexpr std::uint32_t kMaxParameters = 256; +inline constexpr std::size_t kMaxStateBytes = 16u * 1024u * 1024u; + +enum ParameterFlags : std::uint32_t { + ParameterCanAutomate = 1u << 0, + ParameterReadOnly = 1u << 1, + ParameterHidden = 1u << 2, + ParameterList = 1u << 3, + ParameterProgramChange = 1u << 4, + ParameterBypass = 1u << 5, +}; + +} // namespace safevst3 +''' +(root / 'src/common/vst3_host_contract.hpp').write_text(contract, encoding='utf-8') + +protocol_path = root / 'src/common/protocol.hpp' +protocol = protocol_path.read_text(encoding='utf-8') +protocol = replace_once(protocol, '#pragma once\n\n', + '#pragma once\n\n#include "common/vst3_host_contract.hpp"\n\n', + 'protocol host-contract include') +for line in ( + 'inline constexpr std::uint32_t kMaxChannels = 2;\n', + 'inline constexpr std::uint32_t kMaxFrames = 2048;\n', + 'inline constexpr std::uint32_t kMaxParameters = 256;\n', + 'inline constexpr std::size_t kMaxStateBytes = 16u * 1024u * 1024u;\n', +): + protocol = replace_once(protocol, line, '', f'remove duplicate {line.strip()}') +flags = '''enum ParameterFlags : std::uint32_t { + ParameterCanAutomate = 1u << 0, + ParameterReadOnly = 1u << 1, + ParameterHidden = 1u << 2, + ParameterList = 1u << 3, + ParameterProgramChange = 1u << 4, + ParameterBypass = 1u << 5, +}; + +''' +protocol = replace_once(protocol, flags, '', 'remove duplicate ParameterFlags') +protocol_path.write_text(protocol, encoding='utf-8') + +state_path = root / 'src/common/state_snapshot.hpp' +state = state_path.read_text(encoding='utf-8') +state = replace_once(state, '#include "common/protocol.hpp"', + '#include "common/vst3_host_contract.hpp"', + 'state snapshot host-contract include') +state_path.write_text(state, encoding='utf-8') + +deep_hpp = old_hpp.replace('#include "common/protocol.hpp"', + '#include "common/vst3_host_contract.hpp"') +deep_hpp = deep_hpp.replace('Vst3Engine', 'HostedPlugin') +deep_hpp = replace_once(deep_hpp, + 'class HostedPlugin final : public LatencyRestartTarget, public IoRestartLifecycleTarget {', + 'class HostedPlugin : public LatencyRestartTarget, public IoRestartLifecycleTarget {', + 'remove final from deep HostedPlugin') +deep_hpp = replace_once(deep_hpp, ' bool process(AudioSlot& slot) noexcept;\n', '', + 'remove Single AudioSlot method from HostedPlugin') +require('AudioSlot' not in deep_hpp, 'HostedPlugin header still references AudioSlot') +require('common/protocol.hpp' not in deep_hpp, 'HostedPlugin header still references Single protocol') +hosted_hpp_path.write_text(deep_hpp, encoding='utf-8') + +adapter_block = '''bool Vst3Engine::process(AudioSlot& slot) noexcept +{ + float* input[kMaxChannels] = {slot.input[0], slot.input[1]}; + float* output[kMaxChannels] = {slot.output[0], slot.output[1]}; + const ProcessBlockView block{ + input, + output, + slot.channels, + slot.frames, + slot.sequence, + }; + return process(block); +} + +''' +deep_cpp = replace_once(old_cpp, adapter_block, '', 'remove AudioSlot implementation from deep owner') +deep_cpp = replace_once(deep_cpp, '#include "host/vst3_engine.hpp"', + '#include "host/hosted_plugin.hpp"', + 'deep source include') +deep_cpp = deep_cpp.replace('Vst3Engine', 'HostedPlugin') +require('AudioSlot' not in deep_cpp, 'HostedPlugin source still references AudioSlot') +require('Vst3Engine' not in deep_cpp, 'HostedPlugin source still references Vst3Engine') +hosted_cpp_path.write_text(deep_cpp, encoding='utf-8') + +thin_hpp = '''#pragma once + +#ifdef _WIN32 + +#include "common/protocol.hpp" +#include "host/hosted_plugin.hpp" + +namespace safevst3 { + +// Single-transport compatibility adapter. All VST3 lifecycle/state/process +// ownership lives in HostedPlugin; this class only maps AudioSlot buffers into +// the protocol-neutral ProcessBlockView seam. +class Vst3Engine final : public HostedPlugin { +public: + Vst3Engine() = default; + ~Vst3Engine() = default; + + Vst3Engine(const Vst3Engine&) = delete; + Vst3Engine& operator=(const Vst3Engine&) = delete; + + using HostedPlugin::process; + bool process(AudioSlot& slot) noexcept; +}; + +} // namespace safevst3 + +#endif +''' +engine_hpp_path.write_text(thin_hpp, encoding='utf-8') + +thin_cpp = '''#ifdef _WIN32 + +#include "host/vst3_engine.hpp" + +namespace safevst3 { + +bool Vst3Engine::process(AudioSlot& slot) noexcept +{ + float* input[kMaxChannels] = {slot.input[0], slot.input[1]}; + float* output[kMaxChannels] = {slot.output[0], slot.output[1]}; + const ProcessBlockView block{ + input, + output, + slot.channels, + slot.frames, + slot.sequence, + }; + return HostedPlugin::process(block); +} + +} // namespace safevst3 + +#endif +''' +engine_cpp_path.write_text(thin_cpp, encoding='utf-8') + +root_cmake_path = root / 'CMakeLists.txt' +root_cmake = root_cmake_path.read_text(encoding='utf-8') +root_cmake = replace_once(root_cmake, + '-DSOURCE_FILE=${CMAKE_CURRENT_SOURCE_DIR}/src/host/vst3_engine.cpp', + '-DSOURCE_FILE=${CMAKE_CURRENT_SOURCE_DIR}/src/host/hosted_plugin.cpp', + 'process-context source-contract target') +root_cmake = replace_once(root_cmake, + '-DENGINE_SOURCE=${CMAKE_CURRENT_SOURCE_DIR}/src/host/vst3_engine.cpp', + '-DENGINE_SOURCE=${CMAKE_CURRENT_SOURCE_DIR}/src/host/hosted_plugin.cpp', + 'strict-lifecycle source-contract target') +root_cmake_path.write_text(root_cmake, encoding='utf-8') + +strict_path = root / 'tests/strict_lifecycle_source_contract.cmake' +strict = strict_path.read_text(encoding='utf-8') +strict = replace_once(strict, + 'file(READ "${ENGINE_DIR}/vst3_engine.hpp" ENGINE_HEADER)', + 'file(READ "${ENGINE_DIR}/hosted_plugin.hpp" ENGINE_HEADER)', + 'strict lifecycle deep header') +strict = strict.replace('bool Vst3Engine::configure_buses', 'bool HostedPlugin::configure_buses') +strict = strict.replace('bool Vst3Engine::activate_configured_buses', 'bool HostedPlugin::activate_configured_buses') +strict_path.write_text(strict, encoding='utf-8') + +context_path = root / 'tests/process_context_source_contract.cmake' +context = context_path.read_text(encoding='utf-8') +context = context.replace('Vst3Engine::open', 'HostedPlugin::open') +context = context.replace('bool Vst3Engine::flush_parameter_changes() noexcept', + 'bool HostedPlugin::flush_parameter_changes() noexcept') +context = context.replace('bool Vst3Engine::process(AudioSlot& slot) noexcept', + 'bool HostedPlugin::process(const ProcessBlockView& block) noexcept') +context_path.write_text(context, encoding='utf-8') + +r01_path = root / 'tests/r0_1/CMakeLists.txt' +r01 = r01_path.read_text(encoding='utf-8') +r01 = replace_once(r01, + ' "${SAFEVST3_ROOT}/src/host/vst3_engine.cpp"\n', + ' "${SAFEVST3_ROOT}/src/host/hosted_plugin.cpp"\n "${SAFEVST3_ROOT}/src/host/vst3_engine.cpp"\n', + 'R0-1 deep+adapter sources') +r01_path.write_text(r01, encoding='utf-8') + +r02_path = root / 'tests/r0_2/CMakeLists.txt' +r02 = r02_path.read_text(encoding='utf-8') +r02 = replace_once(r02, ' "${SAFEVST3_ROOT}/src/host/vst3_engine.cpp"\n', '', + 'R0-2 must not link Single adapter') +r02 = replace_once(r02, + ' -DHEADER=${SAFEVST3_ROOT}/src/host/hosted_plugin.hpp\n', + ' -DHEADER=${SAFEVST3_ROOT}/src/host/hosted_plugin.hpp\n -DSOURCE=${SAFEVST3_ROOT}/src/host/hosted_plugin.cpp\n', + 'R0-2 deep source contract input') +r02_path.write_text(r02, encoding='utf-8') + +neutral_contract = '''if(NOT DEFINED HEADER OR NOT EXISTS "${HEADER}") + message(FATAL_ERROR "R0-2 HostedPlugin header is missing") +endif() +if(NOT DEFINED SOURCE OR NOT EXISTS "${SOURCE}") + message(FATAL_ERROR "R0-2 HostedPlugin source is missing") +endif() + +file(READ "${HEADER}" header_source) +file(READ "${SOURCE}" implementation_source) + +foreach(forbidden IN ITEMS "AudioSlot" "SharedAudioRegion" "common/protocol.hpp" "Vst3Engine") + string(FIND "${header_source}" "${forbidden}" header_position) + if(NOT header_position EQUAL -1) + message(FATAL_ERROR "HostedPlugin public seam must remain protocol-neutral; header contains forbidden token: ${forbidden}") + endif() + string(FIND "${implementation_source}" "${forbidden}" source_position) + if(NOT source_position EQUAL -1) + message(FATAL_ERROR "HostedPlugin deep implementation must remain outside Single transport; source contains forbidden token: ${forbidden}") + endif() +endforeach() + +string(FIND "${header_source}" "class HostedPlugin" class_position) +if(class_position EQUAL -1) + message(FATAL_ERROR "HostedPlugin public seam must declare class HostedPlugin") +endif() +string(FIND "${header_source}" "ProcessBlockView" process_view_position) +if(process_view_position EQUAL -1) + message(FATAL_ERROR "HostedPlugin public seam must expose ProcessBlockView processing") +endif() +string(FIND "${implementation_source}" "bool HostedPlugin::process(const ProcessBlockView& block) noexcept" deep_process_position) +if(deep_process_position EQUAL -1) + message(FATAL_ERROR "HostedPlugin must own the deep ProcessBlockView implementation") +endif() + +message(STATUS "R0-2 HostedPlugin deep seam is protocol-neutral and independent of the Single adapter") +''' +(root / 'tests/r0_2/hosted_plugin_protocol_neutral.cmake').write_text(neutral_contract, encoding='utf-8') + +readme_path = root / 'tests/r0_2/README.md' +readme = readme_path.read_text(encoding='utf-8') +readme = readme.replace( + '- public-header source contract rejecting Single transport types/layout includes.', + '- deep header/source contract rejecting Single transport types/layout includes and `Vst3Engine` dependency;\n- focused target links `HostedPlugin` without the Single `vst3_engine.cpp` adapter.') +readme_path.write_text(readme, encoding='utf-8') + +for bootstrap in ( + Path('.github/workflows/r0-2-deep-extract-bootstrap.yml'), + Path('.github/workflows/r0-2-deep-extract-push.yml'), + Path('scripts/r0_2_deep_extract.py'), +): + if bootstrap.exists(): + bootstrap.unlink() + +require('AudioSlot' not in hosted_hpp_path.read_text(), 'final HostedPlugin header contains AudioSlot') +require('AudioSlot' not in hosted_cpp_path.read_text(), 'final HostedPlugin source contains AudioSlot') +require('Vst3Engine' not in hosted_cpp_path.read_text(), 'final HostedPlugin source contains Vst3Engine') +require('src/host/vst3_engine.cpp' not in r02_path.read_text(), 'R0-2 focused target still links Single adapter') +subprocess.run(['git', 'diff', '--check'], check=True) From 5154c8b91c0da7a7453bef3c50fda9029e8851e4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 29 Aug 2026 08:51:54 +0700 Subject: [PATCH 09/11] ci(r0-2): fix deep extraction bootstrap syntax --- .github/workflows/r0-2-deep-extract-push.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/r0-2-deep-extract-push.yml b/.github/workflows/r0-2-deep-extract-push.yml index 91079a1..aeaa999 100644 --- a/.github/workflows/r0-2-deep-extract-push.yml +++ b/.github/workflows/r0-2-deep-extract-push.yml @@ -9,7 +9,6 @@ permissions: jobs: deep-extract: - if: github.event.head_commit.message == 'ci(r0-2): trigger deep extraction' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 From 56b86d63c1b83da6cdba07399d249c93e156ba0b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 29 Aug 2026 01:52:09 +0000 Subject: [PATCH 10/11] refactor(r0-2): make HostedPlugin the deep lifecycle owner --- .../workflows/r0-2-deep-extract-bootstrap.yml | 324 ----- .github/workflows/r0-2-deep-extract-push.yml | 29 - CMakeLists.txt | 4 +- scripts/r0_2_deep_extract.py | 289 ---- src/common/protocol.hpp | 15 +- src/common/state_snapshot.hpp | 2 +- src/common/vst3_host_contract.hpp | 22 + src/host/hosted_plugin.cpp | 1193 +++++++++++++++- src/host/hosted_plugin.hpp | 167 ++- src/host/vst3_engine.cpp | 1212 +---------------- src/host/vst3_engine.hpp | 178 +-- tests/process_context_source_contract.cmake | 10 +- tests/r0_1/CMakeLists.txt | 1 + tests/r0_2/CMakeLists.txt | 2 +- tests/r0_2/README.md | 3 +- .../r0_2/hosted_plugin_protocol_neutral.cmake | 29 +- tests/strict_lifecycle_source_contract.cmake | 6 +- 17 files changed, 1329 insertions(+), 2157 deletions(-) delete mode 100644 .github/workflows/r0-2-deep-extract-bootstrap.yml delete mode 100644 .github/workflows/r0-2-deep-extract-push.yml delete mode 100644 scripts/r0_2_deep_extract.py create mode 100644 src/common/vst3_host_contract.hpp diff --git a/.github/workflows/r0-2-deep-extract-bootstrap.yml b/.github/workflows/r0-2-deep-extract-bootstrap.yml deleted file mode 100644 index 6adac42..0000000 --- a/.github/workflows/r0-2-deep-extract-bootstrap.yml +++ /dev/null @@ -1,324 +0,0 @@ -name: R0-2 Deep Extraction Bootstrap - -on: - pull_request: - branches: [main] - paths: - - '.github/workflows/r0-2-deep-extract-bootstrap.yml' - -permissions: - contents: write - -jobs: - deep-extract: - if: github.event.pull_request.head.repo.full_name == github.repository && github.head_ref == 'r0-2-hosted-plugin' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.head_ref }} - fetch-depth: 0 - - - name: Mechanically move deep one-VST3 ownership into HostedPlugin - shell: bash - run: | - python3 - <<'PY' - from pathlib import Path - import subprocess - - def fail(message): - raise SystemExit(message) - - def require(condition, message): - if not condition: - fail(message) - - def replace_once(text, old, new, label): - count = text.count(old) - require(count == 1, f"{label}: expected one match, found {count}") - return text.replace(old, new, 1) - - root = Path('.') - engine_cpp_path = root / 'src/host/vst3_engine.cpp' - engine_hpp_path = root / 'src/host/vst3_engine.hpp' - hosted_cpp_path = root / 'src/host/hosted_plugin.cpp' - hosted_hpp_path = root / 'src/host/hosted_plugin.hpp' - - blob = subprocess.check_output(['git', 'hash-object', str(engine_cpp_path)], text=True).strip() - require(blob == '9e92d2a74847db407d7a354d668b0cf921ef3e2b', - f'unexpected vst3_engine.cpp blob {blob}; refusing mechanical extraction') - - old_cpp = engine_cpp_path.read_text(encoding='utf-8') - old_hpp = engine_hpp_path.read_text(encoding='utf-8') - - # Protocol-neutral one-plugin bounds/policy. Values are copied exactly - # from the existing Single protocol; serialized layout/version do not change. - contract = '''#pragma once - -#include -#include - -namespace safevst3 { - -inline constexpr std::uint32_t kMaxChannels = 2; -inline constexpr std::uint32_t kMaxFrames = 2048; -inline constexpr std::uint32_t kMaxParameters = 256; -inline constexpr std::size_t kMaxStateBytes = 16u * 1024u * 1024u; - -enum ParameterFlags : std::uint32_t { - ParameterCanAutomate = 1u << 0, - ParameterReadOnly = 1u << 1, - ParameterHidden = 1u << 2, - ParameterList = 1u << 3, - ParameterProgramChange = 1u << 4, - ParameterBypass = 1u << 5, -}; - -} // namespace safevst3 -''' - (root / 'src/common/vst3_host_contract.hpp').write_text(contract, encoding='utf-8') - - protocol_path = root / 'src/common/protocol.hpp' - protocol = protocol_path.read_text(encoding='utf-8') - protocol = replace_once(protocol, '#pragma once\n\n', - '#pragma once\n\n#include "common/vst3_host_contract.hpp"\n\n', - 'protocol host-contract include') - for line in ( - 'inline constexpr std::uint32_t kMaxChannels = 2;\n', - 'inline constexpr std::uint32_t kMaxFrames = 2048;\n', - 'inline constexpr std::uint32_t kMaxParameters = 256;\n', - 'inline constexpr std::size_t kMaxStateBytes = 16u * 1024u * 1024u;\n', - ): - protocol = replace_once(protocol, line, '', f'remove duplicate {line.strip()}') - flags = '''enum ParameterFlags : std::uint32_t { - ParameterCanAutomate = 1u << 0, - ParameterReadOnly = 1u << 1, - ParameterHidden = 1u << 2, - ParameterList = 1u << 3, - ParameterProgramChange = 1u << 4, - ParameterBypass = 1u << 5, -}; - -''' - protocol = replace_once(protocol, flags, '', 'remove duplicate ParameterFlags') - protocol_path.write_text(protocol, encoding='utf-8') - - state_path = root / 'src/common/state_snapshot.hpp' - state = state_path.read_text(encoding='utf-8') - state = replace_once(state, '#include "common/protocol.hpp"', - '#include "common/vst3_host_contract.hpp"', - 'state snapshot host-contract include') - state_path.write_text(state, encoding='utf-8') - - # Deep owner declaration: mechanically preserve every proven VST3 - # responsibility, but remove the Single AudioSlot overload/dependency. - deep_hpp = old_hpp.replace('#include "common/protocol.hpp"', - '#include "common/vst3_host_contract.hpp"') - deep_hpp = deep_hpp.replace('Vst3Engine', 'HostedPlugin') - deep_hpp = replace_once(deep_hpp, - 'class HostedPlugin final : public LatencyRestartTarget, public IoRestartLifecycleTarget {', - 'class HostedPlugin : public LatencyRestartTarget, public IoRestartLifecycleTarget {', - 'remove final from deep HostedPlugin') - deep_hpp = replace_once(deep_hpp, ' bool process(AudioSlot& slot) noexcept;\n', '', - 'remove Single AudioSlot method from HostedPlugin') - require('AudioSlot' not in deep_hpp, 'HostedPlugin header still references AudioSlot') - require('common/protocol.hpp' not in deep_hpp, 'HostedPlugin header still references Single protocol') - hosted_hpp_path.write_text(deep_hpp, encoding='utf-8') - - adapter_block = '''bool Vst3Engine::process(AudioSlot& slot) noexcept -{ - float* input[kMaxChannels] = {slot.input[0], slot.input[1]}; - float* output[kMaxChannels] = {slot.output[0], slot.output[1]}; - const ProcessBlockView block{ - input, - output, - slot.channels, - slot.frames, - slot.sequence, - }; - return process(block); -} - -''' - deep_cpp = replace_once(old_cpp, adapter_block, '', 'remove AudioSlot implementation from deep owner') - deep_cpp = replace_once(deep_cpp, '#include "host/vst3_engine.hpp"', - '#include "host/hosted_plugin.hpp"', - 'deep source include') - deep_cpp = deep_cpp.replace('Vst3Engine', 'HostedPlugin') - require('AudioSlot' not in deep_cpp, 'HostedPlugin source still references AudioSlot') - require('Vst3Engine' not in deep_cpp, 'HostedPlugin source still references Vst3Engine') - hosted_cpp_path.write_text(deep_cpp, encoding='utf-8') - - thin_hpp = '''#pragma once - -#ifdef _WIN32 - -#include "common/protocol.hpp" -#include "host/hosted_plugin.hpp" - -namespace safevst3 { - -// Single-transport compatibility adapter. All VST3 lifecycle/state/process -// ownership lives in HostedPlugin; this class only maps AudioSlot buffers into -// the protocol-neutral ProcessBlockView seam. -class Vst3Engine final : public HostedPlugin { -public: - Vst3Engine() = default; - ~Vst3Engine() = default; - - Vst3Engine(const Vst3Engine&) = delete; - Vst3Engine& operator=(const Vst3Engine&) = delete; - - using HostedPlugin::process; - bool process(AudioSlot& slot) noexcept; -}; - -} // namespace safevst3 - -#endif -''' - engine_hpp_path.write_text(thin_hpp, encoding='utf-8') - - thin_cpp = '''#ifdef _WIN32 - -#include "host/vst3_engine.hpp" - -namespace safevst3 { - -bool Vst3Engine::process(AudioSlot& slot) noexcept -{ - float* input[kMaxChannels] = {slot.input[0], slot.input[1]}; - float* output[kMaxChannels] = {slot.output[0], slot.output[1]}; - const ProcessBlockView block{ - input, - output, - slot.channels, - slot.frames, - slot.sequence, - }; - return HostedPlugin::process(block); -} - -} // namespace safevst3 - -#endif -''' - engine_cpp_path.write_text(thin_cpp, encoding='utf-8') - - # Existing source-contracts remain mandatory; retarget them to the - # deep implementation and rename only class-qualified markers. - root_cmake_path = root / 'CMakeLists.txt' - root_cmake = root_cmake_path.read_text(encoding='utf-8') - root_cmake = replace_once(root_cmake, - '-DSOURCE_FILE=${CMAKE_CURRENT_SOURCE_DIR}/src/host/vst3_engine.cpp', - '-DSOURCE_FILE=${CMAKE_CURRENT_SOURCE_DIR}/src/host/hosted_plugin.cpp', - 'process-context source-contract target') - root_cmake = replace_once(root_cmake, - '-DENGINE_SOURCE=${CMAKE_CURRENT_SOURCE_DIR}/src/host/vst3_engine.cpp', - '-DENGINE_SOURCE=${CMAKE_CURRENT_SOURCE_DIR}/src/host/hosted_plugin.cpp', - 'strict-lifecycle source-contract target') - root_cmake_path.write_text(root_cmake, encoding='utf-8') - - strict_path = root / 'tests/strict_lifecycle_source_contract.cmake' - strict = strict_path.read_text(encoding='utf-8') - strict = replace_once(strict, - 'file(READ "${ENGINE_DIR}/vst3_engine.hpp" ENGINE_HEADER)', - 'file(READ "${ENGINE_DIR}/hosted_plugin.hpp" ENGINE_HEADER)', - 'strict lifecycle deep header') - strict = strict.replace('bool Vst3Engine::configure_buses', 'bool HostedPlugin::configure_buses') - strict = strict.replace('bool Vst3Engine::activate_configured_buses', 'bool HostedPlugin::activate_configured_buses') - strict_path.write_text(strict, encoding='utf-8') - - context_path = root / 'tests/process_context_source_contract.cmake' - context = context_path.read_text(encoding='utf-8') - context = context.replace('Vst3Engine::open', 'HostedPlugin::open') - context = context.replace('bool Vst3Engine::flush_parameter_changes() noexcept', - 'bool HostedPlugin::flush_parameter_changes() noexcept') - context = context.replace('bool Vst3Engine::process(AudioSlot& slot) noexcept', - 'bool HostedPlugin::process(const ProcessBlockView& block) noexcept') - context_path.write_text(context, encoding='utf-8') - - r01_path = root / 'tests/r0_1/CMakeLists.txt' - r01 = r01_path.read_text(encoding='utf-8') - r01 = replace_once(r01, - ' "${SAFEVST3_ROOT}/src/host/vst3_engine.cpp"\n', - ' "${SAFEVST3_ROOT}/src/host/hosted_plugin.cpp"\n "${SAFEVST3_ROOT}/src/host/vst3_engine.cpp"\n', - 'R0-1 deep+adapter sources') - r01_path.write_text(r01, encoding='utf-8') - - r02_path = root / 'tests/r0_2/CMakeLists.txt' - r02 = r02_path.read_text(encoding='utf-8') - r02 = replace_once(r02, ' "${SAFEVST3_ROOT}/src/host/vst3_engine.cpp"\n', '', - 'R0-2 must not link Single adapter') - r02 = replace_once(r02, - ' -DHEADER=${SAFEVST3_ROOT}/src/host/hosted_plugin.hpp\n', - ' -DHEADER=${SAFEVST3_ROOT}/src/host/hosted_plugin.hpp\n -DSOURCE=${SAFEVST3_ROOT}/src/host/hosted_plugin.cpp\n', - 'R0-2 deep source contract input') - r02_path.write_text(r02, encoding='utf-8') - - neutral_contract = '''if(NOT DEFINED HEADER OR NOT EXISTS "${HEADER}") - message(FATAL_ERROR "R0-2 HostedPlugin header is missing") -endif() -if(NOT DEFINED SOURCE OR NOT EXISTS "${SOURCE}") - message(FATAL_ERROR "R0-2 HostedPlugin source is missing") -endif() - -file(READ "${HEADER}" header_source) -file(READ "${SOURCE}" implementation_source) - -foreach(forbidden IN ITEMS "AudioSlot" "SharedAudioRegion" "common/protocol.hpp" "Vst3Engine") - string(FIND "${header_source}" "${forbidden}" header_position) - if(NOT header_position EQUAL -1) - message(FATAL_ERROR "HostedPlugin public seam must remain protocol-neutral; header contains forbidden token: ${forbidden}") - endif() - string(FIND "${implementation_source}" "${forbidden}" source_position) - if(NOT source_position EQUAL -1) - message(FATAL_ERROR "HostedPlugin deep implementation must remain outside Single transport; source contains forbidden token: ${forbidden}") - endif() -endforeach() - -string(FIND "${header_source}" "class HostedPlugin" class_position) -if(class_position EQUAL -1) - message(FATAL_ERROR "HostedPlugin public seam must declare class HostedPlugin") -endif() -string(FIND "${header_source}" "ProcessBlockView" process_view_position) -if(process_view_position EQUAL -1) - message(FATAL_ERROR "HostedPlugin public seam must expose ProcessBlockView processing") -endif() -string(FIND "${implementation_source}" "bool HostedPlugin::process(const ProcessBlockView& block) noexcept" deep_process_position) -if(deep_process_position EQUAL -1) - message(FATAL_ERROR "HostedPlugin must own the deep ProcessBlockView implementation") -endif() - -message(STATUS "R0-2 HostedPlugin deep seam is protocol-neutral and independent of the Single adapter") -''' - (root / 'tests/r0_2/hosted_plugin_protocol_neutral.cmake').write_text(neutral_contract, encoding='utf-8') - - readme_path = root / 'tests/r0_2/README.md' - readme = readme_path.read_text(encoding='utf-8') - readme = readme.replace( - '- public-header source contract rejecting Single transport types/layout includes.', - '- deep header/source contract rejecting Single transport types/layout includes and `Vst3Engine` dependency;\n- focused target links `HostedPlugin` without the Single `vst3_engine.cpp` adapter.') - readme_path.write_text(readme, encoding='utf-8') - - # The bootstrap is intentionally absent from the resulting source tree. - Path('.github/workflows/r0-2-deep-extract-bootstrap.yml').unlink() - - # Fail closed on structural invariants before committing. - require('AudioSlot' not in hosted_hpp_path.read_text(), 'final HostedPlugin header contains AudioSlot') - require('AudioSlot' not in hosted_cpp_path.read_text(), 'final HostedPlugin source contains AudioSlot') - require('Vst3Engine' not in hosted_cpp_path.read_text(), 'final HostedPlugin source contains Vst3Engine') - require('src/host/vst3_engine.cpp' not in r02_path.read_text(), 'R0-2 focused target still links Single adapter') - subprocess.run(['git', 'diff', '--check'], check=True) - PY - - - name: Commit bounded R0-2 architecture correction - shell: bash - run: | - git config user.name "Ari Sulistiono" - git config user.email "ari.sulistiono@gmail.com" - git add -A - git diff --cached --stat - git diff --cached --check - git commit -m "refactor(r0-2): make HostedPlugin the deep lifecycle owner" - git push origin HEAD:r0-2-hosted-plugin diff --git a/.github/workflows/r0-2-deep-extract-push.yml b/.github/workflows/r0-2-deep-extract-push.yml deleted file mode 100644 index aeaa999..0000000 --- a/.github/workflows/r0-2-deep-extract-push.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: R0-2 Deep Extraction Push Bootstrap - -on: - push: - branches: [r0-2-hosted-plugin] - -permissions: - contents: write - -jobs: - deep-extract: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: r0-2-hosted-plugin - fetch-depth: 0 - - name: Transform exact audited source - run: python3 scripts/r0_2_deep_extract.py - - name: Commit corrected deep seam - shell: bash - run: | - git config user.name "Ari Sulistiono" - git config user.email "ari.sulistiono@gmail.com" - git add -A - git diff --cached --stat - git diff --cached --check - git commit -m "refactor(r0-2): make HostedPlugin the deep lifecycle owner" - git push origin HEAD:r0-2-hosted-plugin diff --git a/CMakeLists.txt b/CMakeLists.txt index bc11c81..037e45a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -207,14 +207,14 @@ if(SAFEVST3_BUILD_TESTS) add_test( NAME process-context-source-contract COMMAND ${CMAKE_COMMAND} - -DSOURCE_FILE=${CMAKE_CURRENT_SOURCE_DIR}/src/host/vst3_engine.cpp + -DSOURCE_FILE=${CMAKE_CURRENT_SOURCE_DIR}/src/host/hosted_plugin.cpp -P ${CMAKE_CURRENT_SOURCE_DIR}/tests/process_context_source_contract.cmake ) add_test( NAME strict-lifecycle-source-contract COMMAND ${CMAKE_COMMAND} - -DENGINE_SOURCE=${CMAKE_CURRENT_SOURCE_DIR}/src/host/vst3_engine.cpp + -DENGINE_SOURCE=${CMAKE_CURRENT_SOURCE_DIR}/src/host/hosted_plugin.cpp -DMAIN_SOURCE=${CMAKE_CURRENT_SOURCE_DIR}/src/host/main.cpp -DWIN_IPC_SOURCE=${CMAKE_CURRENT_SOURCE_DIR}/src/platform/windows/win_ipc.cpp -P ${CMAKE_CURRENT_SOURCE_DIR}/tests/strict_lifecycle_source_contract.cmake diff --git a/scripts/r0_2_deep_extract.py b/scripts/r0_2_deep_extract.py deleted file mode 100644 index 87477b1..0000000 --- a/scripts/r0_2_deep_extract.py +++ /dev/null @@ -1,289 +0,0 @@ -from pathlib import Path -import subprocess - - -def fail(message): - raise SystemExit(message) - - -def require(condition, message): - if not condition: - fail(message) - - -def replace_once(text, old, new, label): - count = text.count(old) - require(count == 1, f"{label}: expected one match, found {count}") - return text.replace(old, new, 1) - - -root = Path('.') -engine_cpp_path = root / 'src/host/vst3_engine.cpp' -engine_hpp_path = root / 'src/host/vst3_engine.hpp' -hosted_cpp_path = root / 'src/host/hosted_plugin.cpp' -hosted_hpp_path = root / 'src/host/hosted_plugin.hpp' - -blob = subprocess.check_output(['git', 'hash-object', str(engine_cpp_path)], text=True).strip() -require(blob == '9e92d2a74847db407d7a354d668b0cf921ef3e2b', - f'unexpected vst3_engine.cpp blob {blob}; refusing mechanical extraction') - -old_cpp = engine_cpp_path.read_text(encoding='utf-8') -old_hpp = engine_hpp_path.read_text(encoding='utf-8') - -contract = '''#pragma once - -#include -#include - -namespace safevst3 { - -inline constexpr std::uint32_t kMaxChannels = 2; -inline constexpr std::uint32_t kMaxFrames = 2048; -inline constexpr std::uint32_t kMaxParameters = 256; -inline constexpr std::size_t kMaxStateBytes = 16u * 1024u * 1024u; - -enum ParameterFlags : std::uint32_t { - ParameterCanAutomate = 1u << 0, - ParameterReadOnly = 1u << 1, - ParameterHidden = 1u << 2, - ParameterList = 1u << 3, - ParameterProgramChange = 1u << 4, - ParameterBypass = 1u << 5, -}; - -} // namespace safevst3 -''' -(root / 'src/common/vst3_host_contract.hpp').write_text(contract, encoding='utf-8') - -protocol_path = root / 'src/common/protocol.hpp' -protocol = protocol_path.read_text(encoding='utf-8') -protocol = replace_once(protocol, '#pragma once\n\n', - '#pragma once\n\n#include "common/vst3_host_contract.hpp"\n\n', - 'protocol host-contract include') -for line in ( - 'inline constexpr std::uint32_t kMaxChannels = 2;\n', - 'inline constexpr std::uint32_t kMaxFrames = 2048;\n', - 'inline constexpr std::uint32_t kMaxParameters = 256;\n', - 'inline constexpr std::size_t kMaxStateBytes = 16u * 1024u * 1024u;\n', -): - protocol = replace_once(protocol, line, '', f'remove duplicate {line.strip()}') -flags = '''enum ParameterFlags : std::uint32_t { - ParameterCanAutomate = 1u << 0, - ParameterReadOnly = 1u << 1, - ParameterHidden = 1u << 2, - ParameterList = 1u << 3, - ParameterProgramChange = 1u << 4, - ParameterBypass = 1u << 5, -}; - -''' -protocol = replace_once(protocol, flags, '', 'remove duplicate ParameterFlags') -protocol_path.write_text(protocol, encoding='utf-8') - -state_path = root / 'src/common/state_snapshot.hpp' -state = state_path.read_text(encoding='utf-8') -state = replace_once(state, '#include "common/protocol.hpp"', - '#include "common/vst3_host_contract.hpp"', - 'state snapshot host-contract include') -state_path.write_text(state, encoding='utf-8') - -deep_hpp = old_hpp.replace('#include "common/protocol.hpp"', - '#include "common/vst3_host_contract.hpp"') -deep_hpp = deep_hpp.replace('Vst3Engine', 'HostedPlugin') -deep_hpp = replace_once(deep_hpp, - 'class HostedPlugin final : public LatencyRestartTarget, public IoRestartLifecycleTarget {', - 'class HostedPlugin : public LatencyRestartTarget, public IoRestartLifecycleTarget {', - 'remove final from deep HostedPlugin') -deep_hpp = replace_once(deep_hpp, ' bool process(AudioSlot& slot) noexcept;\n', '', - 'remove Single AudioSlot method from HostedPlugin') -require('AudioSlot' not in deep_hpp, 'HostedPlugin header still references AudioSlot') -require('common/protocol.hpp' not in deep_hpp, 'HostedPlugin header still references Single protocol') -hosted_hpp_path.write_text(deep_hpp, encoding='utf-8') - -adapter_block = '''bool Vst3Engine::process(AudioSlot& slot) noexcept -{ - float* input[kMaxChannels] = {slot.input[0], slot.input[1]}; - float* output[kMaxChannels] = {slot.output[0], slot.output[1]}; - const ProcessBlockView block{ - input, - output, - slot.channels, - slot.frames, - slot.sequence, - }; - return process(block); -} - -''' -deep_cpp = replace_once(old_cpp, adapter_block, '', 'remove AudioSlot implementation from deep owner') -deep_cpp = replace_once(deep_cpp, '#include "host/vst3_engine.hpp"', - '#include "host/hosted_plugin.hpp"', - 'deep source include') -deep_cpp = deep_cpp.replace('Vst3Engine', 'HostedPlugin') -require('AudioSlot' not in deep_cpp, 'HostedPlugin source still references AudioSlot') -require('Vst3Engine' not in deep_cpp, 'HostedPlugin source still references Vst3Engine') -hosted_cpp_path.write_text(deep_cpp, encoding='utf-8') - -thin_hpp = '''#pragma once - -#ifdef _WIN32 - -#include "common/protocol.hpp" -#include "host/hosted_plugin.hpp" - -namespace safevst3 { - -// Single-transport compatibility adapter. All VST3 lifecycle/state/process -// ownership lives in HostedPlugin; this class only maps AudioSlot buffers into -// the protocol-neutral ProcessBlockView seam. -class Vst3Engine final : public HostedPlugin { -public: - Vst3Engine() = default; - ~Vst3Engine() = default; - - Vst3Engine(const Vst3Engine&) = delete; - Vst3Engine& operator=(const Vst3Engine&) = delete; - - using HostedPlugin::process; - bool process(AudioSlot& slot) noexcept; -}; - -} // namespace safevst3 - -#endif -''' -engine_hpp_path.write_text(thin_hpp, encoding='utf-8') - -thin_cpp = '''#ifdef _WIN32 - -#include "host/vst3_engine.hpp" - -namespace safevst3 { - -bool Vst3Engine::process(AudioSlot& slot) noexcept -{ - float* input[kMaxChannels] = {slot.input[0], slot.input[1]}; - float* output[kMaxChannels] = {slot.output[0], slot.output[1]}; - const ProcessBlockView block{ - input, - output, - slot.channels, - slot.frames, - slot.sequence, - }; - return HostedPlugin::process(block); -} - -} // namespace safevst3 - -#endif -''' -engine_cpp_path.write_text(thin_cpp, encoding='utf-8') - -root_cmake_path = root / 'CMakeLists.txt' -root_cmake = root_cmake_path.read_text(encoding='utf-8') -root_cmake = replace_once(root_cmake, - '-DSOURCE_FILE=${CMAKE_CURRENT_SOURCE_DIR}/src/host/vst3_engine.cpp', - '-DSOURCE_FILE=${CMAKE_CURRENT_SOURCE_DIR}/src/host/hosted_plugin.cpp', - 'process-context source-contract target') -root_cmake = replace_once(root_cmake, - '-DENGINE_SOURCE=${CMAKE_CURRENT_SOURCE_DIR}/src/host/vst3_engine.cpp', - '-DENGINE_SOURCE=${CMAKE_CURRENT_SOURCE_DIR}/src/host/hosted_plugin.cpp', - 'strict-lifecycle source-contract target') -root_cmake_path.write_text(root_cmake, encoding='utf-8') - -strict_path = root / 'tests/strict_lifecycle_source_contract.cmake' -strict = strict_path.read_text(encoding='utf-8') -strict = replace_once(strict, - 'file(READ "${ENGINE_DIR}/vst3_engine.hpp" ENGINE_HEADER)', - 'file(READ "${ENGINE_DIR}/hosted_plugin.hpp" ENGINE_HEADER)', - 'strict lifecycle deep header') -strict = strict.replace('bool Vst3Engine::configure_buses', 'bool HostedPlugin::configure_buses') -strict = strict.replace('bool Vst3Engine::activate_configured_buses', 'bool HostedPlugin::activate_configured_buses') -strict_path.write_text(strict, encoding='utf-8') - -context_path = root / 'tests/process_context_source_contract.cmake' -context = context_path.read_text(encoding='utf-8') -context = context.replace('Vst3Engine::open', 'HostedPlugin::open') -context = context.replace('bool Vst3Engine::flush_parameter_changes() noexcept', - 'bool HostedPlugin::flush_parameter_changes() noexcept') -context = context.replace('bool Vst3Engine::process(AudioSlot& slot) noexcept', - 'bool HostedPlugin::process(const ProcessBlockView& block) noexcept') -context_path.write_text(context, encoding='utf-8') - -r01_path = root / 'tests/r0_1/CMakeLists.txt' -r01 = r01_path.read_text(encoding='utf-8') -r01 = replace_once(r01, - ' "${SAFEVST3_ROOT}/src/host/vst3_engine.cpp"\n', - ' "${SAFEVST3_ROOT}/src/host/hosted_plugin.cpp"\n "${SAFEVST3_ROOT}/src/host/vst3_engine.cpp"\n', - 'R0-1 deep+adapter sources') -r01_path.write_text(r01, encoding='utf-8') - -r02_path = root / 'tests/r0_2/CMakeLists.txt' -r02 = r02_path.read_text(encoding='utf-8') -r02 = replace_once(r02, ' "${SAFEVST3_ROOT}/src/host/vst3_engine.cpp"\n', '', - 'R0-2 must not link Single adapter') -r02 = replace_once(r02, - ' -DHEADER=${SAFEVST3_ROOT}/src/host/hosted_plugin.hpp\n', - ' -DHEADER=${SAFEVST3_ROOT}/src/host/hosted_plugin.hpp\n -DSOURCE=${SAFEVST3_ROOT}/src/host/hosted_plugin.cpp\n', - 'R0-2 deep source contract input') -r02_path.write_text(r02, encoding='utf-8') - -neutral_contract = '''if(NOT DEFINED HEADER OR NOT EXISTS "${HEADER}") - message(FATAL_ERROR "R0-2 HostedPlugin header is missing") -endif() -if(NOT DEFINED SOURCE OR NOT EXISTS "${SOURCE}") - message(FATAL_ERROR "R0-2 HostedPlugin source is missing") -endif() - -file(READ "${HEADER}" header_source) -file(READ "${SOURCE}" implementation_source) - -foreach(forbidden IN ITEMS "AudioSlot" "SharedAudioRegion" "common/protocol.hpp" "Vst3Engine") - string(FIND "${header_source}" "${forbidden}" header_position) - if(NOT header_position EQUAL -1) - message(FATAL_ERROR "HostedPlugin public seam must remain protocol-neutral; header contains forbidden token: ${forbidden}") - endif() - string(FIND "${implementation_source}" "${forbidden}" source_position) - if(NOT source_position EQUAL -1) - message(FATAL_ERROR "HostedPlugin deep implementation must remain outside Single transport; source contains forbidden token: ${forbidden}") - endif() -endforeach() - -string(FIND "${header_source}" "class HostedPlugin" class_position) -if(class_position EQUAL -1) - message(FATAL_ERROR "HostedPlugin public seam must declare class HostedPlugin") -endif() -string(FIND "${header_source}" "ProcessBlockView" process_view_position) -if(process_view_position EQUAL -1) - message(FATAL_ERROR "HostedPlugin public seam must expose ProcessBlockView processing") -endif() -string(FIND "${implementation_source}" "bool HostedPlugin::process(const ProcessBlockView& block) noexcept" deep_process_position) -if(deep_process_position EQUAL -1) - message(FATAL_ERROR "HostedPlugin must own the deep ProcessBlockView implementation") -endif() - -message(STATUS "R0-2 HostedPlugin deep seam is protocol-neutral and independent of the Single adapter") -''' -(root / 'tests/r0_2/hosted_plugin_protocol_neutral.cmake').write_text(neutral_contract, encoding='utf-8') - -readme_path = root / 'tests/r0_2/README.md' -readme = readme_path.read_text(encoding='utf-8') -readme = readme.replace( - '- public-header source contract rejecting Single transport types/layout includes.', - '- deep header/source contract rejecting Single transport types/layout includes and `Vst3Engine` dependency;\n- focused target links `HostedPlugin` without the Single `vst3_engine.cpp` adapter.') -readme_path.write_text(readme, encoding='utf-8') - -for bootstrap in ( - Path('.github/workflows/r0-2-deep-extract-bootstrap.yml'), - Path('.github/workflows/r0-2-deep-extract-push.yml'), - Path('scripts/r0_2_deep_extract.py'), -): - if bootstrap.exists(): - bootstrap.unlink() - -require('AudioSlot' not in hosted_hpp_path.read_text(), 'final HostedPlugin header contains AudioSlot') -require('AudioSlot' not in hosted_cpp_path.read_text(), 'final HostedPlugin source contains AudioSlot') -require('Vst3Engine' not in hosted_cpp_path.read_text(), 'final HostedPlugin source contains Vst3Engine') -require('src/host/vst3_engine.cpp' not in r02_path.read_text(), 'R0-2 focused target still links Single adapter') -subprocess.run(['git', 'diff', '--check'], check=True) diff --git a/src/common/protocol.hpp b/src/common/protocol.hpp index 9b6f62d..ec1a99f 100644 --- a/src/common/protocol.hpp +++ b/src/common/protocol.hpp @@ -1,5 +1,7 @@ #pragma once +#include "common/vst3_host_contract.hpp" + #include #include @@ -9,14 +11,10 @@ inline constexpr std::uint32_t kProtocolMagic = 0x3356534Fu; // "OSV3" inline constexpr std::uint32_t kProtocolVersion = 8; inline constexpr std::uint32_t kStateTransferMagic = 0x3154534Fu; // "OST1" inline constexpr std::uint32_t kStateTransferVersion = 1; -inline constexpr std::uint32_t kMaxChannels = 2; -inline constexpr std::uint32_t kMaxFrames = 2048; inline constexpr std::uint32_t kSlotCount = 4; -inline constexpr std::uint32_t kMaxParameters = 256; inline constexpr std::size_t kParameterTitleBytes = 64; inline constexpr std::size_t kParameterUnitsBytes = 32; inline constexpr std::size_t kPluginNameBytes = 128; -inline constexpr std::size_t kMaxStateBytes = 16u * 1024u * 1024u; enum class SlotState : long { Free = 0, @@ -67,15 +65,6 @@ enum class StateStatus : long { VstError = 4, }; -enum ParameterFlags : std::uint32_t { - ParameterCanAutomate = 1u << 0, - ParameterReadOnly = 1u << 1, - ParameterHidden = 1u << 2, - ParameterList = 1u << 3, - ParameterProgramChange = 1u << 4, - ParameterBypass = 1u << 5, -}; - struct alignas(64) ParameterDescriptor { std::uint32_t id = 0; std::int32_t step_count = 0; diff --git a/src/common/state_snapshot.hpp b/src/common/state_snapshot.hpp index e3df166..5675f65 100644 --- a/src/common/state_snapshot.hpp +++ b/src/common/state_snapshot.hpp @@ -1,6 +1,6 @@ #pragma once -#include "common/protocol.hpp" +#include "common/vst3_host_contract.hpp" #include #include diff --git a/src/common/vst3_host_contract.hpp b/src/common/vst3_host_contract.hpp new file mode 100644 index 0000000..fb6c8e5 --- /dev/null +++ b/src/common/vst3_host_contract.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include +#include + +namespace safevst3 { + +inline constexpr std::uint32_t kMaxChannels = 2; +inline constexpr std::uint32_t kMaxFrames = 2048; +inline constexpr std::uint32_t kMaxParameters = 256; +inline constexpr std::size_t kMaxStateBytes = 16u * 1024u * 1024u; + +enum ParameterFlags : std::uint32_t { + ParameterCanAutomate = 1u << 0, + ParameterReadOnly = 1u << 1, + ParameterHidden = 1u << 2, + ParameterList = 1u << 3, + ParameterProgramChange = 1u << 4, + ParameterBypass = 1u << 5, +}; + +} // namespace safevst3 diff --git a/src/host/hosted_plugin.cpp b/src/host/hosted_plugin.cpp index 0d15ce9..8390fc7 100644 --- a/src/host/hosted_plugin.cpp +++ b/src/host/hosted_plugin.cpp @@ -2,155 +2,1216 @@ #include "host/hosted_plugin.hpp" -#include "host/vst3_engine.hpp" +#include "common/audio_channel_adapter.hpp" +#include "common/parameter_utils.hpp" +#include "common/state_restore_policy.hpp" +#include "pluginterfaces/vst/vstspeaker.h" +#include "public.sdk/source/common/memorystream.h" +#include "public.sdk/source/vst/hosting/plugprovider.h" +#include "public.sdk/source/vst/utility/stringconvert.h" -#include +#include +#include namespace safevst3 { -HostedPlugin::HostedPlugin() - : engine_(std::make_unique()) +using namespace Steinberg; +using namespace Steinberg::Vst; + +static_assert(kProcessNeedSystemTime == IProcessContextRequirements::kNeedSystemTime); +static_assert(kProcessNeedContinuousTimeSamples == IProcessContextRequirements::kNeedContinousTimeSamples); +static_assert(kProcessNeedProjectTimeMusic == IProcessContextRequirements::kNeedProjectTimeMusic); +static_assert(kProcessNeedBarPositionMusic == IProcessContextRequirements::kNeedBarPositionMusic); +static_assert(kProcessNeedCycleMusic == IProcessContextRequirements::kNeedCycleMusic); +static_assert(kProcessNeedSamplesToNextClock == IProcessContextRequirements::kNeedSamplesToNextClock); +static_assert(kProcessNeedTempo == IProcessContextRequirements::kNeedTempo); +static_assert(kProcessNeedTimeSignature == IProcessContextRequirements::kNeedTimeSignature); +static_assert(kProcessNeedChord == IProcessContextRequirements::kNeedChord); +static_assert(kProcessNeedFrameRate == IProcessContextRequirements::kNeedFrameRate); +static_assert(kProcessNeedTransportState == IProcessContextRequirements::kNeedTransportState); +static_assert(kProcessContextContinuousTimeValid == ProcessContext::kContTimeValid); + +namespace { + +bool copy_stream(MemoryStream& stream, std::vector& destination, + const char* label, std::string& error) { + const auto raw_size = stream.getSize(); + if (raw_size < 0 || static_cast(raw_size) > kMaxStateBytes) { + error = std::string(label) + " state exceeds the supported snapshot size"; + return false; + } + + const auto size = static_cast(raw_size); + if (size == 0) { + destination.clear(); + return true; + } + + const char* data = stream.getData(); + if (!data) { + error = std::string(label) + " state stream returned no data"; + return false; + } + + const auto* begin = reinterpret_cast(data); + destination.assign(begin, begin + static_cast(size)); + return true; } -HostedPlugin::~HostedPlugin() = default; +MemoryStream read_stream(const std::vector& bytes) +{ + return MemoryStream(bytes.empty() ? nullptr : const_cast(bytes.data()), + static_cast(bytes.size())); +} -bool HostedPlugin::open(const std::string& path, - const std::string& class_id, - std::uint32_t sample_rate, - std::uint32_t channels, - Steinberg::Vst::IComponentHandler* component_handler, - StartupPhaseSink* startup_phase_sink, - std::string& error) +PluginCallResult classify_plugin_call_result(tresult result) noexcept { - return engine_->open(path, class_id, sample_rate, channels, - component_handler, startup_phase_sink, error); + if (result == kResultTrue) + return PluginCallResult::Success; + if (result == kResultFalse) + return PluginCallResult::ResultFalse; + if (result == kNotImplemented) + return PluginCallResult::NotImplemented; + return PluginCallResult::UnexpectedFailure; } -bool HostedPlugin::open(const std::string& path, - const std::string& class_id, - std::uint32_t sample_rate, - std::uint32_t channels, - Steinberg::Vst::IComponentHandler* component_handler, - std::string& error) +std::uint32_t supported_arrangement_channels(SpeakerArrangement arrangement) noexcept { - return engine_->open(path, class_id, sample_rate, channels, - component_handler, error); + if (arrangement == SpeakerArr::kMono) + return 1; + if (arrangement == SpeakerArr::kStereo) + return 2; + return 0; } -void HostedPlugin::close() noexcept +SpeakerArrangement fallback_arrangement_for_channels(int32 channels) noexcept { - engine_->close(); + if (channels == 0) + return SpeakerArr::kEmpty; + if (channels == 1) + return SpeakerArr::kMono; + if (channels == 2) + return SpeakerArr::kStereo; + return SpeakerArr::kEmpty; } -bool HostedPlugin::process(const ProcessBlockView& block) noexcept +const char* io_restart_step_name(IoRestartLifecycleStep step) noexcept +{ + switch (step) { + case IoRestartLifecycleStep::StopProcessing: return "setProcessing(false)"; + case IoRestartLifecycleStep::Deactivate: return "setActive(false)"; + case IoRestartLifecycleStep::InspectRequested: return "inspect requested bus arrangements"; + case IoRestartLifecycleStep::ConfirmRequested: return "setBusArrangements"; + case IoRestartLifecycleStep::InspectConfirmed: return "inspect confirmed bus arrangements"; + case IoRestartLifecycleStep::RebuildProcessing: return "rebuild ProcessData"; + case IoRestartLifecycleStep::Activate: return "setActive(true)"; + case IoRestartLifecycleStep::QueryLatency: return "getLatencySamples()"; + case IoRestartLifecycleStep::StartProcessing: return "setProcessing(true)"; + case IoRestartLifecycleStep::Commit: return "commit I/O layout"; + case IoRestartLifecycleStep::None: + default: return "unknown step"; + } +} + +const char* latency_restart_step_name(LatencyRestartStep step) noexcept +{ + switch (step) { + case LatencyRestartStep::StopProcessing: + return "setProcessing(false)"; + case LatencyRestartStep::Deactivate: + return "setActive(false)"; + case LatencyRestartStep::Activate: + return "setActive(true)"; + case LatencyRestartStep::QueryLatency: + return "getLatencySamples()"; + case LatencyRestartStep::StartProcessing: + return "setProcessing(true)"; + case LatencyRestartStep::None: + default: + return "unknown step"; + } +} + +} // namespace + +HostedPlugin::~HostedPlugin() { close(); } + +void HostedPlugin::report_startup_phase(StartupErrorCode phase) noexcept +{ + if (startup_phase_sink_) + startup_phase_sink_->publish(phase); +} + +bool HostedPlugin::configure_buses(std::uint32_t channels, std::string& error) +{ + main_input_bus_ = -1; + main_output_bus_ = -1; + plugin_input_channels_ = 0; + plugin_output_channels_ = 0; + + report_startup_phase(StartupErrorCode::BusNegotiation); + const int32 input_count = component_->getBusCount(kAudio, kInput); + report_startup_phase(StartupErrorCode::BusNegotiation); + const int32 output_count = component_->getBusCount(kAudio, kOutput); + if (input_count <= 0 || output_count <= 0) { + error = "VST3 effect has no audio input/output bus"; + return false; + } + + std::vector input_arrangements(static_cast(input_count), SpeakerArr::kEmpty); + std::vector output_arrangements(static_cast(output_count), SpeakerArr::kEmpty); + + for (int32 i = 0; i < input_count; ++i) { + BusInfo info{}; + report_startup_phase(StartupErrorCode::BusNegotiation); + if (component_->getBusInfo(kAudio, kInput, i, info) != kResultTrue) { + error = "VST3 input bus metadata unavailable"; + return false; + } + report_startup_phase(StartupErrorCode::BusNegotiation); + if (processor_->getBusArrangement(kInput, i, input_arrangements[static_cast(i)]) != kResultTrue) { + const auto fallback = fallback_arrangement_for_channels(info.channelCount); + if (info.channelCount > 2) { + error = "VST3 input bus arrangement unavailable for multichannel bus"; + return false; + } + input_arrangements[static_cast(i)] = fallback; + } + if (main_input_bus_ < 0 && info.busType == BusTypes::kMain) + main_input_bus_ = i; + } + + for (int32 i = 0; i < output_count; ++i) { + BusInfo info{}; + report_startup_phase(StartupErrorCode::BusNegotiation); + if (component_->getBusInfo(kAudio, kOutput, i, info) != kResultTrue) { + error = "VST3 output bus metadata unavailable"; + return false; + } + report_startup_phase(StartupErrorCode::BusNegotiation); + if (processor_->getBusArrangement(kOutput, i, output_arrangements[static_cast(i)]) != kResultTrue) { + const auto fallback = fallback_arrangement_for_channels(info.channelCount); + if (info.channelCount > 2) { + error = "VST3 output bus arrangement unavailable for multichannel bus"; + return false; + } + output_arrangements[static_cast(i)] = fallback; + } + if (main_output_bus_ < 0 && info.busType == BusTypes::kMain) + main_output_bus_ = i; + } + + if (main_input_bus_ < 0 || main_output_bus_ < 0) { + error = "VST3 effect has no main audio input/output bus"; + return false; + } + + const SpeakerArrangement requested = channels == 1 ? SpeakerArr::kMono : SpeakerArr::kStereo; + input_arrangements[static_cast(main_input_bus_)] = requested; + output_arrangements[static_cast(main_output_bus_)] = requested; + + report_startup_phase(StartupErrorCode::BusNegotiation); + const tresult set_result = processor_->setBusArrangements( + input_arrangements.data(), input_count, output_arrangements.data(), output_count); + if (set_result != kResultTrue && set_result != kResultFalse) { + error = "VST3 setBusArrangements failed during initial negotiation"; + return false; + } + + SpeakerArrangement actual_in = requested; + SpeakerArrangement actual_out = requested; + report_startup_phase(StartupErrorCode::BusNegotiation); + const bool queried_in = + processor_->getBusArrangement(kInput, main_input_bus_, actual_in) == kResultTrue; + report_startup_phase(StartupErrorCode::BusNegotiation); + const bool queried_out = + processor_->getBusArrangement(kOutput, main_output_bus_, actual_out) == kResultTrue; + const bool queried = queried_in && queried_out; + if (set_result == kResultFalse && !queried) { + error = "VST3 rejected requested I/O and did not expose a fallback arrangement"; + return false; + } + + plugin_input_channels_ = supported_arrangement_channels(actual_in); + plugin_output_channels_ = supported_arrangement_channels(actual_out); + if (plugin_input_channels_ == 0 || plugin_output_channels_ == 0) { + error = "VST3 main I/O is outside the supported mono/stereo scope"; + return false; + } + + return true; +} + +bool HostedPlugin::activate_configured_buses(std::string& error) +{ + if (!component_ || main_input_bus_ < 0 || main_output_bus_ < 0) { + error = "VST3 init[bus-activation]: configured main buses unavailable"; + return false; + } + + report_startup_phase(StartupErrorCode::BusActivation); + const int32 input_count = component_->getBusCount(kAudio, kInput); + report_startup_phase(StartupErrorCode::BusActivation); + const int32 output_count = component_->getBusCount(kAudio, kOutput); + for (int32 i = 0; i < input_count; ++i) { + report_startup_phase(StartupErrorCode::BusActivation); + (void)component_->activateBus(kAudio, kInput, i, false); + } + for (int32 i = 0; i < output_count; ++i) { + report_startup_phase(StartupErrorCode::BusActivation); + (void)component_->activateBus(kAudio, kOutput, i, false); + } + + // Preserve the broad S1 compatibility contract: some shipping plug-ins + // return advisory/non-true results here even though the requested main bus + // becomes usable. The compatibility fix is the Setup-Done ordering, not a + // new fatal return-code requirement. Processing/setup failures remain hard. + report_startup_phase(StartupErrorCode::BusActivation); + (void)component_->activateBus(kAudio, kInput, main_input_bus_, true); + report_startup_phase(StartupErrorCode::BusActivation); + (void)component_->activateBus(kAudio, kOutput, main_output_bus_, true); + return true; +} + +bool HostedPlugin::enumerate_parameters(std::string& error) +{ + parameters_.clear(); + if (!controller_) + return true; + + report_startup_phase(StartupErrorCode::ParameterCatalog); + const int32 count = controller_->getParameterCount(); + if (count < 0) { + error = "VST3 controller returned an invalid parameter count"; + return false; + } + + parameters_.reserve(static_cast(count)); + for (int32 index = 0; index < count; ++index) { + ParameterInfo info{}; + report_startup_phase(StartupErrorCode::ParameterCatalog); + if (controller_->getParameterInfo(index, info) != kResultTrue) + continue; + + std::uint32_t flags = 0; + if (info.flags & ParameterInfo::kCanAutomate) flags |= ParameterCanAutomate; + if (info.flags & ParameterInfo::kIsReadOnly) flags |= ParameterReadOnly; + if (info.flags & ParameterInfo::kIsHidden) flags |= ParameterHidden; + if (info.flags & ParameterInfo::kIsList) flags |= ParameterList; + if (info.flags & ParameterInfo::kIsProgramChange) flags |= ParameterProgramChange; + if (info.flags & ParameterInfo::kIsBypass) flags |= ParameterBypass; + + EngineParameter parameter{}; + parameter.id = static_cast(info.id); + parameter.step_count = info.stepCount; + parameter.flags = flags; + parameter.default_normalized = normalize_parameter_value(info.defaultNormalizedValue, info.stepCount); + report_startup_phase(StartupErrorCode::ParameterCatalog); + parameter.current_normalized = normalize_parameter_value(controller_->getParamNormalized(info.id), info.stepCount); + parameter.title = StringConvert::convert(info.title); + parameter.units = StringConvert::convert(info.units); + parameters_.push_back(std::move(parameter)); + } + + const auto queue_capacity = static_cast(parameters_.size()); + input_parameter_changes_.setMaxParameters(queue_capacity); + output_parameter_changes_.setMaxParameters(queue_capacity); + return true; +} + +bool HostedPlugin::open(const std::string& path, + const std::string& class_id, + std::uint32_t sample_rate, + std::uint32_t channels, + IComponentHandler* component_handler, + StartupPhaseSink* startup_phase_sink, + std::string& error) { - return engine_->process(block); + close(); + startup_phase_sink_ = startup_phase_sink; + if (channels == 0 || channels > kMaxChannels) { + error = "Public preview supports only mono or stereo"; + return false; + } + + host_ = owned(new HostApplication()); + // Preserve the S1 baseline SDK host-context contract. Strict lifecycle + // ownership changes component/controller ordering only; it must not remove + // the plugin context visible to SDK-backed objects during initialization. + PluginContextFactory::instance().setPluginContext(host_.get()); + + report_startup_phase(StartupErrorCode::ModuleLoad); + module_ = VST3::Hosting::Module::create(path, error); + if (!module_) { + error = "VST3 init[module-load]: " + error; + return false; + } + + report_startup_phase(StartupErrorCode::ClassSelect); + auto factory = module_->getFactory(); + report_startup_phase(StartupErrorCode::ClassSelect); + factory.setHostContext(host_.get()); + const VST3::Hosting::ClassInfo* chosen = nullptr; + report_startup_phase(StartupErrorCode::ClassSelect); + auto classes = factory.classInfos(); + for (const auto& info : classes) { + if (info.category() != kVstAudioEffectClass) + continue; + if (!class_id.empty() && info.ID().toString() != class_id) + continue; + chosen = &info; + break; + } + if (!chosen) { + error = class_id.empty() + ? "VST3 init[class-select]: no VST3 audio-effect class found in module" + : "VST3 init[class-select]: requested VST3 class ID not found"; + return false; + } + + plugin_name_ = chosen->name(); + loaded_class_id_ = chosen->ID().toString(); + + report_startup_phase(StartupErrorCode::ComponentCreate); + component_ = factory.createInstance(chosen->ID()); + if (!component_) { + error = "VST3 init[component-create]: component instance unavailable"; + return false; + } + report_startup_phase(StartupErrorCode::ComponentInitialize); + if (component_->initialize(host_.get()) != kResultOk) { + error = "VST3 init[component-initialize]: initialize failed"; + return false; + } + component_initialized_ = true; + + IEditController* single_controller = nullptr; + report_startup_phase(StartupErrorCode::ControllerCreate); + if (component_->queryInterface(IEditController::iid, + reinterpret_cast(&single_controller)) == kResultTrue && + single_controller) { + controller_ = owned(single_controller); + controller_is_component_ = true; + } else { + TUID controller_cid{}; + report_startup_phase(StartupErrorCode::ControllerCreate); + if (component_->getControllerClassId(controller_cid) == kResultTrue) { + report_startup_phase(StartupErrorCode::ControllerCreate); + controller_ = factory.createInstance(VST3::UID(controller_cid)); + if (!controller_) { + error = "VST3 init[controller-create]: advertised controller could not be created"; + return false; + } + report_startup_phase(StartupErrorCode::ControllerInitialize); + if (controller_->initialize(host_.get()) != kResultOk) { + error = "VST3 init[controller-initialize]: initialize failed"; + return false; + } + controller_initialized_ = true; + } + } + + // Strict separated-component ordering: the host callback must exist before + // controller/component connection. Some vendors tolerate a late handler; + // iZotope-family processors are known to exercise this frontier strictly. + if (controller_) { + if (!component_handler) { + error = "VST3 init[component-handler]: host handler unavailable"; + return false; + } + report_startup_phase(StartupErrorCode::ComponentHandler); + if (controller_->setComponentHandler(component_handler) != kResultTrue) { + error = "VST3 init[component-handler]: setComponentHandler failed"; + return false; + } + } + + if (controller_ && !controller_is_component_) { + report_startup_phase(StartupErrorCode::ConnectionPoints); + FUnknownPtr component_cp(component_); + report_startup_phase(StartupErrorCode::ConnectionPoints); + FUnknownPtr controller_cp(controller_); + if (component_cp && controller_cp) { + component_connection_ = owned(new ConnectionProxy(component_cp)); + controller_connection_ = owned(new ConnectionProxy(controller_cp)); + report_startup_phase(StartupErrorCode::ConnectComponentController); + if (component_connection_->connect(controller_cp) != kResultTrue) { + error = "VST3 init[connect-component-controller]: component connection failed"; + return false; + } + report_startup_phase(StartupErrorCode::ConnectControllerComponent); + if (controller_connection_->connect(component_cp) != kResultTrue) { + report_startup_phase(StartupErrorCode::ConnectControllerComponent); + (void)component_connection_->disconnect(); + error = "VST3 init[connect-controller-component]: controller connection failed"; + return false; + } + } else if (component_cp || controller_cp) { + error = "VST3 init[connection-points]: asymmetric separated connection support"; + return false; + } + } + + // VST3 split components must begin with the controller synchronized to the + // component's current processor state. Steinberg's host contract places + // this after handler/connection setup and before the host scans parameters. + // The SDK examples intentionally treat the returned setComponentState code + // as advisory; the compliance requirement here is that the synchronization + // call happens whenever the component can provide an initial state. + if (controller_ && !controller_is_component_) { + MemoryStream initial_component_state; + report_startup_phase(StartupErrorCode::InitialStateSync); + if (component_->getState(&initial_component_state) == kResultTrue) { + if (initial_component_state.seek(0, IBStream::kIBSeekSet, nullptr) != kResultTrue) { + error = "VST3 init[initial-state-sync]: failed to rewind initial component state"; + return false; + } + report_startup_phase(StartupErrorCode::InitialStateSync); + (void)controller_->setComponentState(&initial_component_state); + } + } + + report_startup_phase(StartupErrorCode::ProcessorInterface); + processor_ = FUnknownPtr(component_).getInterface(); + if (!processor_) { + error = "VST3 init[processor-interface]: component does not implement IAudioProcessor"; + return false; + } + + report_startup_phase(StartupErrorCode::SampleFormat); + if (processor_->canProcessSampleSize(kSample32) != kResultTrue) { + error = "VST3 init[sample-format]: public preview requires float32-capable VST3 processing"; + return false; + } + + if (!enumerate_parameters(error)) { + error = "VST3 init[parameter-catalog]: " + error; + return false; + } + if (!configure_buses(channels, error)) { + error = "VST3 init[bus-negotiation]: " + error; + return false; + } + + sample_rate_ = sample_rate; + channels_ = channels; + process_setup_.processMode = kRealtime; + process_setup_.symbolicSampleSize = kSample32; + process_setup_.maxSamplesPerBlock = static_cast(kMaxFrames); + process_setup_.sampleRate = static_cast(sample_rate); + + report_startup_phase(StartupErrorCode::SetupProcessing); + if (processor_->setupProcessing(process_setup_) != kResultOk) { + error = "VST3 init[setup-processing]: setupProcessing failed"; + return false; + } + if (!activate_configured_buses(error)) + return false; + + // VST3 3.7+ asks the host to query this processor extension once during + // setup, before activation. Older plug-ins may not expose it and remain + // compatible with the always-valid sampleRate/projectTimeSamples fields. + std::uint32_t requested_context = 0; + report_startup_phase(StartupErrorCode::ProcessContext); + FUnknownPtr context_requirements(component_); + if (context_requirements) { + report_startup_phase(StartupErrorCode::ProcessContext); + requested_context = context_requirements->getProcessContextRequirements(); + } + process_context_policy_ = plan_process_context(requested_context); + + report_startup_phase(StartupErrorCode::ProcessData); + if (!process_data_.prepare(*component_, 0, kSample32)) { + error = "VST3 init[process-data]: failed to prepare ProcessData bus containers"; + return false; + } + + const auto initial_context = make_process_context_frame( + static_cast(sample_rate), sample_position_, process_context_policy_); + process_context_.sampleRate = initial_context.sample_rate; + process_context_.projectTimeSamples = initial_context.project_time_samples; + process_context_.continousTimeSamples = initial_context.continuous_time_samples; + process_context_.state = initial_context.state; + process_data_.processContext = &process_context_; + + report_startup_phase(StartupErrorCode::SetActive); + if (component_->setActive(true) != kResultTrue) { + error = "VST3 init[set-active]: setActive(true) failed"; + return false; + } + + // Steinberg's processing lifecycle queries initial latency after activation + // but before entering the Processing state. Some plug-ins tolerate the + // inverse order; strict hosts must not depend on that tolerance. + report_startup_phase(StartupErrorCode::LatencyQuery); + latency_samples_ = processor_->getLatencySamples(); + + report_startup_phase(StartupErrorCode::SetProcessing); + const tresult set_processing_result = processor_->setProcessing(true); + if (set_processing_result != kResultTrue) { + if (startup_phase_sink_) + startup_phase_sink_->publish_vendor_result( + static_cast(set_processing_result)); + component_->setActive(false); + error = "VST3 init[set-processing]: setProcessing(true) failed (tresult=" + + format_vst3_tresult(static_cast(set_processing_result)) + ')'; + return false; + } + + startup_phase_sink_ = nullptr; + return true; } bool HostedPlugin::capture_state(PluginStateSnapshot& snapshot, std::string& error) { - return engine_->capture_state(snapshot, error); + snapshot = {}; + error.clear(); + if (!component_) { + error = "VST3 component unavailable while capturing state"; + return false; + } + + MemoryStream component_stream; + if (component_->getState(&component_stream) != kResultTrue) { + error = "VST3 component getState failed"; + return false; + } + if (!copy_stream(component_stream, snapshot.component, "VST3 component", error)) + return false; + + if (controller_) { + MemoryStream controller_stream; + if (controller_->getState(&controller_stream) == kResultTrue && + !copy_stream(controller_stream, snapshot.controller, "VST3 controller", error)) + return false; + } + + if (snapshot.total_bytes() > kMaxStateBytes) { + error = "Combined VST3 component/controller state exceeds the supported snapshot size"; + snapshot = {}; + return false; + } + return true; } bool HostedPlugin::restore_state(const PluginStateSnapshot& snapshot, std::string& error) { - return engine_->restore_state(snapshot, error); + error.clear(); + if (!component_ || !processor_) { + error = "VST3 component unavailable while restoring state"; + return false; + } + if (snapshot.total_bytes() > kMaxStateBytes) { + error = "VST3 state exceeds the supported snapshot size"; + return false; + } + + // State restoration is a control-plane transaction. Suspend the processor, + // restore in the ordering required by the VST3 specification, and always + // try to resume so a corrupt vendor blob cannot leave the helper inactive. + (void)processor_->setProcessing(false); + (void)component_->setActive(false); + input_parameter_changes_.clearQueue(); + output_parameter_changes_.clearQueue(); + parameter_changes_pending_ = false; + + bool restored = true; + auto component_stream = read_stream(snapshot.component); + if (!accepts_state_restore_result( + StateRestoreCall::ComponentState, + classify_plugin_call_result(component_->setState(&component_stream)))) { + error = "VST3 component setState failed"; + restored = false; + } + + if (restored && controller_) { + auto controller_component_stream = read_stream(snapshot.component); + if (!accepts_state_restore_result( + StateRestoreCall::ControllerComponentState, + classify_plugin_call_result( + controller_->setComponentState(&controller_component_stream)))) { + error = "VST3 controller setComponentState returned an unexpected failure"; + restored = false; + } + } + + if (restored && controller_ && !snapshot.controller.empty()) { + auto controller_stream = read_stream(snapshot.controller); + if (!accepts_state_restore_result( + StateRestoreCall::ControllerPrivateState, + classify_plugin_call_result(controller_->setState(&controller_stream)))) { + error = "VST3 controller setState failed"; + restored = false; + } + } + + const bool activated = component_->setActive(true) == kResultTrue; + const bool processing = activated && processor_->setProcessing(true) == kResultTrue; + if (!activated || !processing) { + error = !activated ? "VST3 setActive(true) failed after state restore" + : "VST3 setProcessing(true) failed after state restore"; + return false; + } + + if (!restored) + return false; + + latency_samples_ = processor_->getLatencySamples(); + refresh_parameter_values(); + return true; } bool HostedPlugin::refresh_latency_after_restart(std::string& error) { - return engine_->refresh_latency_after_restart(error); + error.clear(); + if (!component_ || !processor_) { + error = "VST3 component unavailable while refreshing latency"; + return false; + } + + const auto result = run_latency_restart_transaction(*this); + if (!result.committed) { + error = std::string("VST3 latency restart failed at ") + + latency_restart_step_name(result.failed_step); + return false; + } + + latency_samples_ = result.latency_samples; + return true; } bool HostedPlugin::reconfigure_io_after_restart(IoLayout& layout, - std::uint32_t& latency_samples, - std::string& error) + std::uint32_t& latency_samples, + std::string& error) { - return engine_->reconfigure_io_after_restart(layout, latency_samples, error); + error.clear(); + layout = {}; + latency_samples = 0; + if (!component_ || !processor_) { + error = "VST3 component unavailable while reconfiguring I/O"; + return false; + } + + const auto result = run_io_restart_lifecycle(*this); + if (!result.committed) { + error = std::string("VST3 I/O restart failed at ") + io_restart_step_name(result.failed_step); + return false; + } + + layout = result.layout; + latency_samples = result.latency_samples; + return true; } -bool HostedPlugin::queue_parameter(std::uint32_t id, double normalized) noexcept +bool HostedPlugin::collect_io_layout_candidate(IoLayout& layout) noexcept +{ + layout = {}; + if (!component_ || !processor_) + return false; + + const int32 input_count = component_->getBusCount(kAudio, kInput); + const int32 output_count = component_->getBusCount(kAudio, kOutput); + if (input_count <= 0 || output_count <= 0 || + input_count > static_cast(kMaxDynamicAudioBuses) || + output_count > static_cast(kMaxDynamicAudioBuses)) + return false; + + io_candidate_inputs_.fill(SpeakerArr::kEmpty); + io_candidate_outputs_.fill(SpeakerArr::kEmpty); + io_candidate_input_count_ = input_count; + io_candidate_output_count_ = output_count; + io_candidate_main_input_bus_ = -1; + io_candidate_main_output_bus_ = -1; + std::uint32_t main_input_count = 0; + std::uint32_t main_output_count = 0; + + for (int32 i = 0; i < input_count; ++i) { + BusInfo info{}; + if (component_->getBusInfo(kAudio, kInput, i, info) != kResultTrue) + return false; + auto& arrangement = io_candidate_inputs_[static_cast(i)]; + if (processor_->getBusArrangement(kInput, i, arrangement) != kResultTrue) { + if (info.channelCount > 2) + return false; + arrangement = fallback_arrangement_for_channels(info.channelCount); + } + if (info.busType == BusTypes::kMain) { + ++main_input_count; + if (main_input_count == 1u) + io_candidate_main_input_bus_ = i; + } + } + + for (int32 i = 0; i < output_count; ++i) { + BusInfo info{}; + if (component_->getBusInfo(kAudio, kOutput, i, info) != kResultTrue) + return false; + auto& arrangement = io_candidate_outputs_[static_cast(i)]; + if (processor_->getBusArrangement(kOutput, i, arrangement) != kResultTrue) { + if (info.channelCount > 2) + return false; + arrangement = fallback_arrangement_for_channels(info.channelCount); + } + if (info.busType == BusTypes::kMain) { + ++main_output_count; + if (main_output_count == 1u) + io_candidate_main_output_bus_ = i; + } + } + + if (!has_unambiguous_main_io(main_input_count, main_output_count) || + io_candidate_main_input_bus_ < 0 || io_candidate_main_output_bus_ < 0) + return false; + + layout.main_input_bus = io_candidate_main_input_bus_; + layout.main_output_bus = io_candidate_main_output_bus_; + layout.input_channels = supported_arrangement_channels( + io_candidate_inputs_[static_cast(io_candidate_main_input_bus_)]); + layout.output_channels = supported_arrangement_channels( + io_candidate_outputs_[static_cast(io_candidate_main_output_bus_)]); + return is_supported_io_layout(layout); +} + +bool HostedPlugin::io_stop_processing() noexcept { - return engine_->queue_parameter(id, normalized); + return processor_ && processor_->setProcessing(false) == kResultTrue; } -bool HostedPlugin::queue_parameter_from_controller(std::uint32_t id, double normalized) noexcept +bool HostedPlugin::io_deactivate() noexcept { - return engine_->queue_parameter_from_controller(id, normalized); + return component_ && component_->setActive(false) == kResultTrue; } -bool HostedPlugin::set_controller_parameter(std::uint32_t id, double normalized) noexcept +bool HostedPlugin::io_inspect_requested_layout(IoLayout& layout) noexcept { - return engine_->set_controller_parameter(id, normalized); + return collect_io_layout_candidate(layout); } -bool HostedPlugin::queue_processor_parameter(std::uint32_t id, double normalized) noexcept +IoArrangementResult HostedPlugin::io_confirm_requested_layout(const IoLayout&) noexcept { - return engine_->queue_processor_parameter(id, normalized); + if (!processor_ || io_candidate_input_count_ <= 0 || io_candidate_output_count_ <= 0) + return IoArrangementResult::FatalFailure; + + const tresult result = processor_->setBusArrangements( + io_candidate_inputs_.data(), io_candidate_input_count_, + io_candidate_outputs_.data(), io_candidate_output_count_); + if (result == kResultTrue) + return IoArrangementResult::Accepted; + if (result == kResultFalse) + return IoArrangementResult::AdvisoryRejected; + return IoArrangementResult::FatalFailure; } -bool HostedPlugin::flush_parameter_changes() noexcept +bool HostedPlugin::io_inspect_confirmed_layout(IoLayout& layout) noexcept { - return engine_->flush_parameter_changes(); + return collect_io_layout_candidate(layout); } -void HostedPlugin::refresh_parameter_values() noexcept +bool HostedPlugin::io_rebuild_processing(const IoLayout& layout) noexcept { - engine_->refresh_parameter_values(); + if (!component_ || !is_supported_io_layout(layout)) + return false; + + const int32 input_count = component_->getBusCount(kAudio, kInput); + const int32 output_count = component_->getBusCount(kAudio, kOutput); + if (input_count <= layout.main_input_bus || output_count <= layout.main_output_bus) + return false; + + for (int32 i = 0; i < input_count; ++i) + (void)component_->activateBus(kAudio, kInput, i, false); + for (int32 i = 0; i < output_count; ++i) + (void)component_->activateBus(kAudio, kOutput, i, false); + (void)component_->activateBus(kAudio, kInput, layout.main_input_bus, true); + (void)component_->activateBus(kAudio, kOutput, layout.main_output_bus, true); + + process_data_.unprepare(); + try { + if (!process_data_.prepare(*component_, 0, kSample32)) + return false; + } catch (...) { + return false; + } + process_data_.processContext = &process_context_; + return true; } -bool HostedPlugin::refresh_parameter_metadata(std::string& error) +bool HostedPlugin::io_activate() noexcept +{ + return component_ && component_->setActive(true) == kResultTrue; +} + +bool HostedPlugin::io_query_latency(std::uint32_t& latency_samples) noexcept +{ + if (!processor_) + return false; + latency_samples = processor_->getLatencySamples(); + return true; +} + +bool HostedPlugin::io_start_processing() noexcept +{ + return processor_ && processor_->setProcessing(true) == kResultTrue; +} + +void HostedPlugin::io_commit_layout(const IoLayout& layout, + std::uint32_t latency_samples) noexcept +{ + main_input_bus_ = layout.main_input_bus; + main_output_bus_ = layout.main_output_bus; + plugin_input_channels_ = layout.input_channels; + plugin_output_channels_ = layout.output_channels; + latency_samples_ = latency_samples; +} + +bool HostedPlugin::set_processing(bool enabled) noexcept +{ + return processor_ && processor_->setProcessing(enabled) == kResultTrue; +} + +bool HostedPlugin::set_active(bool enabled) noexcept +{ + return component_ && component_->setActive(enabled) == kResultTrue; +} + +std::uint32_t HostedPlugin::get_latency_samples() noexcept +{ + return processor_ ? processor_->getLatencySamples() : 0; +} + +void HostedPlugin::set_component_handler(IComponentHandler* handler) noexcept +{ + if (controller_) + (void)controller_->setComponentHandler(handler); +} + +void HostedPlugin::close() noexcept +{ + if (processor_) + (void)processor_->setProcessing(false); + if (component_) + (void)component_->setActive(false); + process_data_.unprepare(); + + if (controller_) + (void)controller_->setComponentHandler(nullptr); + if (component_connection_) + (void)component_connection_->disconnect(); + if (controller_connection_) + (void)controller_connection_->disconnect(); + component_connection_ = nullptr; + controller_connection_ = nullptr; + + processor_ = nullptr; + // Match Steinberg PlugProvider teardown ordering after disconnect: the + // component terminates first, followed by a separately initialized controller. + if (component_ && component_initialized_) + (void)component_->terminate(); + if (controller_ && controller_initialized_ && !controller_is_component_) + (void)controller_->terminate(); + + input_parameter_changes_.clearQueue(); + output_parameter_changes_.clearQueue(); + parameter_changes_pending_ = false; + parameter_update_count_ = 0; + parameters_.clear(); + controller_ = nullptr; + component_ = nullptr; + module_.reset(); + PluginContextFactory::instance().setPluginContext(nullptr); + host_ = nullptr; + main_input_bus_ = -1; + main_output_bus_ = -1; + plugin_input_channels_ = 0; + plugin_output_channels_ = 0; + io_candidate_inputs_.fill(SpeakerArr::kEmpty); + io_candidate_outputs_.fill(SpeakerArr::kEmpty); + io_candidate_input_count_ = 0; + io_candidate_output_count_ = 0; + io_candidate_main_input_bus_ = -1; + io_candidate_main_output_bus_ = -1; + plugin_name_.clear(); + loaded_class_id_.clear(); + process_context_policy_ = {}; + process_context_ = {}; + latency_samples_ = 0; + sample_position_ = 0; + component_initialized_ = false; + controller_initialized_ = false; + controller_is_component_ = false; + startup_phase_sink_ = nullptr; +} + +EngineParameter* HostedPlugin::find_parameter(std::uint32_t id) noexcept { - return engine_->refresh_parameter_metadata(error); + auto it = std::find_if(parameters_.begin(), parameters_.end(), [id](const EngineParameter& parameter) { + return parameter.id == id; + }); + return it == parameters_.end() ? nullptr : &*it; } -std::size_t HostedPlugin::take_parameter_updates(EngineParameterUpdate* destination, - std::size_t capacity) noexcept +bool HostedPlugin::queue_parameter_impl(std::uint32_t id, double normalized, bool update_controller) noexcept { - return engine_->take_parameter_updates(destination, capacity); + if (!processor_) + return false; + + EngineParameter* parameter = find_parameter(id); + if (!parameter) + return false; + + // Hidden/read-only flags restrict host-authored fallback controls. A native + // vendor editor is the plug-in's own controller UI; its performEdit() still + // has to be transferred by the host to the processor, even for parameters + // that are not meant to be exposed as generic host controls. + if (update_controller && (parameter->flags & (ParameterReadOnly | ParameterHidden)) != 0) + return false; + + normalized = normalize_parameter_value(normalized, parameter->step_count); + if (update_controller) { + if (!controller_ || controller_->setParamNormalized(static_cast(id), normalized) != kResultTrue) + return false; + } + + int32 queue_index = 0; + IParamValueQueue* queue = input_parameter_changes_.addParameterData(static_cast(id), queue_index); + if (!queue) + return false; + + int32 point_index = 0; + if (queue->addPoint(0, normalized, point_index) != kResultTrue) + return false; + + parameter->current_normalized = normalized; + record_parameter_update(id, normalized); + parameter_changes_pending_ = true; + return true; } -void HostedPlugin::set_component_handler(Steinberg::Vst::IComponentHandler* handler) noexcept +bool HostedPlugin::queue_parameter(std::uint32_t id, double normalized) noexcept { - engine_->set_component_handler(handler); + return queue_parameter_impl(id, normalized, true); } -Steinberg::Vst::IEditController* HostedPlugin::edit_controller() const noexcept +bool HostedPlugin::queue_parameter_from_controller(std::uint32_t id, double normalized) noexcept { - return engine_->edit_controller(); + return queue_processor_parameter(id, normalized); } -const std::string& HostedPlugin::plugin_name() const noexcept +bool HostedPlugin::set_controller_parameter(std::uint32_t id, double normalized) noexcept { - return engine_->plugin_name(); + if (!controller_) + return false; + EngineParameter* parameter = find_parameter(id); + if (!parameter) + return false; + normalized = normalize_parameter_value(normalized, parameter->step_count); + return controller_->setParamNormalized(static_cast(id), normalized) == kResultTrue; } -const std::string& HostedPlugin::loaded_class_id() const noexcept +bool HostedPlugin::queue_processor_parameter(std::uint32_t id, double normalized) noexcept { - return engine_->loaded_class_id(); + return queue_parameter_impl(id, normalized, false); } -std::uint32_t HostedPlugin::latency_samples() const noexcept +void HostedPlugin::refresh_parameter_values() noexcept { - return engine_->latency_samples(); + if (!controller_) + return; + for (auto& parameter : parameters_) { + const double value = normalize_parameter_value( + controller_->getParamNormalized(static_cast(parameter.id)), parameter.step_count); + if (value == parameter.current_normalized) + continue; + parameter.current_normalized = value; + record_parameter_update(parameter.id, value); + } } -std::uint32_t HostedPlugin::process_context_requirements() const noexcept +bool HostedPlugin::refresh_parameter_metadata(std::string& error) +{ + error.clear(); + return enumerate_parameters(error); +} + +bool HostedPlugin::apply_pending_parameter_changes(ProcessData& data) noexcept { - return engine_->process_context_requirements(); + if (!parameter_changes_pending_) { + data.inputParameterChanges = nullptr; + return false; + } + data.inputParameterChanges = &input_parameter_changes_; + return true; } -std::uint32_t HostedPlugin::unsupported_process_context_requirements() const noexcept +void HostedPlugin::finish_parameter_changes() noexcept { - return engine_->unsupported_process_context_requirements(); + input_parameter_changes_.clearQueue(); + parameter_changes_pending_ = false; } -const std::vector& HostedPlugin::parameters() const noexcept +void HostedPlugin::record_parameter_update(std::uint32_t id, double normalized) noexcept { - return engine_->parameters(); + for (std::size_t i = 0; i < parameter_update_count_; ++i) { + if (parameter_updates_[i].id == id) { + parameter_updates_[i].normalized = normalized; + return; + } + } + if (parameter_update_count_ < parameter_updates_.size()) + parameter_updates_[parameter_update_count_++] = {id, normalized}; +} + +void HostedPlugin::capture_output_parameter_changes() noexcept +{ + const int32 count = output_parameter_changes_.getParameterCount(); + for (int32 i = 0; i < count; ++i) { + IParamValueQueue* queue = output_parameter_changes_.getParameterData(i); + if (!queue || queue->getPointCount() <= 0) + continue; + + int32 sample_offset = 0; + ParamValue value = 0.0; + if (queue->getPoint(queue->getPointCount() - 1, sample_offset, value) != kResultTrue) + continue; + + const auto id = static_cast(queue->getParameterId()); + EngineParameter* parameter = find_parameter(id); + if (!parameter) + continue; + + value = normalize_parameter_value(value, parameter->step_count); + parameter->current_normalized = value; + // Processor feedback is intentionally controller-free. S2.2 transfers + // this update over DSP->control SPSC and the control thread applies it + // to IEditController. This keeps vendor UI calls out of process(). + record_parameter_update(id, value); + } + output_parameter_changes_.clearQueue(); +} + +std::size_t HostedPlugin::take_parameter_updates(EngineParameterUpdate* destination, std::size_t capacity) noexcept +{ + if (!destination || capacity == 0) + return 0; + const std::size_t count = std::min(capacity, parameter_update_count_); + std::copy_n(parameter_updates_.begin(), count, destination); + if (count < parameter_update_count_) + std::move(parameter_updates_.begin() + static_cast(count), + parameter_updates_.begin() + static_cast(parameter_update_count_), + parameter_updates_.begin()); + parameter_update_count_ -= count; + return count; +} + +bool HostedPlugin::flush_parameter_changes() noexcept +{ + if (!processor_ || !parameter_changes_pending_) + return true; + + ProcessData flush{}; + flush.processMode = kRealtime; + flush.symbolicSampleSize = kSample32; + flush.numSamples = 0; + flush.numInputs = 0; + flush.numOutputs = 0; + flush.inputs = nullptr; + flush.outputs = nullptr; + flush.inputEvents = nullptr; + flush.outputEvents = nullptr; + flush.outputParameterChanges = &output_parameter_changes_; + flush.processContext = &process_context_; + apply_pending_parameter_changes(flush); + + const tresult result = processor_->process(flush); + finish_parameter_changes(); + capture_output_parameter_changes(); + return result == kResultOk; +} + +bool HostedPlugin::process(const ProcessBlockView& block) noexcept +{ + if (!processor_ || !block.input || !block.output || + block.frames == 0 || block.frames > kMaxFrames || + block.channels != channels_ || + (plugin_input_channels_ != 1 && plugin_input_channels_ != 2) || + (plugin_output_channels_ != 1 && plugin_output_channels_ != 2)) + return false; + + for (std::uint32_t ch = 0; ch < block.channels; ++ch) { + if (!block.input[ch] || !block.output[ch]) + return false; + } + + Sample32* in[kMaxChannels]{}; + Sample32* out[kMaxChannels]{}; + + if (plugin_input_channels_ == channels_) { + for (std::uint32_t ch = 0; ch < channels_; ++ch) + in[ch] = block.input[ch]; + } else if (channels_ == 2 && plugin_input_channels_ == 1) { + average_stereo_to_mono( + block.input[0], block.input[1], input_adapter_[0].data(), block.frames); + in[0] = input_adapter_[0].data(); + } else if (channels_ == 1 && plugin_input_channels_ == 2) { + duplicate_mono_to_stereo( + block.input[0], input_adapter_[0].data(), input_adapter_[1].data(), block.frames); + in[0] = input_adapter_[0].data(); + in[1] = input_adapter_[1].data(); + } else { + return false; + } + + const bool output_direct = plugin_output_channels_ == channels_; + if (output_direct) { + for (std::uint32_t ch = 0; ch < channels_; ++ch) + out[ch] = block.output[ch]; + } else { + for (std::uint32_t ch = 0; ch < plugin_output_channels_; ++ch) + out[ch] = output_adapter_[ch].data(); + } + + if (!process_data_.setChannelBuffers( + kInput, main_input_bus_, in, static_cast(plugin_input_channels_)) || + !process_data_.setChannelBuffers( + kOutput, main_output_bus_, out, static_cast(plugin_output_channels_))) + return false; + + process_data_.numSamples = static_cast(block.frames); + process_data_.inputEvents = nullptr; + process_data_.outputEvents = nullptr; + process_data_.outputParameterChanges = &output_parameter_changes_; + apply_pending_parameter_changes(process_data_); + + const auto context_frame = make_process_context_frame( + process_setup_.sampleRate, sample_position_, process_context_policy_); + process_context_.sampleRate = context_frame.sample_rate; + process_context_.projectTimeSamples = context_frame.project_time_samples; + process_context_.continousTimeSamples = context_frame.continuous_time_samples; + process_context_.state = context_frame.state; + + const tresult result = processor_->process(process_data_); + finish_parameter_changes(); + capture_output_parameter_changes(); + if (result != kResultOk) + return false; + sample_position_ += block.frames; + + if (!output_direct) { + if (channels_ == 2 && plugin_output_channels_ == 1) { + duplicate_mono_to_stereo( + output_adapter_[0].data(), block.output[0], block.output[1], block.frames); + } else if (channels_ == 1 && plugin_output_channels_ == 2) { + average_stereo_to_mono( + output_adapter_[0].data(), output_adapter_[1].data(), + block.output[0], block.frames); + } else { + return false; + } + } + + return true; } } // namespace safevst3 -#endif +#endif \ No newline at end of file diff --git a/src/host/hosted_plugin.hpp b/src/host/hosted_plugin.hpp index b06ffaf..7df2ad6 100644 --- a/src/host/hosted_plugin.hpp +++ b/src/host/hosted_plugin.hpp @@ -2,35 +2,51 @@ #ifdef _WIN32 +#include "common/vst3_host_contract.hpp" +#include "common/io_restart_transaction.hpp" +#include "common/latency_restart_transaction.hpp" +#include "common/process_context_policy.hpp" +#include "common/startup_error.hpp" +#include "common/state_snapshot.hpp" #include "host/process_block_view.hpp" - +#include "host/vst3_processing_compat.hpp" + +#include "pluginterfaces/vst/ivstaudioprocessor.h" +#include "pluginterfaces/vst/ivstcomponent.h" +#include "pluginterfaces/vst/ivsteditcontroller.h" +#include "pluginterfaces/vst/ivstprocesscontext.h" +#include "public.sdk/source/vst/hosting/hostclasses.h" +#include "public.sdk/source/vst/hosting/module.h" +#include "public.sdk/source/vst/hosting/parameterchanges.h" +#include "public.sdk/source/vst/hosting/connectionproxy.h" +#include "public.sdk/source/vst/hosting/processdata.h" + +#include #include -#include #include #include #include -namespace Steinberg::Vst { -class IComponentHandler; -class IEditController; -} - namespace safevst3 { -class StartupPhaseSink; -class Vst3Engine; -struct EngineParameter; -struct EngineParameterUpdate; -struct IoLayout; -struct PluginStateSnapshot; - -// Protocol-neutral helper-side facade for exactly one VST3 audio effect. -// Single/Rack transports own their buffer/layout adapters outside this class. -// The implementation intentionally reuses the already-qualified Vst3Engine -// core so R0-2 introduces no second lifecycle/state implementation. -class HostedPlugin final { +struct EngineParameter { + std::uint32_t id = 0; + std::int32_t step_count = 0; + std::uint32_t flags = 0; + double default_normalized = 0.0; + double current_normalized = 0.0; + std::string title; + std::string units; +}; + +struct EngineParameterUpdate { + std::uint32_t id = 0; + double normalized = 0.0; +}; + +class HostedPlugin : public LatencyRestartTarget, public IoRestartLifecycleTarget { public: - HostedPlugin(); + HostedPlugin() = default; ~HostedPlugin(); HostedPlugin(const HostedPlugin&) = delete; @@ -48,8 +64,15 @@ class HostedPlugin final { std::uint32_t sample_rate, std::uint32_t channels, Steinberg::Vst::IComponentHandler* component_handler, - std::string& error); - + std::string& error) + { + StartupPhaseSink* sink = current_startup_phase_sink(); + const bool opened = open(path, class_id, sample_rate, channels, + component_handler, sink, error); + if (sink) + set_current_startup_phase_sink(nullptr); + return opened; + } void close() noexcept; bool process(const ProcessBlockView& block) noexcept; @@ -60,30 +83,106 @@ class HostedPlugin final { std::uint32_t& latency_samples, std::string& error); + // Transitional combined seam retained for callers outside the S2 helper. + // S2 helper code uses the explicit controller/processor ownership methods + // below so moving process() to its own thread does not introduce cross- + // thread IEditController calls. bool queue_parameter(std::uint32_t id, double normalized) noexcept; bool queue_parameter_from_controller(std::uint32_t id, double normalized) noexcept; bool set_controller_parameter(std::uint32_t id, double normalized) noexcept; bool queue_processor_parameter(std::uint32_t id, double normalized) noexcept; + bool flush_parameter_changes() noexcept; void refresh_parameter_values() noexcept; bool refresh_parameter_metadata(std::string& error); - std::size_t take_parameter_updates(EngineParameterUpdate* destination, - std::size_t capacity) noexcept; + std::size_t take_parameter_updates(EngineParameterUpdate* destination, std::size_t capacity) noexcept; void set_component_handler(Steinberg::Vst::IComponentHandler* handler) noexcept; - Steinberg::Vst::IEditController* edit_controller() const noexcept; - - const std::string& plugin_name() const noexcept; - const std::string& loaded_class_id() const noexcept; - std::uint32_t latency_samples() const noexcept; - std::uint32_t process_context_requirements() const noexcept; - std::uint32_t unsupported_process_context_requirements() const noexcept; - const std::vector& parameters() const noexcept; + Steinberg::Vst::IEditController* edit_controller() const noexcept { return controller_.get(); } + + const std::string& plugin_name() const noexcept { return plugin_name_; } + const std::string& loaded_class_id() const noexcept { return loaded_class_id_; } + std::uint32_t latency_samples() const noexcept { return latency_samples_; } + std::uint32_t process_context_requirements() const noexcept { + return process_context_policy_.requested_requirements; + } + std::uint32_t unsupported_process_context_requirements() const noexcept { + return process_context_policy_.unsupported_requirements; + } + const std::vector& parameters() const noexcept { return parameters_; } private: - std::unique_ptr engine_; + bool set_processing(bool enabled) noexcept override; + bool set_active(bool enabled) noexcept override; + std::uint32_t get_latency_samples() noexcept override; + + bool io_stop_processing() noexcept override; + bool io_deactivate() noexcept override; + bool io_inspect_requested_layout(IoLayout& layout) noexcept override; + IoArrangementResult io_confirm_requested_layout(const IoLayout& layout) noexcept override; + bool io_inspect_confirmed_layout(IoLayout& layout) noexcept override; + bool io_rebuild_processing(const IoLayout& layout) noexcept override; + bool io_activate() noexcept override; + bool io_query_latency(std::uint32_t& latency_samples) noexcept override; + bool io_start_processing() noexcept override; + void io_commit_layout(const IoLayout& layout, std::uint32_t latency_samples) noexcept override; + + void report_startup_phase(StartupErrorCode phase) noexcept; + bool configure_buses(std::uint32_t channels, std::string& error); + bool activate_configured_buses(std::string& error); + bool collect_io_layout_candidate(IoLayout& layout) noexcept; + bool enumerate_parameters(std::string& error); + bool queue_parameter_impl(std::uint32_t id, double normalized, bool update_controller) noexcept; + bool apply_pending_parameter_changes(Steinberg::Vst::ProcessData& data) noexcept; + void finish_parameter_changes() noexcept; + void capture_output_parameter_changes() noexcept; + void record_parameter_update(std::uint32_t id, double normalized) noexcept; + EngineParameter* find_parameter(std::uint32_t id) noexcept; + + Steinberg::IPtr host_; + VST3::Hosting::Module::Ptr module_; + Steinberg::IPtr component_; + Steinberg::IPtr controller_; + Steinberg::IPtr component_connection_; + Steinberg::IPtr controller_connection_; + CompatibleAudioProcessorPtr processor_; + Steinberg::Vst::HostProcessData process_data_; + Steinberg::Vst::ParameterChanges input_parameter_changes_{static_cast(kMaxParameters)}; + Steinberg::Vst::ParameterChanges output_parameter_changes_{static_cast(kMaxParameters)}; + Steinberg::Vst::ProcessSetup process_setup_{}; + Steinberg::Vst::ProcessContext process_context_{}; + ProcessContextPolicy process_context_policy_{}; + StartupPhaseSink* startup_phase_sink_ = nullptr; + Steinberg::int32 main_input_bus_ = -1; + Steinberg::int32 main_output_bus_ = -1; + static constexpr std::size_t kMaxDynamicAudioBuses = 16; + std::uint32_t plugin_input_channels_ = 0; + std::uint32_t plugin_output_channels_ = 0; + std::array io_candidate_inputs_{}; + std::array io_candidate_outputs_{}; + Steinberg::int32 io_candidate_input_count_ = 0; + Steinberg::int32 io_candidate_output_count_ = 0; + Steinberg::int32 io_candidate_main_input_bus_ = -1; + Steinberg::int32 io_candidate_main_output_bus_ = -1; + alignas(64) std::array, kMaxChannels> + input_adapter_{}; + alignas(64) std::array, kMaxChannels> + output_adapter_{}; + std::vector parameters_; + std::array parameter_updates_{}; + std::size_t parameter_update_count_ = 0; + std::string plugin_name_; + std::string loaded_class_id_; + std::uint32_t sample_rate_ = 0; + std::uint32_t channels_ = 0; + std::uint32_t latency_samples_ = 0; + Steinberg::int64 sample_position_ = 0; + bool parameter_changes_pending_ = false; + bool component_initialized_ = false; + bool controller_initialized_ = false; + bool controller_is_component_ = false; }; } // namespace safevst3 -#endif +#endif \ No newline at end of file diff --git a/src/host/vst3_engine.cpp b/src/host/vst3_engine.cpp index 9e92d2a..2b62ce1 100644 --- a/src/host/vst3_engine.cpp +++ b/src/host/vst3_engine.cpp @@ -2,1132 +2,8 @@ #include "host/vst3_engine.hpp" -#include "common/audio_channel_adapter.hpp" -#include "common/parameter_utils.hpp" -#include "common/state_restore_policy.hpp" -#include "pluginterfaces/vst/vstspeaker.h" -#include "public.sdk/source/common/memorystream.h" -#include "public.sdk/source/vst/hosting/plugprovider.h" -#include "public.sdk/source/vst/utility/stringconvert.h" - -#include -#include - namespace safevst3 { -using namespace Steinberg; -using namespace Steinberg::Vst; - -static_assert(kProcessNeedSystemTime == IProcessContextRequirements::kNeedSystemTime); -static_assert(kProcessNeedContinuousTimeSamples == IProcessContextRequirements::kNeedContinousTimeSamples); -static_assert(kProcessNeedProjectTimeMusic == IProcessContextRequirements::kNeedProjectTimeMusic); -static_assert(kProcessNeedBarPositionMusic == IProcessContextRequirements::kNeedBarPositionMusic); -static_assert(kProcessNeedCycleMusic == IProcessContextRequirements::kNeedCycleMusic); -static_assert(kProcessNeedSamplesToNextClock == IProcessContextRequirements::kNeedSamplesToNextClock); -static_assert(kProcessNeedTempo == IProcessContextRequirements::kNeedTempo); -static_assert(kProcessNeedTimeSignature == IProcessContextRequirements::kNeedTimeSignature); -static_assert(kProcessNeedChord == IProcessContextRequirements::kNeedChord); -static_assert(kProcessNeedFrameRate == IProcessContextRequirements::kNeedFrameRate); -static_assert(kProcessNeedTransportState == IProcessContextRequirements::kNeedTransportState); -static_assert(kProcessContextContinuousTimeValid == ProcessContext::kContTimeValid); - -namespace { - -bool copy_stream(MemoryStream& stream, std::vector& destination, - const char* label, std::string& error) -{ - const auto raw_size = stream.getSize(); - if (raw_size < 0 || static_cast(raw_size) > kMaxStateBytes) { - error = std::string(label) + " state exceeds the supported snapshot size"; - return false; - } - - const auto size = static_cast(raw_size); - if (size == 0) { - destination.clear(); - return true; - } - - const char* data = stream.getData(); - if (!data) { - error = std::string(label) + " state stream returned no data"; - return false; - } - - const auto* begin = reinterpret_cast(data); - destination.assign(begin, begin + static_cast(size)); - return true; -} - -MemoryStream read_stream(const std::vector& bytes) -{ - return MemoryStream(bytes.empty() ? nullptr : const_cast(bytes.data()), - static_cast(bytes.size())); -} - -PluginCallResult classify_plugin_call_result(tresult result) noexcept -{ - if (result == kResultTrue) - return PluginCallResult::Success; - if (result == kResultFalse) - return PluginCallResult::ResultFalse; - if (result == kNotImplemented) - return PluginCallResult::NotImplemented; - return PluginCallResult::UnexpectedFailure; -} - -std::uint32_t supported_arrangement_channels(SpeakerArrangement arrangement) noexcept -{ - if (arrangement == SpeakerArr::kMono) - return 1; - if (arrangement == SpeakerArr::kStereo) - return 2; - return 0; -} - -SpeakerArrangement fallback_arrangement_for_channels(int32 channels) noexcept -{ - if (channels == 0) - return SpeakerArr::kEmpty; - if (channels == 1) - return SpeakerArr::kMono; - if (channels == 2) - return SpeakerArr::kStereo; - return SpeakerArr::kEmpty; -} - -const char* io_restart_step_name(IoRestartLifecycleStep step) noexcept -{ - switch (step) { - case IoRestartLifecycleStep::StopProcessing: return "setProcessing(false)"; - case IoRestartLifecycleStep::Deactivate: return "setActive(false)"; - case IoRestartLifecycleStep::InspectRequested: return "inspect requested bus arrangements"; - case IoRestartLifecycleStep::ConfirmRequested: return "setBusArrangements"; - case IoRestartLifecycleStep::InspectConfirmed: return "inspect confirmed bus arrangements"; - case IoRestartLifecycleStep::RebuildProcessing: return "rebuild ProcessData"; - case IoRestartLifecycleStep::Activate: return "setActive(true)"; - case IoRestartLifecycleStep::QueryLatency: return "getLatencySamples()"; - case IoRestartLifecycleStep::StartProcessing: return "setProcessing(true)"; - case IoRestartLifecycleStep::Commit: return "commit I/O layout"; - case IoRestartLifecycleStep::None: - default: return "unknown step"; - } -} - -const char* latency_restart_step_name(LatencyRestartStep step) noexcept -{ - switch (step) { - case LatencyRestartStep::StopProcessing: - return "setProcessing(false)"; - case LatencyRestartStep::Deactivate: - return "setActive(false)"; - case LatencyRestartStep::Activate: - return "setActive(true)"; - case LatencyRestartStep::QueryLatency: - return "getLatencySamples()"; - case LatencyRestartStep::StartProcessing: - return "setProcessing(true)"; - case LatencyRestartStep::None: - default: - return "unknown step"; - } -} - -} // namespace - -Vst3Engine::~Vst3Engine() { close(); } - -void Vst3Engine::report_startup_phase(StartupErrorCode phase) noexcept -{ - if (startup_phase_sink_) - startup_phase_sink_->publish(phase); -} - -bool Vst3Engine::configure_buses(std::uint32_t channels, std::string& error) -{ - main_input_bus_ = -1; - main_output_bus_ = -1; - plugin_input_channels_ = 0; - plugin_output_channels_ = 0; - - report_startup_phase(StartupErrorCode::BusNegotiation); - const int32 input_count = component_->getBusCount(kAudio, kInput); - report_startup_phase(StartupErrorCode::BusNegotiation); - const int32 output_count = component_->getBusCount(kAudio, kOutput); - if (input_count <= 0 || output_count <= 0) { - error = "VST3 effect has no audio input/output bus"; - return false; - } - - std::vector input_arrangements(static_cast(input_count), SpeakerArr::kEmpty); - std::vector output_arrangements(static_cast(output_count), SpeakerArr::kEmpty); - - for (int32 i = 0; i < input_count; ++i) { - BusInfo info{}; - report_startup_phase(StartupErrorCode::BusNegotiation); - if (component_->getBusInfo(kAudio, kInput, i, info) != kResultTrue) { - error = "VST3 input bus metadata unavailable"; - return false; - } - report_startup_phase(StartupErrorCode::BusNegotiation); - if (processor_->getBusArrangement(kInput, i, input_arrangements[static_cast(i)]) != kResultTrue) { - const auto fallback = fallback_arrangement_for_channels(info.channelCount); - if (info.channelCount > 2) { - error = "VST3 input bus arrangement unavailable for multichannel bus"; - return false; - } - input_arrangements[static_cast(i)] = fallback; - } - if (main_input_bus_ < 0 && info.busType == BusTypes::kMain) - main_input_bus_ = i; - } - - for (int32 i = 0; i < output_count; ++i) { - BusInfo info{}; - report_startup_phase(StartupErrorCode::BusNegotiation); - if (component_->getBusInfo(kAudio, kOutput, i, info) != kResultTrue) { - error = "VST3 output bus metadata unavailable"; - return false; - } - report_startup_phase(StartupErrorCode::BusNegotiation); - if (processor_->getBusArrangement(kOutput, i, output_arrangements[static_cast(i)]) != kResultTrue) { - const auto fallback = fallback_arrangement_for_channels(info.channelCount); - if (info.channelCount > 2) { - error = "VST3 output bus arrangement unavailable for multichannel bus"; - return false; - } - output_arrangements[static_cast(i)] = fallback; - } - if (main_output_bus_ < 0 && info.busType == BusTypes::kMain) - main_output_bus_ = i; - } - - if (main_input_bus_ < 0 || main_output_bus_ < 0) { - error = "VST3 effect has no main audio input/output bus"; - return false; - } - - const SpeakerArrangement requested = channels == 1 ? SpeakerArr::kMono : SpeakerArr::kStereo; - input_arrangements[static_cast(main_input_bus_)] = requested; - output_arrangements[static_cast(main_output_bus_)] = requested; - - report_startup_phase(StartupErrorCode::BusNegotiation); - const tresult set_result = processor_->setBusArrangements( - input_arrangements.data(), input_count, output_arrangements.data(), output_count); - if (set_result != kResultTrue && set_result != kResultFalse) { - error = "VST3 setBusArrangements failed during initial negotiation"; - return false; - } - - SpeakerArrangement actual_in = requested; - SpeakerArrangement actual_out = requested; - report_startup_phase(StartupErrorCode::BusNegotiation); - const bool queried_in = - processor_->getBusArrangement(kInput, main_input_bus_, actual_in) == kResultTrue; - report_startup_phase(StartupErrorCode::BusNegotiation); - const bool queried_out = - processor_->getBusArrangement(kOutput, main_output_bus_, actual_out) == kResultTrue; - const bool queried = queried_in && queried_out; - if (set_result == kResultFalse && !queried) { - error = "VST3 rejected requested I/O and did not expose a fallback arrangement"; - return false; - } - - plugin_input_channels_ = supported_arrangement_channels(actual_in); - plugin_output_channels_ = supported_arrangement_channels(actual_out); - if (plugin_input_channels_ == 0 || plugin_output_channels_ == 0) { - error = "VST3 main I/O is outside the supported mono/stereo scope"; - return false; - } - - return true; -} - -bool Vst3Engine::activate_configured_buses(std::string& error) -{ - if (!component_ || main_input_bus_ < 0 || main_output_bus_ < 0) { - error = "VST3 init[bus-activation]: configured main buses unavailable"; - return false; - } - - report_startup_phase(StartupErrorCode::BusActivation); - const int32 input_count = component_->getBusCount(kAudio, kInput); - report_startup_phase(StartupErrorCode::BusActivation); - const int32 output_count = component_->getBusCount(kAudio, kOutput); - for (int32 i = 0; i < input_count; ++i) { - report_startup_phase(StartupErrorCode::BusActivation); - (void)component_->activateBus(kAudio, kInput, i, false); - } - for (int32 i = 0; i < output_count; ++i) { - report_startup_phase(StartupErrorCode::BusActivation); - (void)component_->activateBus(kAudio, kOutput, i, false); - } - - // Preserve the broad S1 compatibility contract: some shipping plug-ins - // return advisory/non-true results here even though the requested main bus - // becomes usable. The compatibility fix is the Setup-Done ordering, not a - // new fatal return-code requirement. Processing/setup failures remain hard. - report_startup_phase(StartupErrorCode::BusActivation); - (void)component_->activateBus(kAudio, kInput, main_input_bus_, true); - report_startup_phase(StartupErrorCode::BusActivation); - (void)component_->activateBus(kAudio, kOutput, main_output_bus_, true); - return true; -} - -bool Vst3Engine::enumerate_parameters(std::string& error) -{ - parameters_.clear(); - if (!controller_) - return true; - - report_startup_phase(StartupErrorCode::ParameterCatalog); - const int32 count = controller_->getParameterCount(); - if (count < 0) { - error = "VST3 controller returned an invalid parameter count"; - return false; - } - - parameters_.reserve(static_cast(count)); - for (int32 index = 0; index < count; ++index) { - ParameterInfo info{}; - report_startup_phase(StartupErrorCode::ParameterCatalog); - if (controller_->getParameterInfo(index, info) != kResultTrue) - continue; - - std::uint32_t flags = 0; - if (info.flags & ParameterInfo::kCanAutomate) flags |= ParameterCanAutomate; - if (info.flags & ParameterInfo::kIsReadOnly) flags |= ParameterReadOnly; - if (info.flags & ParameterInfo::kIsHidden) flags |= ParameterHidden; - if (info.flags & ParameterInfo::kIsList) flags |= ParameterList; - if (info.flags & ParameterInfo::kIsProgramChange) flags |= ParameterProgramChange; - if (info.flags & ParameterInfo::kIsBypass) flags |= ParameterBypass; - - EngineParameter parameter{}; - parameter.id = static_cast(info.id); - parameter.step_count = info.stepCount; - parameter.flags = flags; - parameter.default_normalized = normalize_parameter_value(info.defaultNormalizedValue, info.stepCount); - report_startup_phase(StartupErrorCode::ParameterCatalog); - parameter.current_normalized = normalize_parameter_value(controller_->getParamNormalized(info.id), info.stepCount); - parameter.title = StringConvert::convert(info.title); - parameter.units = StringConvert::convert(info.units); - parameters_.push_back(std::move(parameter)); - } - - const auto queue_capacity = static_cast(parameters_.size()); - input_parameter_changes_.setMaxParameters(queue_capacity); - output_parameter_changes_.setMaxParameters(queue_capacity); - return true; -} - -bool Vst3Engine::open(const std::string& path, - const std::string& class_id, - std::uint32_t sample_rate, - std::uint32_t channels, - IComponentHandler* component_handler, - StartupPhaseSink* startup_phase_sink, - std::string& error) -{ - close(); - startup_phase_sink_ = startup_phase_sink; - if (channels == 0 || channels > kMaxChannels) { - error = "Public preview supports only mono or stereo"; - return false; - } - - host_ = owned(new HostApplication()); - // Preserve the S1 baseline SDK host-context contract. Strict lifecycle - // ownership changes component/controller ordering only; it must not remove - // the plugin context visible to SDK-backed objects during initialization. - PluginContextFactory::instance().setPluginContext(host_.get()); - - report_startup_phase(StartupErrorCode::ModuleLoad); - module_ = VST3::Hosting::Module::create(path, error); - if (!module_) { - error = "VST3 init[module-load]: " + error; - return false; - } - - report_startup_phase(StartupErrorCode::ClassSelect); - auto factory = module_->getFactory(); - report_startup_phase(StartupErrorCode::ClassSelect); - factory.setHostContext(host_.get()); - const VST3::Hosting::ClassInfo* chosen = nullptr; - report_startup_phase(StartupErrorCode::ClassSelect); - auto classes = factory.classInfos(); - for (const auto& info : classes) { - if (info.category() != kVstAudioEffectClass) - continue; - if (!class_id.empty() && info.ID().toString() != class_id) - continue; - chosen = &info; - break; - } - if (!chosen) { - error = class_id.empty() - ? "VST3 init[class-select]: no VST3 audio-effect class found in module" - : "VST3 init[class-select]: requested VST3 class ID not found"; - return false; - } - - plugin_name_ = chosen->name(); - loaded_class_id_ = chosen->ID().toString(); - - report_startup_phase(StartupErrorCode::ComponentCreate); - component_ = factory.createInstance(chosen->ID()); - if (!component_) { - error = "VST3 init[component-create]: component instance unavailable"; - return false; - } - report_startup_phase(StartupErrorCode::ComponentInitialize); - if (component_->initialize(host_.get()) != kResultOk) { - error = "VST3 init[component-initialize]: initialize failed"; - return false; - } - component_initialized_ = true; - - IEditController* single_controller = nullptr; - report_startup_phase(StartupErrorCode::ControllerCreate); - if (component_->queryInterface(IEditController::iid, - reinterpret_cast(&single_controller)) == kResultTrue && - single_controller) { - controller_ = owned(single_controller); - controller_is_component_ = true; - } else { - TUID controller_cid{}; - report_startup_phase(StartupErrorCode::ControllerCreate); - if (component_->getControllerClassId(controller_cid) == kResultTrue) { - report_startup_phase(StartupErrorCode::ControllerCreate); - controller_ = factory.createInstance(VST3::UID(controller_cid)); - if (!controller_) { - error = "VST3 init[controller-create]: advertised controller could not be created"; - return false; - } - report_startup_phase(StartupErrorCode::ControllerInitialize); - if (controller_->initialize(host_.get()) != kResultOk) { - error = "VST3 init[controller-initialize]: initialize failed"; - return false; - } - controller_initialized_ = true; - } - } - - // Strict separated-component ordering: the host callback must exist before - // controller/component connection. Some vendors tolerate a late handler; - // iZotope-family processors are known to exercise this frontier strictly. - if (controller_) { - if (!component_handler) { - error = "VST3 init[component-handler]: host handler unavailable"; - return false; - } - report_startup_phase(StartupErrorCode::ComponentHandler); - if (controller_->setComponentHandler(component_handler) != kResultTrue) { - error = "VST3 init[component-handler]: setComponentHandler failed"; - return false; - } - } - - if (controller_ && !controller_is_component_) { - report_startup_phase(StartupErrorCode::ConnectionPoints); - FUnknownPtr component_cp(component_); - report_startup_phase(StartupErrorCode::ConnectionPoints); - FUnknownPtr controller_cp(controller_); - if (component_cp && controller_cp) { - component_connection_ = owned(new ConnectionProxy(component_cp)); - controller_connection_ = owned(new ConnectionProxy(controller_cp)); - report_startup_phase(StartupErrorCode::ConnectComponentController); - if (component_connection_->connect(controller_cp) != kResultTrue) { - error = "VST3 init[connect-component-controller]: component connection failed"; - return false; - } - report_startup_phase(StartupErrorCode::ConnectControllerComponent); - if (controller_connection_->connect(component_cp) != kResultTrue) { - report_startup_phase(StartupErrorCode::ConnectControllerComponent); - (void)component_connection_->disconnect(); - error = "VST3 init[connect-controller-component]: controller connection failed"; - return false; - } - } else if (component_cp || controller_cp) { - error = "VST3 init[connection-points]: asymmetric separated connection support"; - return false; - } - } - - // VST3 split components must begin with the controller synchronized to the - // component's current processor state. Steinberg's host contract places - // this after handler/connection setup and before the host scans parameters. - // The SDK examples intentionally treat the returned setComponentState code - // as advisory; the compliance requirement here is that the synchronization - // call happens whenever the component can provide an initial state. - if (controller_ && !controller_is_component_) { - MemoryStream initial_component_state; - report_startup_phase(StartupErrorCode::InitialStateSync); - if (component_->getState(&initial_component_state) == kResultTrue) { - if (initial_component_state.seek(0, IBStream::kIBSeekSet, nullptr) != kResultTrue) { - error = "VST3 init[initial-state-sync]: failed to rewind initial component state"; - return false; - } - report_startup_phase(StartupErrorCode::InitialStateSync); - (void)controller_->setComponentState(&initial_component_state); - } - } - - report_startup_phase(StartupErrorCode::ProcessorInterface); - processor_ = FUnknownPtr(component_).getInterface(); - if (!processor_) { - error = "VST3 init[processor-interface]: component does not implement IAudioProcessor"; - return false; - } - - report_startup_phase(StartupErrorCode::SampleFormat); - if (processor_->canProcessSampleSize(kSample32) != kResultTrue) { - error = "VST3 init[sample-format]: public preview requires float32-capable VST3 processing"; - return false; - } - - if (!enumerate_parameters(error)) { - error = "VST3 init[parameter-catalog]: " + error; - return false; - } - if (!configure_buses(channels, error)) { - error = "VST3 init[bus-negotiation]: " + error; - return false; - } - - sample_rate_ = sample_rate; - channels_ = channels; - process_setup_.processMode = kRealtime; - process_setup_.symbolicSampleSize = kSample32; - process_setup_.maxSamplesPerBlock = static_cast(kMaxFrames); - process_setup_.sampleRate = static_cast(sample_rate); - - report_startup_phase(StartupErrorCode::SetupProcessing); - if (processor_->setupProcessing(process_setup_) != kResultOk) { - error = "VST3 init[setup-processing]: setupProcessing failed"; - return false; - } - if (!activate_configured_buses(error)) - return false; - - // VST3 3.7+ asks the host to query this processor extension once during - // setup, before activation. Older plug-ins may not expose it and remain - // compatible with the always-valid sampleRate/projectTimeSamples fields. - std::uint32_t requested_context = 0; - report_startup_phase(StartupErrorCode::ProcessContext); - FUnknownPtr context_requirements(component_); - if (context_requirements) { - report_startup_phase(StartupErrorCode::ProcessContext); - requested_context = context_requirements->getProcessContextRequirements(); - } - process_context_policy_ = plan_process_context(requested_context); - - report_startup_phase(StartupErrorCode::ProcessData); - if (!process_data_.prepare(*component_, 0, kSample32)) { - error = "VST3 init[process-data]: failed to prepare ProcessData bus containers"; - return false; - } - - const auto initial_context = make_process_context_frame( - static_cast(sample_rate), sample_position_, process_context_policy_); - process_context_.sampleRate = initial_context.sample_rate; - process_context_.projectTimeSamples = initial_context.project_time_samples; - process_context_.continousTimeSamples = initial_context.continuous_time_samples; - process_context_.state = initial_context.state; - process_data_.processContext = &process_context_; - - report_startup_phase(StartupErrorCode::SetActive); - if (component_->setActive(true) != kResultTrue) { - error = "VST3 init[set-active]: setActive(true) failed"; - return false; - } - - // Steinberg's processing lifecycle queries initial latency after activation - // but before entering the Processing state. Some plug-ins tolerate the - // inverse order; strict hosts must not depend on that tolerance. - report_startup_phase(StartupErrorCode::LatencyQuery); - latency_samples_ = processor_->getLatencySamples(); - - report_startup_phase(StartupErrorCode::SetProcessing); - const tresult set_processing_result = processor_->setProcessing(true); - if (set_processing_result != kResultTrue) { - if (startup_phase_sink_) - startup_phase_sink_->publish_vendor_result( - static_cast(set_processing_result)); - component_->setActive(false); - error = "VST3 init[set-processing]: setProcessing(true) failed (tresult=" + - format_vst3_tresult(static_cast(set_processing_result)) + ')'; - return false; - } - - startup_phase_sink_ = nullptr; - return true; -} - -bool Vst3Engine::capture_state(PluginStateSnapshot& snapshot, std::string& error) -{ - snapshot = {}; - error.clear(); - if (!component_) { - error = "VST3 component unavailable while capturing state"; - return false; - } - - MemoryStream component_stream; - if (component_->getState(&component_stream) != kResultTrue) { - error = "VST3 component getState failed"; - return false; - } - if (!copy_stream(component_stream, snapshot.component, "VST3 component", error)) - return false; - - if (controller_) { - MemoryStream controller_stream; - if (controller_->getState(&controller_stream) == kResultTrue && - !copy_stream(controller_stream, snapshot.controller, "VST3 controller", error)) - return false; - } - - if (snapshot.total_bytes() > kMaxStateBytes) { - error = "Combined VST3 component/controller state exceeds the supported snapshot size"; - snapshot = {}; - return false; - } - return true; -} - -bool Vst3Engine::restore_state(const PluginStateSnapshot& snapshot, std::string& error) -{ - error.clear(); - if (!component_ || !processor_) { - error = "VST3 component unavailable while restoring state"; - return false; - } - if (snapshot.total_bytes() > kMaxStateBytes) { - error = "VST3 state exceeds the supported snapshot size"; - return false; - } - - // State restoration is a control-plane transaction. Suspend the processor, - // restore in the ordering required by the VST3 specification, and always - // try to resume so a corrupt vendor blob cannot leave the helper inactive. - (void)processor_->setProcessing(false); - (void)component_->setActive(false); - input_parameter_changes_.clearQueue(); - output_parameter_changes_.clearQueue(); - parameter_changes_pending_ = false; - - bool restored = true; - auto component_stream = read_stream(snapshot.component); - if (!accepts_state_restore_result( - StateRestoreCall::ComponentState, - classify_plugin_call_result(component_->setState(&component_stream)))) { - error = "VST3 component setState failed"; - restored = false; - } - - if (restored && controller_) { - auto controller_component_stream = read_stream(snapshot.component); - if (!accepts_state_restore_result( - StateRestoreCall::ControllerComponentState, - classify_plugin_call_result( - controller_->setComponentState(&controller_component_stream)))) { - error = "VST3 controller setComponentState returned an unexpected failure"; - restored = false; - } - } - - if (restored && controller_ && !snapshot.controller.empty()) { - auto controller_stream = read_stream(snapshot.controller); - if (!accepts_state_restore_result( - StateRestoreCall::ControllerPrivateState, - classify_plugin_call_result(controller_->setState(&controller_stream)))) { - error = "VST3 controller setState failed"; - restored = false; - } - } - - const bool activated = component_->setActive(true) == kResultTrue; - const bool processing = activated && processor_->setProcessing(true) == kResultTrue; - if (!activated || !processing) { - error = !activated ? "VST3 setActive(true) failed after state restore" - : "VST3 setProcessing(true) failed after state restore"; - return false; - } - - if (!restored) - return false; - - latency_samples_ = processor_->getLatencySamples(); - refresh_parameter_values(); - return true; -} - -bool Vst3Engine::refresh_latency_after_restart(std::string& error) -{ - error.clear(); - if (!component_ || !processor_) { - error = "VST3 component unavailable while refreshing latency"; - return false; - } - - const auto result = run_latency_restart_transaction(*this); - if (!result.committed) { - error = std::string("VST3 latency restart failed at ") + - latency_restart_step_name(result.failed_step); - return false; - } - - latency_samples_ = result.latency_samples; - return true; -} - -bool Vst3Engine::reconfigure_io_after_restart(IoLayout& layout, - std::uint32_t& latency_samples, - std::string& error) -{ - error.clear(); - layout = {}; - latency_samples = 0; - if (!component_ || !processor_) { - error = "VST3 component unavailable while reconfiguring I/O"; - return false; - } - - const auto result = run_io_restart_lifecycle(*this); - if (!result.committed) { - error = std::string("VST3 I/O restart failed at ") + io_restart_step_name(result.failed_step); - return false; - } - - layout = result.layout; - latency_samples = result.latency_samples; - return true; -} - -bool Vst3Engine::collect_io_layout_candidate(IoLayout& layout) noexcept -{ - layout = {}; - if (!component_ || !processor_) - return false; - - const int32 input_count = component_->getBusCount(kAudio, kInput); - const int32 output_count = component_->getBusCount(kAudio, kOutput); - if (input_count <= 0 || output_count <= 0 || - input_count > static_cast(kMaxDynamicAudioBuses) || - output_count > static_cast(kMaxDynamicAudioBuses)) - return false; - - io_candidate_inputs_.fill(SpeakerArr::kEmpty); - io_candidate_outputs_.fill(SpeakerArr::kEmpty); - io_candidate_input_count_ = input_count; - io_candidate_output_count_ = output_count; - io_candidate_main_input_bus_ = -1; - io_candidate_main_output_bus_ = -1; - std::uint32_t main_input_count = 0; - std::uint32_t main_output_count = 0; - - for (int32 i = 0; i < input_count; ++i) { - BusInfo info{}; - if (component_->getBusInfo(kAudio, kInput, i, info) != kResultTrue) - return false; - auto& arrangement = io_candidate_inputs_[static_cast(i)]; - if (processor_->getBusArrangement(kInput, i, arrangement) != kResultTrue) { - if (info.channelCount > 2) - return false; - arrangement = fallback_arrangement_for_channels(info.channelCount); - } - if (info.busType == BusTypes::kMain) { - ++main_input_count; - if (main_input_count == 1u) - io_candidate_main_input_bus_ = i; - } - } - - for (int32 i = 0; i < output_count; ++i) { - BusInfo info{}; - if (component_->getBusInfo(kAudio, kOutput, i, info) != kResultTrue) - return false; - auto& arrangement = io_candidate_outputs_[static_cast(i)]; - if (processor_->getBusArrangement(kOutput, i, arrangement) != kResultTrue) { - if (info.channelCount > 2) - return false; - arrangement = fallback_arrangement_for_channels(info.channelCount); - } - if (info.busType == BusTypes::kMain) { - ++main_output_count; - if (main_output_count == 1u) - io_candidate_main_output_bus_ = i; - } - } - - if (!has_unambiguous_main_io(main_input_count, main_output_count) || - io_candidate_main_input_bus_ < 0 || io_candidate_main_output_bus_ < 0) - return false; - - layout.main_input_bus = io_candidate_main_input_bus_; - layout.main_output_bus = io_candidate_main_output_bus_; - layout.input_channels = supported_arrangement_channels( - io_candidate_inputs_[static_cast(io_candidate_main_input_bus_)]); - layout.output_channels = supported_arrangement_channels( - io_candidate_outputs_[static_cast(io_candidate_main_output_bus_)]); - return is_supported_io_layout(layout); -} - -bool Vst3Engine::io_stop_processing() noexcept -{ - return processor_ && processor_->setProcessing(false) == kResultTrue; -} - -bool Vst3Engine::io_deactivate() noexcept -{ - return component_ && component_->setActive(false) == kResultTrue; -} - -bool Vst3Engine::io_inspect_requested_layout(IoLayout& layout) noexcept -{ - return collect_io_layout_candidate(layout); -} - -IoArrangementResult Vst3Engine::io_confirm_requested_layout(const IoLayout&) noexcept -{ - if (!processor_ || io_candidate_input_count_ <= 0 || io_candidate_output_count_ <= 0) - return IoArrangementResult::FatalFailure; - - const tresult result = processor_->setBusArrangements( - io_candidate_inputs_.data(), io_candidate_input_count_, - io_candidate_outputs_.data(), io_candidate_output_count_); - if (result == kResultTrue) - return IoArrangementResult::Accepted; - if (result == kResultFalse) - return IoArrangementResult::AdvisoryRejected; - return IoArrangementResult::FatalFailure; -} - -bool Vst3Engine::io_inspect_confirmed_layout(IoLayout& layout) noexcept -{ - return collect_io_layout_candidate(layout); -} - -bool Vst3Engine::io_rebuild_processing(const IoLayout& layout) noexcept -{ - if (!component_ || !is_supported_io_layout(layout)) - return false; - - const int32 input_count = component_->getBusCount(kAudio, kInput); - const int32 output_count = component_->getBusCount(kAudio, kOutput); - if (input_count <= layout.main_input_bus || output_count <= layout.main_output_bus) - return false; - - for (int32 i = 0; i < input_count; ++i) - (void)component_->activateBus(kAudio, kInput, i, false); - for (int32 i = 0; i < output_count; ++i) - (void)component_->activateBus(kAudio, kOutput, i, false); - (void)component_->activateBus(kAudio, kInput, layout.main_input_bus, true); - (void)component_->activateBus(kAudio, kOutput, layout.main_output_bus, true); - - process_data_.unprepare(); - try { - if (!process_data_.prepare(*component_, 0, kSample32)) - return false; - } catch (...) { - return false; - } - process_data_.processContext = &process_context_; - return true; -} - -bool Vst3Engine::io_activate() noexcept -{ - return component_ && component_->setActive(true) == kResultTrue; -} - -bool Vst3Engine::io_query_latency(std::uint32_t& latency_samples) noexcept -{ - if (!processor_) - return false; - latency_samples = processor_->getLatencySamples(); - return true; -} - -bool Vst3Engine::io_start_processing() noexcept -{ - return processor_ && processor_->setProcessing(true) == kResultTrue; -} - -void Vst3Engine::io_commit_layout(const IoLayout& layout, - std::uint32_t latency_samples) noexcept -{ - main_input_bus_ = layout.main_input_bus; - main_output_bus_ = layout.main_output_bus; - plugin_input_channels_ = layout.input_channels; - plugin_output_channels_ = layout.output_channels; - latency_samples_ = latency_samples; -} - -bool Vst3Engine::set_processing(bool enabled) noexcept -{ - return processor_ && processor_->setProcessing(enabled) == kResultTrue; -} - -bool Vst3Engine::set_active(bool enabled) noexcept -{ - return component_ && component_->setActive(enabled) == kResultTrue; -} - -std::uint32_t Vst3Engine::get_latency_samples() noexcept -{ - return processor_ ? processor_->getLatencySamples() : 0; -} - -void Vst3Engine::set_component_handler(IComponentHandler* handler) noexcept -{ - if (controller_) - (void)controller_->setComponentHandler(handler); -} - -void Vst3Engine::close() noexcept -{ - if (processor_) - (void)processor_->setProcessing(false); - if (component_) - (void)component_->setActive(false); - process_data_.unprepare(); - - if (controller_) - (void)controller_->setComponentHandler(nullptr); - if (component_connection_) - (void)component_connection_->disconnect(); - if (controller_connection_) - (void)controller_connection_->disconnect(); - component_connection_ = nullptr; - controller_connection_ = nullptr; - - processor_ = nullptr; - // Match Steinberg PlugProvider teardown ordering after disconnect: the - // component terminates first, followed by a separately initialized controller. - if (component_ && component_initialized_) - (void)component_->terminate(); - if (controller_ && controller_initialized_ && !controller_is_component_) - (void)controller_->terminate(); - - input_parameter_changes_.clearQueue(); - output_parameter_changes_.clearQueue(); - parameter_changes_pending_ = false; - parameter_update_count_ = 0; - parameters_.clear(); - controller_ = nullptr; - component_ = nullptr; - module_.reset(); - PluginContextFactory::instance().setPluginContext(nullptr); - host_ = nullptr; - main_input_bus_ = -1; - main_output_bus_ = -1; - plugin_input_channels_ = 0; - plugin_output_channels_ = 0; - io_candidate_inputs_.fill(SpeakerArr::kEmpty); - io_candidate_outputs_.fill(SpeakerArr::kEmpty); - io_candidate_input_count_ = 0; - io_candidate_output_count_ = 0; - io_candidate_main_input_bus_ = -1; - io_candidate_main_output_bus_ = -1; - plugin_name_.clear(); - loaded_class_id_.clear(); - process_context_policy_ = {}; - process_context_ = {}; - latency_samples_ = 0; - sample_position_ = 0; - component_initialized_ = false; - controller_initialized_ = false; - controller_is_component_ = false; - startup_phase_sink_ = nullptr; -} - -EngineParameter* Vst3Engine::find_parameter(std::uint32_t id) noexcept -{ - auto it = std::find_if(parameters_.begin(), parameters_.end(), [id](const EngineParameter& parameter) { - return parameter.id == id; - }); - return it == parameters_.end() ? nullptr : &*it; -} - -bool Vst3Engine::queue_parameter_impl(std::uint32_t id, double normalized, bool update_controller) noexcept -{ - if (!processor_) - return false; - - EngineParameter* parameter = find_parameter(id); - if (!parameter) - return false; - - // Hidden/read-only flags restrict host-authored fallback controls. A native - // vendor editor is the plug-in's own controller UI; its performEdit() still - // has to be transferred by the host to the processor, even for parameters - // that are not meant to be exposed as generic host controls. - if (update_controller && (parameter->flags & (ParameterReadOnly | ParameterHidden)) != 0) - return false; - - normalized = normalize_parameter_value(normalized, parameter->step_count); - if (update_controller) { - if (!controller_ || controller_->setParamNormalized(static_cast(id), normalized) != kResultTrue) - return false; - } - - int32 queue_index = 0; - IParamValueQueue* queue = input_parameter_changes_.addParameterData(static_cast(id), queue_index); - if (!queue) - return false; - - int32 point_index = 0; - if (queue->addPoint(0, normalized, point_index) != kResultTrue) - return false; - - parameter->current_normalized = normalized; - record_parameter_update(id, normalized); - parameter_changes_pending_ = true; - return true; -} - -bool Vst3Engine::queue_parameter(std::uint32_t id, double normalized) noexcept -{ - return queue_parameter_impl(id, normalized, true); -} - -bool Vst3Engine::queue_parameter_from_controller(std::uint32_t id, double normalized) noexcept -{ - return queue_processor_parameter(id, normalized); -} - -bool Vst3Engine::set_controller_parameter(std::uint32_t id, double normalized) noexcept -{ - if (!controller_) - return false; - EngineParameter* parameter = find_parameter(id); - if (!parameter) - return false; - normalized = normalize_parameter_value(normalized, parameter->step_count); - return controller_->setParamNormalized(static_cast(id), normalized) == kResultTrue; -} - -bool Vst3Engine::queue_processor_parameter(std::uint32_t id, double normalized) noexcept -{ - return queue_parameter_impl(id, normalized, false); -} - -void Vst3Engine::refresh_parameter_values() noexcept -{ - if (!controller_) - return; - for (auto& parameter : parameters_) { - const double value = normalize_parameter_value( - controller_->getParamNormalized(static_cast(parameter.id)), parameter.step_count); - if (value == parameter.current_normalized) - continue; - parameter.current_normalized = value; - record_parameter_update(parameter.id, value); - } -} - -bool Vst3Engine::refresh_parameter_metadata(std::string& error) -{ - error.clear(); - return enumerate_parameters(error); -} - -bool Vst3Engine::apply_pending_parameter_changes(ProcessData& data) noexcept -{ - if (!parameter_changes_pending_) { - data.inputParameterChanges = nullptr; - return false; - } - data.inputParameterChanges = &input_parameter_changes_; - return true; -} - -void Vst3Engine::finish_parameter_changes() noexcept -{ - input_parameter_changes_.clearQueue(); - parameter_changes_pending_ = false; -} - -void Vst3Engine::record_parameter_update(std::uint32_t id, double normalized) noexcept -{ - for (std::size_t i = 0; i < parameter_update_count_; ++i) { - if (parameter_updates_[i].id == id) { - parameter_updates_[i].normalized = normalized; - return; - } - } - if (parameter_update_count_ < parameter_updates_.size()) - parameter_updates_[parameter_update_count_++] = {id, normalized}; -} - -void Vst3Engine::capture_output_parameter_changes() noexcept -{ - const int32 count = output_parameter_changes_.getParameterCount(); - for (int32 i = 0; i < count; ++i) { - IParamValueQueue* queue = output_parameter_changes_.getParameterData(i); - if (!queue || queue->getPointCount() <= 0) - continue; - - int32 sample_offset = 0; - ParamValue value = 0.0; - if (queue->getPoint(queue->getPointCount() - 1, sample_offset, value) != kResultTrue) - continue; - - const auto id = static_cast(queue->getParameterId()); - EngineParameter* parameter = find_parameter(id); - if (!parameter) - continue; - - value = normalize_parameter_value(value, parameter->step_count); - parameter->current_normalized = value; - // Processor feedback is intentionally controller-free. S2.2 transfers - // this update over DSP->control SPSC and the control thread applies it - // to IEditController. This keeps vendor UI calls out of process(). - record_parameter_update(id, value); - } - output_parameter_changes_.clearQueue(); -} - -std::size_t Vst3Engine::take_parameter_updates(EngineParameterUpdate* destination, std::size_t capacity) noexcept -{ - if (!destination || capacity == 0) - return 0; - const std::size_t count = std::min(capacity, parameter_update_count_); - std::copy_n(parameter_updates_.begin(), count, destination); - if (count < parameter_update_count_) - std::move(parameter_updates_.begin() + static_cast(count), - parameter_updates_.begin() + static_cast(parameter_update_count_), - parameter_updates_.begin()); - parameter_update_count_ -= count; - return count; -} - -bool Vst3Engine::flush_parameter_changes() noexcept -{ - if (!processor_ || !parameter_changes_pending_) - return true; - - ProcessData flush{}; - flush.processMode = kRealtime; - flush.symbolicSampleSize = kSample32; - flush.numSamples = 0; - flush.numInputs = 0; - flush.numOutputs = 0; - flush.inputs = nullptr; - flush.outputs = nullptr; - flush.inputEvents = nullptr; - flush.outputEvents = nullptr; - flush.outputParameterChanges = &output_parameter_changes_; - flush.processContext = &process_context_; - apply_pending_parameter_changes(flush); - - const tresult result = processor_->process(flush); - finish_parameter_changes(); - capture_output_parameter_changes(); - return result == kResultOk; -} - bool Vst3Engine::process(AudioSlot& slot) noexcept { float* input[kMaxChannels] = {slot.input[0], slot.input[1]}; @@ -1139,93 +15,9 @@ bool Vst3Engine::process(AudioSlot& slot) noexcept slot.frames, slot.sequence, }; - return process(block); -} - -bool Vst3Engine::process(const ProcessBlockView& block) noexcept -{ - if (!processor_ || !block.input || !block.output || - block.frames == 0 || block.frames > kMaxFrames || - block.channels != channels_ || - (plugin_input_channels_ != 1 && plugin_input_channels_ != 2) || - (plugin_output_channels_ != 1 && plugin_output_channels_ != 2)) - return false; - - for (std::uint32_t ch = 0; ch < block.channels; ++ch) { - if (!block.input[ch] || !block.output[ch]) - return false; - } - - Sample32* in[kMaxChannels]{}; - Sample32* out[kMaxChannels]{}; - - if (plugin_input_channels_ == channels_) { - for (std::uint32_t ch = 0; ch < channels_; ++ch) - in[ch] = block.input[ch]; - } else if (channels_ == 2 && plugin_input_channels_ == 1) { - average_stereo_to_mono( - block.input[0], block.input[1], input_adapter_[0].data(), block.frames); - in[0] = input_adapter_[0].data(); - } else if (channels_ == 1 && plugin_input_channels_ == 2) { - duplicate_mono_to_stereo( - block.input[0], input_adapter_[0].data(), input_adapter_[1].data(), block.frames); - in[0] = input_adapter_[0].data(); - in[1] = input_adapter_[1].data(); - } else { - return false; - } - - const bool output_direct = plugin_output_channels_ == channels_; - if (output_direct) { - for (std::uint32_t ch = 0; ch < channels_; ++ch) - out[ch] = block.output[ch]; - } else { - for (std::uint32_t ch = 0; ch < plugin_output_channels_; ++ch) - out[ch] = output_adapter_[ch].data(); - } - - if (!process_data_.setChannelBuffers( - kInput, main_input_bus_, in, static_cast(plugin_input_channels_)) || - !process_data_.setChannelBuffers( - kOutput, main_output_bus_, out, static_cast(plugin_output_channels_))) - return false; - - process_data_.numSamples = static_cast(block.frames); - process_data_.inputEvents = nullptr; - process_data_.outputEvents = nullptr; - process_data_.outputParameterChanges = &output_parameter_changes_; - apply_pending_parameter_changes(process_data_); - - const auto context_frame = make_process_context_frame( - process_setup_.sampleRate, sample_position_, process_context_policy_); - process_context_.sampleRate = context_frame.sample_rate; - process_context_.projectTimeSamples = context_frame.project_time_samples; - process_context_.continousTimeSamples = context_frame.continuous_time_samples; - process_context_.state = context_frame.state; - - const tresult result = processor_->process(process_data_); - finish_parameter_changes(); - capture_output_parameter_changes(); - if (result != kResultOk) - return false; - sample_position_ += block.frames; - - if (!output_direct) { - if (channels_ == 2 && plugin_output_channels_ == 1) { - duplicate_mono_to_stereo( - output_adapter_[0].data(), block.output[0], block.output[1], block.frames); - } else if (channels_ == 1 && plugin_output_channels_ == 2) { - average_stereo_to_mono( - output_adapter_[0].data(), output_adapter_[1].data(), - block.output[0], block.frames); - } else { - return false; - } - } - - return true; + return HostedPlugin::process(block); } } // namespace safevst3 -#endif \ No newline at end of file +#endif diff --git a/src/host/vst3_engine.hpp b/src/host/vst3_engine.hpp index 0475f24..13a4ec2 100644 --- a/src/host/vst3_engine.hpp +++ b/src/host/vst3_engine.hpp @@ -3,187 +3,25 @@ #ifdef _WIN32 #include "common/protocol.hpp" -#include "common/io_restart_transaction.hpp" -#include "common/latency_restart_transaction.hpp" -#include "common/process_context_policy.hpp" -#include "common/startup_error.hpp" -#include "common/state_snapshot.hpp" -#include "host/process_block_view.hpp" -#include "host/vst3_processing_compat.hpp" - -#include "pluginterfaces/vst/ivstaudioprocessor.h" -#include "pluginterfaces/vst/ivstcomponent.h" -#include "pluginterfaces/vst/ivsteditcontroller.h" -#include "pluginterfaces/vst/ivstprocesscontext.h" -#include "public.sdk/source/vst/hosting/hostclasses.h" -#include "public.sdk/source/vst/hosting/module.h" -#include "public.sdk/source/vst/hosting/parameterchanges.h" -#include "public.sdk/source/vst/hosting/connectionproxy.h" -#include "public.sdk/source/vst/hosting/processdata.h" - -#include -#include -#include -#include -#include +#include "host/hosted_plugin.hpp" namespace safevst3 { -struct EngineParameter { - std::uint32_t id = 0; - std::int32_t step_count = 0; - std::uint32_t flags = 0; - double default_normalized = 0.0; - double current_normalized = 0.0; - std::string title; - std::string units; -}; - -struct EngineParameterUpdate { - std::uint32_t id = 0; - double normalized = 0.0; -}; - -class Vst3Engine final : public LatencyRestartTarget, public IoRestartLifecycleTarget { +// Single-transport compatibility adapter. All VST3 lifecycle/state/process +// ownership lives in HostedPlugin; this class only maps AudioSlot buffers into +// the protocol-neutral ProcessBlockView seam. +class Vst3Engine final : public HostedPlugin { public: Vst3Engine() = default; - ~Vst3Engine(); + ~Vst3Engine() = default; Vst3Engine(const Vst3Engine&) = delete; Vst3Engine& operator=(const Vst3Engine&) = delete; - bool open(const std::string& path, - const std::string& class_id, - std::uint32_t sample_rate, - std::uint32_t channels, - Steinberg::Vst::IComponentHandler* component_handler, - StartupPhaseSink* startup_phase_sink, - std::string& error); - bool open(const std::string& path, - const std::string& class_id, - std::uint32_t sample_rate, - std::uint32_t channels, - Steinberg::Vst::IComponentHandler* component_handler, - std::string& error) - { - StartupPhaseSink* sink = current_startup_phase_sink(); - const bool opened = open(path, class_id, sample_rate, channels, - component_handler, sink, error); - if (sink) - set_current_startup_phase_sink(nullptr); - return opened; - } - void close() noexcept; - bool process(const ProcessBlockView& block) noexcept; + using HostedPlugin::process; bool process(AudioSlot& slot) noexcept; - - bool capture_state(PluginStateSnapshot& snapshot, std::string& error); - bool restore_state(const PluginStateSnapshot& snapshot, std::string& error); - bool refresh_latency_after_restart(std::string& error); - bool reconfigure_io_after_restart(IoLayout& layout, - std::uint32_t& latency_samples, - std::string& error); - - // Transitional combined seam retained for callers outside the S2 helper. - // S2 helper code uses the explicit controller/processor ownership methods - // below so moving process() to its own thread does not introduce cross- - // thread IEditController calls. - bool queue_parameter(std::uint32_t id, double normalized) noexcept; - bool queue_parameter_from_controller(std::uint32_t id, double normalized) noexcept; - bool set_controller_parameter(std::uint32_t id, double normalized) noexcept; - bool queue_processor_parameter(std::uint32_t id, double normalized) noexcept; - - bool flush_parameter_changes() noexcept; - void refresh_parameter_values() noexcept; - bool refresh_parameter_metadata(std::string& error); - std::size_t take_parameter_updates(EngineParameterUpdate* destination, std::size_t capacity) noexcept; - - void set_component_handler(Steinberg::Vst::IComponentHandler* handler) noexcept; - Steinberg::Vst::IEditController* edit_controller() const noexcept { return controller_.get(); } - - const std::string& plugin_name() const noexcept { return plugin_name_; } - const std::string& loaded_class_id() const noexcept { return loaded_class_id_; } - std::uint32_t latency_samples() const noexcept { return latency_samples_; } - std::uint32_t process_context_requirements() const noexcept { - return process_context_policy_.requested_requirements; - } - std::uint32_t unsupported_process_context_requirements() const noexcept { - return process_context_policy_.unsupported_requirements; - } - const std::vector& parameters() const noexcept { return parameters_; } - -private: - bool set_processing(bool enabled) noexcept override; - bool set_active(bool enabled) noexcept override; - std::uint32_t get_latency_samples() noexcept override; - - bool io_stop_processing() noexcept override; - bool io_deactivate() noexcept override; - bool io_inspect_requested_layout(IoLayout& layout) noexcept override; - IoArrangementResult io_confirm_requested_layout(const IoLayout& layout) noexcept override; - bool io_inspect_confirmed_layout(IoLayout& layout) noexcept override; - bool io_rebuild_processing(const IoLayout& layout) noexcept override; - bool io_activate() noexcept override; - bool io_query_latency(std::uint32_t& latency_samples) noexcept override; - bool io_start_processing() noexcept override; - void io_commit_layout(const IoLayout& layout, std::uint32_t latency_samples) noexcept override; - - void report_startup_phase(StartupErrorCode phase) noexcept; - bool configure_buses(std::uint32_t channels, std::string& error); - bool activate_configured_buses(std::string& error); - bool collect_io_layout_candidate(IoLayout& layout) noexcept; - bool enumerate_parameters(std::string& error); - bool queue_parameter_impl(std::uint32_t id, double normalized, bool update_controller) noexcept; - bool apply_pending_parameter_changes(Steinberg::Vst::ProcessData& data) noexcept; - void finish_parameter_changes() noexcept; - void capture_output_parameter_changes() noexcept; - void record_parameter_update(std::uint32_t id, double normalized) noexcept; - EngineParameter* find_parameter(std::uint32_t id) noexcept; - - Steinberg::IPtr host_; - VST3::Hosting::Module::Ptr module_; - Steinberg::IPtr component_; - Steinberg::IPtr controller_; - Steinberg::IPtr component_connection_; - Steinberg::IPtr controller_connection_; - CompatibleAudioProcessorPtr processor_; - Steinberg::Vst::HostProcessData process_data_; - Steinberg::Vst::ParameterChanges input_parameter_changes_{static_cast(kMaxParameters)}; - Steinberg::Vst::ParameterChanges output_parameter_changes_{static_cast(kMaxParameters)}; - Steinberg::Vst::ProcessSetup process_setup_{}; - Steinberg::Vst::ProcessContext process_context_{}; - ProcessContextPolicy process_context_policy_{}; - StartupPhaseSink* startup_phase_sink_ = nullptr; - Steinberg::int32 main_input_bus_ = -1; - Steinberg::int32 main_output_bus_ = -1; - static constexpr std::size_t kMaxDynamicAudioBuses = 16; - std::uint32_t plugin_input_channels_ = 0; - std::uint32_t plugin_output_channels_ = 0; - std::array io_candidate_inputs_{}; - std::array io_candidate_outputs_{}; - Steinberg::int32 io_candidate_input_count_ = 0; - Steinberg::int32 io_candidate_output_count_ = 0; - Steinberg::int32 io_candidate_main_input_bus_ = -1; - Steinberg::int32 io_candidate_main_output_bus_ = -1; - alignas(64) std::array, kMaxChannels> - input_adapter_{}; - alignas(64) std::array, kMaxChannels> - output_adapter_{}; - std::vector parameters_; - std::array parameter_updates_{}; - std::size_t parameter_update_count_ = 0; - std::string plugin_name_; - std::string loaded_class_id_; - std::uint32_t sample_rate_ = 0; - std::uint32_t channels_ = 0; - std::uint32_t latency_samples_ = 0; - Steinberg::int64 sample_position_ = 0; - bool parameter_changes_pending_ = false; - bool component_initialized_ = false; - bool controller_initialized_ = false; - bool controller_is_component_ = false; }; } // namespace safevst3 -#endif \ No newline at end of file +#endif diff --git a/tests/process_context_source_contract.cmake b/tests/process_context_source_contract.cmake index ce2d51c..4e42783 100644 --- a/tests/process_context_source_contract.cmake +++ b/tests/process_context_source_contract.cmake @@ -7,7 +7,7 @@ file(READ "${SOURCE_FILE}" SOURCE) string(REGEX MATCHALL "getProcessContextRequirements\\(" REQUIREMENT_CALLS "${SOURCE}") list(LENGTH REQUIREMENT_CALLS REQUIREMENT_CALL_COUNT) if(NOT REQUIREMENT_CALL_COUNT EQUAL 1) - message(FATAL_ERROR "VST3 host must query getProcessContextRequirements exactly once in Vst3Engine::open; found ${REQUIREMENT_CALL_COUNT}") + message(FATAL_ERROR "VST3 host must query getProcessContextRequirements exactly once in HostedPlugin::open; found ${REQUIREMENT_CALL_COUNT}") endif() string(FIND "${SOURCE}" "processor_->setupProcessing(process_setup_)" SETUP_POS) @@ -36,8 +36,8 @@ if(NOT (BLOCK_FRAME_POS LESS PROCESS_POS AND PROCESS_POS LESS ADVANCE_POS)) message(FATAL_ERROR "Audio-block context must be prepared before process() and sample position advanced only after successful process()") endif() -string(FIND "${SOURCE}" "bool Vst3Engine::flush_parameter_changes() noexcept" FLUSH_BEGIN) -string(FIND "${SOURCE}" "bool Vst3Engine::process(AudioSlot& slot) noexcept" PROCESS_BEGIN) +string(FIND "${SOURCE}" "bool HostedPlugin::flush_parameter_changes() noexcept" FLUSH_BEGIN) +string(FIND "${SOURCE}" "bool HostedPlugin::process(const ProcessBlockView& block) noexcept" PROCESS_BEGIN) if(FLUSH_BEGIN LESS 0 OR PROCESS_BEGIN LESS 0 OR NOT FLUSH_BEGIN LESS PROCESS_BEGIN) message(FATAL_ERROR "Could not isolate zero-sample parameter flush implementation") endif() @@ -48,7 +48,7 @@ if(NOT FLUSH_SAMPLE_POSITION EQUAL -1) message(FATAL_ERROR "Zero-sample parameter flush must never advance or rewrite project/continuous sample time") endif() -# S1.6 full reload must recreate through Vst3Engine::open(), because open() is +# S1.6 full reload must recreate through HostedPlugin::open(), because open() is # the lifecycle frontier that performs the one-per-instance requirements query. get_filename_component(HOST_DIR "${SOURCE_FILE}" DIRECTORY) set(HOST_MAIN_FILE "${HOST_DIR}/main.cpp") @@ -66,7 +66,7 @@ string(SUBSTRING "${HOST_MAIN_SOURCE}" ${RELOAD_BEGIN} ${RELOAD_LENGTH} RELOAD_S string(FIND "${RELOAD_SOURCE}" "engine_.close();" RELOAD_CLOSE_POS) string(FIND "${RELOAD_SOURCE}" "engine_.open(" RELOAD_OPEN_POS) if(RELOAD_CLOSE_POS LESS 0 OR RELOAD_OPEN_POS LESS 0 OR NOT RELOAD_CLOSE_POS LESS RELOAD_OPEN_POS) - message(FATAL_ERROR "Full kReloadComponent recreation must close then reopen through Vst3Engine::open so requirements are freshly queried") + message(FATAL_ERROR "Full kReloadComponent recreation must close then reopen through HostedPlugin::open so requirements are freshly queried") endif() message(STATUS "VST3 process-context source contract ok") diff --git a/tests/r0_1/CMakeLists.txt b/tests/r0_1/CMakeLists.txt index 0d031a9..2dce7bd 100644 --- a/tests/r0_1/CMakeLists.txt +++ b/tests/r0_1/CMakeLists.txt @@ -49,6 +49,7 @@ target_compile_definitions(r0-1-fixture-stereo PRIVATE add_executable(r0-1-vst3-engine-process-test "${CMAKE_CURRENT_LIST_DIR}/vst3_engine_process_characterization.cpp" + "${SAFEVST3_ROOT}/src/host/hosted_plugin.cpp" "${SAFEVST3_ROOT}/src/host/vst3_engine.cpp" ) target_include_directories(r0-1-vst3-engine-process-test PRIVATE diff --git a/tests/r0_2/CMakeLists.txt b/tests/r0_2/CMakeLists.txt index c4b354f..f3100e0 100644 --- a/tests/r0_2/CMakeLists.txt +++ b/tests/r0_2/CMakeLists.txt @@ -29,7 +29,6 @@ target_compile_definitions(r0-2-hosted-plugin-fixture PRIVATE WIN32_LEAN_AND_MEA add_executable(r0-2-hosted-plugin-characterization "${CMAKE_CURRENT_LIST_DIR}/hosted_plugin_characterization.cpp" "${SAFEVST3_ROOT}/src/host/hosted_plugin.cpp" - "${SAFEVST3_ROOT}/src/host/vst3_engine.cpp" ) target_include_directories(r0-2-hosted-plugin-characterization PRIVATE "${SAFEVST3_ROOT}/src") target_link_libraries(r0-2-hosted-plugin-characterization PRIVATE @@ -55,5 +54,6 @@ add_test( NAME r0-2-hosted-plugin-protocol-neutral-contract COMMAND ${CMAKE_COMMAND} -DHEADER=${SAFEVST3_ROOT}/src/host/hosted_plugin.hpp + -DSOURCE=${SAFEVST3_ROOT}/src/host/hosted_plugin.cpp -P ${CMAKE_CURRENT_LIST_DIR}/hosted_plugin_protocol_neutral.cmake ) diff --git a/tests/r0_2/README.md b/tests/r0_2/README.md index 24943aa..a37e6e7 100644 --- a/tests/r0_2/README.md +++ b/tests/r0_2/README.md @@ -9,6 +9,7 @@ It builds a deterministic real VST3 effect with a separated edit controller and - fixed latency exposure and latency-restart transaction; - component + controller state capture/restore with observable audio state; - helper-facing edit-controller accessor lifetime; -- public-header source contract rejecting Single transport types/layout includes. +- deep header/source contract rejecting Single transport types/layout includes and `Vst3Engine` dependency; +- focused target links `HostedPlugin` without the Single `vst3_engine.cpp` adapter. The fixture is a correctness oracle only. It does not implement Rack transport, Rack helper, multi-plugin processing, graphical Rack UI, scanner changes or any R1 behavior. diff --git a/tests/r0_2/hosted_plugin_protocol_neutral.cmake b/tests/r0_2/hosted_plugin_protocol_neutral.cmake index 6110552..0bd9b55 100644 --- a/tests/r0_2/hosted_plugin_protocol_neutral.cmake +++ b/tests/r0_2/hosted_plugin_protocol_neutral.cmake @@ -1,24 +1,35 @@ if(NOT DEFINED HEADER OR NOT EXISTS "${HEADER}") message(FATAL_ERROR "R0-2 HostedPlugin header is missing") endif() +if(NOT DEFINED SOURCE OR NOT EXISTS "${SOURCE}") + message(FATAL_ERROR "R0-2 HostedPlugin source is missing") +endif() -file(READ "${HEADER}" source) +file(READ "${HEADER}" header_source) +file(READ "${SOURCE}" implementation_source) -foreach(forbidden IN ITEMS "AudioSlot" "SharedAudioRegion" "common/protocol.hpp") - string(FIND "${source}" "${forbidden}" position) - if(NOT position EQUAL -1) - message(FATAL_ERROR "HostedPlugin public seam must remain protocol-neutral; found forbidden token: ${forbidden}") +foreach(forbidden IN ITEMS "AudioSlot" "SharedAudioRegion" "common/protocol.hpp" "Vst3Engine") + string(FIND "${header_source}" "${forbidden}" header_position) + if(NOT header_position EQUAL -1) + message(FATAL_ERROR "HostedPlugin public seam must remain protocol-neutral; header contains forbidden token: ${forbidden}") + endif() + string(FIND "${implementation_source}" "${forbidden}" source_position) + if(NOT source_position EQUAL -1) + message(FATAL_ERROR "HostedPlugin deep implementation must remain outside Single transport; source contains forbidden token: ${forbidden}") endif() endforeach() -string(FIND "${source}" "class HostedPlugin" class_position) +string(FIND "${header_source}" "class HostedPlugin" class_position) if(class_position EQUAL -1) message(FATAL_ERROR "HostedPlugin public seam must declare class HostedPlugin") endif() - -string(FIND "${source}" "ProcessBlockView" process_view_position) +string(FIND "${header_source}" "ProcessBlockView" process_view_position) if(process_view_position EQUAL -1) message(FATAL_ERROR "HostedPlugin public seam must expose ProcessBlockView processing") endif() +string(FIND "${implementation_source}" "bool HostedPlugin::process(const ProcessBlockView& block) noexcept" deep_process_position) +if(deep_process_position EQUAL -1) + message(FATAL_ERROR "HostedPlugin must own the deep ProcessBlockView implementation") +endif() -message(STATUS "R0-2 HostedPlugin public seam is protocol-neutral") +message(STATUS "R0-2 HostedPlugin deep seam is protocol-neutral and independent of the Single adapter") diff --git a/tests/strict_lifecycle_source_contract.cmake b/tests/strict_lifecycle_source_contract.cmake index 22ab60e..d749f63 100644 --- a/tests/strict_lifecycle_source_contract.cmake +++ b/tests/strict_lifecycle_source_contract.cmake @@ -6,7 +6,7 @@ file(READ "${ENGINE_SOURCE}" ENGINE) file(READ "${MAIN_SOURCE}" MAIN) file(READ "${WIN_IPC_SOURCE}" WIN_IPC) get_filename_component(ENGINE_DIR "${ENGINE_SOURCE}" DIRECTORY) -file(READ "${ENGINE_DIR}/vst3_engine.hpp" ENGINE_HEADER) +file(READ "${ENGINE_DIR}/hosted_plugin.hpp" ENGINE_HEADER) file(READ "${ENGINE_DIR}/vst3_processing_compat.hpp" PROCESSING_COMPAT) string(FIND "${ENGINE}" "new PlugProvider" PROVIDER_POS) @@ -57,8 +57,8 @@ if(COMPAT_MEMBER LESS 0 OR COMPAT_NOT_IMPLEMENTED LESS 0 OR COMPAT_FALSE LESS 0 message(FATAL_ERROR "S1.8d setProcessing compatibility seam regressed") endif() -string(FIND "${ENGINE}" "bool Vst3Engine::configure_buses" CONFIG_BEGIN) -string(FIND "${ENGINE}" "bool Vst3Engine::activate_configured_buses" ACTIVATE_BEGIN) +string(FIND "${ENGINE}" "bool HostedPlugin::configure_buses" CONFIG_BEGIN) +string(FIND "${ENGINE}" "bool HostedPlugin::activate_configured_buses" ACTIVATE_BEGIN) if(CONFIG_BEGIN LESS 0 OR ACTIVATE_BEGIN LESS 0 OR NOT CONFIG_BEGIN LESS ACTIVATE_BEGIN) message(FATAL_ERROR "Could not isolate initial bus negotiation") endif() From 2e0efe0967499eaefd2c92bfb0c17a891e546b3a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 29 Aug 2026 08:54:11 +0700 Subject: [PATCH 11/11] ci: qualify corrected R0-2 deep extraction