From 1d8edd59e99d7b44db995de3db810731bfe94a89 Mon Sep 17 00:00:00 2001 From: pkv Date: Sat, 14 Jun 2025 15:55:54 +0200 Subject: [PATCH 1/5] cmake: Add finder for Steinberg VST3 SDK A finder to Steinberg VST3 SDK is added in cmake. Signed-off-by: pkv --- cmake/finders/FindVST3SDK.cmake | 118 ++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 cmake/finders/FindVST3SDK.cmake diff --git a/cmake/finders/FindVST3SDK.cmake b/cmake/finders/FindVST3SDK.cmake new file mode 100644 index 00000000000000..f39cc26a949aca --- /dev/null +++ b/cmake/finders/FindVST3SDK.cmake @@ -0,0 +1,118 @@ +#[=======================================================================[.rst +FindVST3SDK +----------- + +FindModule for VST3 SDK + +Imported Targets +^^^^^^^^^^^^^^^^ + +.. versionadded:: 3.0 + +This module defines the :prop_tgt:`IMPORTED` target ``VST3::SDK``. + + +Result Variables +^^^^^^^^^^^^^^^^ + +This module sets the following variables: + +``VST3SDK_FOUND`` + True, if all required components and the core library were found. + +``VST3SDK_PATH`` + Path to the SDK. + +``VST3SDK_REQUIRED_FILES`` + List of required files. +#]=======================================================================] + +include(FindPackageHandleStandardArgs) + +find_path( + VST3SDK_PATH + NAMES pluginterfaces/base/funknown.h + PATHS ${VST3SDK_PATH} ${CMAKE_SOURCE_DIR}/plugins/obs-vst3/sdk ${CMAKE_SOURCE_DIR}/deps/vst3sdk + NO_DEFAULT_PATH +) + +if(VST3SDK_PATH) + set( + VST3SDK_REQUIRED_FILES + pluginterfaces/base/funknown.cpp + pluginterfaces/base/coreiids.cpp + public.sdk/source/vst/vstinitiids.cpp + public.sdk/source/vst/hosting/connectionproxy.cpp + public.sdk/source/vst/hosting/eventlist.cpp + public.sdk/source/vst/hosting/hostclasses.cpp + public.sdk/source/vst/hosting/module.cpp + public.sdk/source/vst/hosting/parameterchanges.cpp + public.sdk/source/vst/hosting/pluginterfacesupport.cpp + public.sdk/source/vst/hosting/processdata.cpp + public.sdk/source/vst/hosting/plugprovider.cpp + public.sdk/source/vst/moduleinfo/moduleinfoparser.cpp + public.sdk/source/common/commonstringconvert.cpp + public.sdk/source/common/memorystream.cpp + public.sdk/source/vst/utility/stringconvert.cpp + ) + + if(OS_WINDOWS) + list( + APPEND VST3SDK_REQUIRED_FILES + public.sdk/source/vst/hosting/module_win32.cpp + public.sdk/source/common/threadchecker_win32.cpp + ) + elseif(OS_MACOS) + list( + APPEND VST3SDK_REQUIRED_FILES + public.sdk/source/vst/hosting/module_mac.mm + public.sdk/source/common/threadchecker_mac.mm + ) + elseif(OS_LINUX) + list( + APPEND VST3SDK_REQUIRED_FILES + public.sdk/source/vst/hosting/module_linux.cpp + public.sdk/source/common/threadchecker_linux.cpp + ) + endif() + + set(_vst3sdk_missing "") + foreach(_f IN LISTS VST3SDK_REQUIRED_FILES) + if(NOT EXISTS "${VST3SDK_PATH}/${_f}") + list(APPEND _vst3sdk_missing "${_f}") + endif() + endforeach() + + if(_vst3sdk_missing) + message(STATUS "VST3 SDK candidate at ${VST3SDK_PATH} is incomplete:") + foreach(_m IN LISTS _vst3sdk_missing) + message(STATUS " Missing: ${_m}") + endforeach() + set(VST3SDK_FOUND FALSE) + else() + set(VST3SDK_FOUND TRUE) + endif() +endif() + +find_package_handle_standard_args( + VST3SDK + REQUIRED_VARS VST3SDK_PATH VST3SDK_FOUND + REASON_FAILURE_MESSAGE + "Could not find a complete Steinberg VST3 SDK. Set VST3SDK_PATH to the SDK root containing base, pluginterfaces, public.sdk/source/vst/hosting." +) + +if(VST3SDK_FOUND) + if(NOT TARGET VST3::SDK) + add_library(VST3::SDK INTERFACE IMPORTED) + set_target_properties(VST3::SDK PROPERTIES INTERFACE_INCLUDE_DIRECTORIES "${VST3SDK_PATH}") + message(STATUS "Found VST3 SDK: ${VST3SDK_PATH}") + endif() +endif() + +include(FeatureSummary) +set_package_properties( + VST3SDK + PROPERTIES + URL "https://www.steinberg.net/developers/" + DESCRIPTION "The Steinberg VST3 SDK provides the headers and sources for hosting and developing VST3 plug-ins." +) From 13a18e8f0b961db0b017349c0fb1642d56b9cc83 Mon Sep 17 00:00:00 2001 From: pkv Date: Sat, 1 Nov 2025 02:11:10 +0100 Subject: [PATCH 2/5] CI: Use deps with VST3 SDK (temp, remove once deps are merged) This is to ensure test builds are working. This commit should be removed on merging of the PR. Signed-off-by: pkv --- CMakePresets.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/CMakePresets.json b/CMakePresets.json index f104a9264b8644..5eb4b29fd5562e 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -27,14 +27,14 @@ "obsproject.com/obs-studio": { "dependencies": { "prebuilt": { - "version": "2025-08-23", - "baseUrl": "https://github.com/obsproject/obs-deps/releases/download", + "version": "2025-12-16", + "baseUrl": "https://github.com/pkviet/obs-deps/releases/download", "label": "Pre-Built obs-deps", "hashes": { - "macos-universal": "9403bb43fb0a9bb215739a5659ca274fe884dbbbcd22bd9ca781c961fb041c42", - "windows-x64": "8de229cff6f1981508c0eb646b35e644633a5855787b9f5d3b90ae2aeb87ffc1", - "windows-x86": "fb3c68b75911f292b3206e346053638db1c73605957207445a0a92b33ab5e00a", - "windows-arm64": "dd87ba00a6cbc153182fb62b3678a3b5021d1d11eb2730442060937a645eb97e" + "macos-universal": "7c2470efffbaca7e19e99d2e4acea4c47395ceec74689d6a7dd6a555af201364", + "windows-x64": "5b0df4f56a59a3ee0bfcc5ca466275b2b20a75d40de820bfba5d718970cc6b4c", + "windows-x86": "6993d2feba9742fef760d4efd2879521900e5bcea1256f83e7be63458f7aa046", + "windows-arm64": "82d08b42582f90ea8946119bfbf9066fc8d774a49da79c9d407795569d89d274" } }, "qt6": { From f237622870482f3b846ad769ff16b3710ab6ea91 Mon Sep 17 00:00:00 2001 From: pkv Date: Sat, 1 Nov 2025 02:04:02 +0100 Subject: [PATCH 3/5] CI: Enable VST3 SDK This pulls the VST3 SDK for CI builds. Signed-off-by: pkv --- .github/scripts/.build.zsh | 6 ++++++ .github/scripts/Build-Windows.ps1 | 7 +++++-- .github/scripts/utils.zsh/setup_ubuntu | 24 ++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/.github/scripts/.build.zsh b/.github/scripts/.build.zsh index ab417cebb88fdd..89ad6f268d083b 100755 --- a/.github/scripts/.build.zsh +++ b/.github/scripts/.build.zsh @@ -40,6 +40,7 @@ build() { if (( ! ${+SCRIPT_HOME} )) typeset -g SCRIPT_HOME=${ZSH_ARGZERO:A:h} local host_os=${${(s:-:)ZSH_ARGZERO:t:r}[2]} local project_root=${SCRIPT_HOME:A:h:h} + local buildspec_file=${project_root}/CMakePresets.json fpath=(${SCRIPT_HOME}/utils.zsh ${fpath}) autoload -Uz log_group log_error log_output check_${host_os} @@ -126,6 +127,10 @@ build() { cmake_args+=(CMAKE_XCODE_ATTRIBUTE_COMPILATION_CACHE_ENABLE_DIAGNOSTIC_REMARKS:STRING=YES) } + local deps_version + read -r deps_version <<< "$(jq -r '.obsproject.com/obs-studio.dependencies.prebuilt.version' "${buildspec_file}")" + cmake_args+=(-DVST3SDK_PATH:STRING=${project_root}/.deps/obs-deps-${deps_version}-universal/include/vst3sdk) + typeset -gx NSUnbufferedIO=YES typeset -gx CODESIGN_IDENT="${CODESIGN_IDENT:--}" @@ -213,6 +218,7 @@ build() { --preset ubuntu-ci -DENABLE_BROWSER:BOOL=ON -DCEF_ROOT_DIR:PATH="${project_root}/.deps/cef_binary_${CEF_VERSION}_${target//ubuntu-/linux_}" + -DVST3SDK_PATH:PATH="${project_root}/.deps/vst3sdk" ) cmake_build_args+=(build_${target%%-*} --config ${config} --parallel) diff --git a/.github/scripts/Build-Windows.ps1 b/.github/scripts/Build-Windows.ps1 index c8eb6bb74de2f6..c90992695a5db6 100644 --- a/.github/scripts/Build-Windows.ps1 +++ b/.github/scripts/Build-Windows.ps1 @@ -36,14 +36,14 @@ function Build { $ScriptHome = $PSScriptRoot $ProjectRoot = Resolve-Path -Path "$PSScriptRoot/../.." - + $BuildSpecFile = "${ProjectRoot}/CMakePresets.json" $UtilityFunctions = Get-ChildItem -Path $PSScriptRoot/utils.pwsh/*.ps1 -Recurse foreach($Utility in $UtilityFunctions) { Write-Debug "Loading $($Utility.FullName)" . $Utility.FullName } - + $BuildSpec = Get-Content -Path ${BuildSpecFile} -Raw | ConvertFrom-Json Install-BuildDependencies -WingetFile "${ScriptHome}/.Wingetfile" Push-Location -Stack BuildTemp @@ -51,6 +51,9 @@ function Build { $CmakeArgs = @('--preset', "windows-ci-${Target}") + $DepsVersion = $BuildSpec.configurePresets.vendor.'obsproject.com/obs-studio'.dependencies.prebuilt.version + $CmakeArgs += @("-DVST3SDK_PATH=${ProjectRoot}\.deps\obs-deps-${DepsVersion}-${Target}\include\vst3sdk") + $CmakeBuildArgs = @('--build') $CmakeInstallArgs = @() diff --git a/.github/scripts/utils.zsh/setup_ubuntu b/.github/scripts/utils.zsh/setup_ubuntu index d877352c27a312..151f68d143a008 100644 --- a/.github/scripts/utils.zsh/setup_ubuntu +++ b/.github/scripts/utils.zsh/setup_ubuntu @@ -92,3 +92,27 @@ sudo apt-get install -y --no-install-recommends \ libffmpeg-nvenc-dev librist-dev libsrt-openssl-dev \ qt6-base-dev libqt6svg6-dev qt6-base-private-dev \ libvpl-dev libvpl2 + +log_group 'Setting up VST3 SDK...' + +pushd "${project_root}/.deps" + +local _target="vst3sdk" +local _url="https://github.com/steinbergmedia/vst3sdk.git" +local _hash="9fad9770f2ae8542ab1a548a68c1ad1ac690abe0" + +if [[ ! -d ${_target}/pluginterfaces ]]; then + log_status "Cloning Steinberg VST3 SDK..." + git clone --filter=blob:none --sparse --recurse-submodules=no "${_url}" "${_target}" + + pushd "${_target}" + git checkout "${_hash}" + git submodule update --init base pluginterfaces public.sdk + popd + + log_status "Steinberg VST3 SDK cloned successfully." +else + log_status "Steinberg VST3 SDK already present; skipping clone." +fi + +popd From 8a050286e74b3eacdef652ebd471db77faaacc10 Mon Sep 17 00:00:00 2001 From: pkv Date: Thu, 27 Nov 2025 11:59:17 +0100 Subject: [PATCH 4/5] build-aux: Disable obs-vst3 for flatpak This disables obs-vst3 in the flatpak manifest. Flatpak can't be supported for now because Steinberg SDK does not allow locations other than in /usr and $HOME for the VST3s. Signed-off-by: pkv --- build-aux/com.obsproject.Studio.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/build-aux/com.obsproject.Studio.json b/build-aux/com.obsproject.Studio.json index c8bd01a5c9b2a7..0d6ba21f9dc033 100644 --- a/build-aux/com.obsproject.Studio.json +++ b/build-aux/com.obsproject.Studio.json @@ -141,7 +141,8 @@ "-DENABLE_AJA=ON", "-DENABLE_LIBFDK=ON", "-DENABLE_QSV11=ON", - "-DENABLE_DECKLINK=OFF" + "-DENABLE_DECKLINK=OFF", + "-DENABLE_VST3=OFF" ], "secret-opts": [ "-DRESTREAM_CLIENTID=$RESTREAM_CLIENTID", From 57cecca4a55ae0d866a61432551a59ac30b6d5b5 Mon Sep 17 00:00:00 2001 From: pkv Date: Wed, 21 May 2025 18:34:08 +0200 Subject: [PATCH 5/5] obs-vst3: Add a VST3 host This adds a VST3 host to obs-studio on Windows, macOS and Linux. Only audio effects are enabled. Instruments and MIDI are not supported. A maximum of 1 main bus, 1 sidechain input bus and 1 main output bus are enabled. Note that VST3 on linux only support X11. Although the SDK added Wayland capability, it is too recent so there is yet no Wayland VST3 apart from a demo from the SDK. Signed-off-by: pkv --- plugins/CMakeLists.txt | 1 + plugins/obs-vst3/CMakeLists.txt | 148 +++ plugins/obs-vst3/VST3ComponentHolder.cpp | 99 ++ plugins/obs-vst3/VST3ComponentHolder.h | 57 + plugins/obs-vst3/VST3EditorWindow.h | 46 + plugins/obs-vst3/VST3HostApp.cpp | 100 ++ plugins/obs-vst3/VST3HostApp.h | 57 + plugins/obs-vst3/VST3ParamEditQueue.h | 52 + plugins/obs-vst3/VST3Plugin.cpp | 653 ++++++++++ plugins/obs-vst3/VST3Plugin.h | 149 +++ plugins/obs-vst3/VST3Scanner.cpp | 293 +++++ plugins/obs-vst3/VST3Scanner.h | 67 + .../obs-vst3/cmake/windows/obs-module.rc.in | 27 + plugins/obs-vst3/cmake/windows/obs-studio.ico | Bin 0 -> 93455 bytes plugins/obs-vst3/data/locale/en-US.ini | 7 + plugins/obs-vst3/editor/linux/RunLoopImpl.cpp | 256 ++++ plugins/obs-vst3/editor/linux/RunLoopImpl.h | 71 ++ .../editor/linux/VST3EditorWindow.cpp | 633 ++++++++++ .../obs-vst3/editor/mac/VST3EditorWindow.mm | 266 ++++ .../obs-vst3/editor/win/VST3EditorWindow.cpp | 287 +++++ plugins/obs-vst3/obs-vst3.cpp | 1124 +++++++++++++++++ plugins/obs-vst3/obs-vst3.h | 85 ++ plugins/obs-vst3/plugin-main.cpp | 66 + 23 files changed, 4544 insertions(+) create mode 100644 plugins/obs-vst3/CMakeLists.txt create mode 100644 plugins/obs-vst3/VST3ComponentHolder.cpp create mode 100644 plugins/obs-vst3/VST3ComponentHolder.h create mode 100644 plugins/obs-vst3/VST3EditorWindow.h create mode 100644 plugins/obs-vst3/VST3HostApp.cpp create mode 100644 plugins/obs-vst3/VST3HostApp.h create mode 100644 plugins/obs-vst3/VST3ParamEditQueue.h create mode 100644 plugins/obs-vst3/VST3Plugin.cpp create mode 100644 plugins/obs-vst3/VST3Plugin.h create mode 100644 plugins/obs-vst3/VST3Scanner.cpp create mode 100644 plugins/obs-vst3/VST3Scanner.h create mode 100644 plugins/obs-vst3/cmake/windows/obs-module.rc.in create mode 100644 plugins/obs-vst3/cmake/windows/obs-studio.ico create mode 100644 plugins/obs-vst3/data/locale/en-US.ini create mode 100644 plugins/obs-vst3/editor/linux/RunLoopImpl.cpp create mode 100644 plugins/obs-vst3/editor/linux/RunLoopImpl.h create mode 100644 plugins/obs-vst3/editor/linux/VST3EditorWindow.cpp create mode 100644 plugins/obs-vst3/editor/mac/VST3EditorWindow.mm create mode 100644 plugins/obs-vst3/editor/win/VST3EditorWindow.cpp create mode 100644 plugins/obs-vst3/obs-vst3.cpp create mode 100644 plugins/obs-vst3/obs-vst3.h create mode 100644 plugins/obs-vst3/plugin-main.cpp diff --git a/plugins/CMakeLists.txt b/plugins/CMakeLists.txt index c12f015c8b85ae..42f0843d2b376c 100644 --- a/plugins/CMakeLists.txt +++ b/plugins/CMakeLists.txt @@ -75,6 +75,7 @@ add_obs_plugin( PLATFORMS WINDOWS MACOS LINUX WITH_MESSAGE ) +add_obs_plugin(obs-vst3 PLATFORMS WINDOWS MACOS LINUX) add_obs_plugin(obs-webrtc) check_obs_websocket() diff --git a/plugins/obs-vst3/CMakeLists.txt b/plugins/obs-vst3/CMakeLists.txt new file mode 100644 index 00000000000000..779fef0f7003fb --- /dev/null +++ b/plugins/obs-vst3/CMakeLists.txt @@ -0,0 +1,148 @@ +cmake_minimum_required(VERSION 3.28...3.30) + +option(ENABLE_VST3 "Enable building OBS with VST3 plugin" ON) + +if(NOT ENABLE_VST3) + target_disable(obs-vst3) + return() +endif() + +project(obs-vst3) + +find_package(Qt6 REQUIRED Widgets) +set(CMAKE_AUTOMOC ON) + +add_library(obs-vst3 MODULE VST3ComponentHolder.h VST3ComponentHolder.cpp) +add_library(OBS::vst3 ALIAS obs-vst3) + +find_package(VST3SDK QUIET) + +if(NOT VST3SDK_FOUND) + message(STATUS "VST3 SDK not found — disabling obs-vst3 plugin.") + target_disable(obs-vst3) + return() +endif() + +# SDK compile warnings +set( + SDK_WARN_FLAGS + -Wno-cast-align + -Wno-conversion + -Wno-cpp + -Wno-delete-non-virtual-dtor + -Wno-deprecated + -Wno-deprecated-copy-dtor + -Wno-deprecated-declarations + -Wno-dangling-else + -Wno-extra + -Wno-extra-semi + -Wno-float-equal + -Wno-format + -Wno-format-security + -Wno-format-truncation + -Wno-ignored-qualifiers + -Wno-int-to-pointer-cast + -Wno-missing-braces + -Wno-missing-field-initializers + -Wno-non-virtual-dtor + -Wno-overloaded-virtual + -Wno-parentheses + -Wno-pedantic + -Wno-redundant-decls + -Wno-reorder + -Wno-shadow + -Wno-sign-compare + -Wno-sign-conversion + -Wno-switch-default + -Wno-type-limits + -Wno-unused-but-set-variable + -Wno-unused-function + -Wno-unused-parameter + -Wno-zero-as-null-pointer-constant +) + +if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU") + list(APPEND SDK_WARN_FLAGS -Wno-class-memaccess -Wno-maybe-uninitialized) +endif() + +list(JOIN SDK_WARN_FLAGS " " SDK_WARN_FLAGS_STR) + +# SDK sources +set(VST3_SDK_SOURCES) +foreach(_src IN LISTS VST3SDK_REQUIRED_FILES) + list(APPEND VST3_SDK_SOURCES "${VST3SDK_PATH}/${_src}") +endforeach() + +# Main sources +set( + VST3_MAIN_SOURCES + plugin-main.cpp + obs-vst3.cpp + obs-vst3.h + VST3ComponentHolder.cpp + VST3ComponentHolder.h + VST3HostApp.cpp + VST3HostApp.h + VST3Scanner.cpp + VST3Scanner.h + VST3Plugin.cpp + VST3Plugin.h + VST3EditorWindow.h +) + +# Editor window sources +set(VST3EDITORWINDOW_SRC) +if(OS_WINDOWS) + list(APPEND VST3EDITORWINDOW_SRC editor/win/VST3EditorWindow.cpp) +elseif(OS_MACOS) + list(APPEND VST3EDITORWINDOW_SRC editor/mac/VST3EditorWindow.mm) +elseif(OS_LINUX) + list(APPEND VST3EDITORWINDOW_SRC editor/linux/VST3EditorWindow.cpp editor/linux/RunLoopImpl.cpp) +endif() + +# Add all sources +target_sources(obs-vst3 PRIVATE ${VST3_MAIN_SOURCES} ${VST3EDITORWINDOW_SRC} ${VST3_SDK_SOURCES}) + +# Disable warnings for SDK files +if(OS_LINUX OR OS_MACOS) + set_source_files_properties(${VST3_SDK_SOURCES} PROPERTIES COMPILE_FLAGS "${SDK_WARN_FLAGS_STR}") +endif() + +# macOS ARC for all .mm files (editor + sdk) +if(OS_MACOS) + set_source_files_properties( + editor/mac/VST3EditorWindow.mm + "${VST3SDK_PATH}/public.sdk/source/vst/hosting/module_mac.mm" + "${VST3SDK_PATH}/public.sdk/source/common/threadchecker_mac.mm" + PROPERTIES COMPILE_OPTIONS "-fobjc-arc" COMPILE_FLAGS "${SDK_WARN_FLAGS_STR}" + ) + target_link_libraries(obs-vst3 PRIVATE "-framework Cocoa" "-framework Foundation") +endif() + +# Includes +target_include_directories( + obs-vst3 + PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ${VST3SDK_PATH} + ${VST3SDK_PATH}/base + ${VST3SDK_PATH}/pluginterfaces + ${VST3SDK_PATH}/public.sdk/source/vst + ${VST3SDK_PATH}/public.sdk/source/vst/hosting + ${VST3SDK_PATH}/public.sdk/source/vst/utility +) + +target_link_libraries(obs-vst3 PRIVATE OBS::libobs Qt6::Widgets) + +# Windows resources +if(OS_WINDOWS) + configure_file(cmake/windows/obs-module.rc.in obs-vst3.rc) + target_sources(obs-vst3 PRIVATE obs-vst3.rc) + configure_file( + ${CMAKE_CURRENT_SOURCE_DIR}/cmake/windows/obs-studio.ico + ${CMAKE_CURRENT_BINARY_DIR}/obs-studio.ico + COPYONLY + ) +endif() + +set_target_properties_obs(obs-vst3 PROPERTIES FOLDER plugins PREFIX "") diff --git a/plugins/obs-vst3/VST3ComponentHolder.cpp b/plugins/obs-vst3/VST3ComponentHolder.cpp new file mode 100644 index 00000000000000..567c220ce4bad0 --- /dev/null +++ b/plugins/obs-vst3/VST3ComponentHolder.cpp @@ -0,0 +1,99 @@ +/****************************************************************************** + Copyright (C) 2025-2026 pkv + This file is part of obs-vst3. + It uses the Steinberg VST3 SDK, which is licensed under MIT license. + See https://github.com/steinbergmedia/vst3sdk for details. + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with this program. If not, see . +******************************************************************************/ +#include "VST3ComponentHolder.h" +#include "VST3Plugin.h" + +using namespace Steinberg; +using namespace Vst; + +VST3ComponentHolder::VST3ComponentHolder(VST3Plugin *plugin_) : plugin(plugin_) {} + +VST3ComponentHolder::~VST3ComponentHolder() noexcept {FUNKNOWN_DTOR} + +/* IComponentHandler methods */ +tresult PLUGIN_API VST3ComponentHolder::beginEdit(ParamID) +{ + return kResultOk; +} + +tresult PLUGIN_API VST3ComponentHolder::performEdit(ParamID id, ParamValue valueNormalized) +{ + if (guiToDsp) + guiToDsp->push({id, valueNormalized}); + + return kResultOk; +} + +tresult PLUGIN_API VST3ComponentHolder::endEdit(ParamID) +{ + return kResultOk; +} + +tresult PLUGIN_API VST3ComponentHolder::restartComponent(int32 flags) +{ + if (!plugin) + return kInvalidArgument; + + if ((flags & kReloadComponent) || (flags & kLatencyChanged)) { + plugin->obsVst3Struct->bypass.store(true, std::memory_order_relaxed); + + if (plugin->audioEffect) + plugin->audioEffect->setProcessing(false); + + if (plugin->vstPlug) { + plugin->vstPlug->setActive(false); + plugin->vstPlug->setActive(true); + } + + if (plugin->audioEffect) + plugin->audioEffect->setProcessing(true); + + uint32 latency = plugin->audioEffect ? plugin->audioEffect->getLatencySamples() : 0; + infovst3plugin("Latency of the plugin is %u samples", latency); + + plugin->obsVst3Struct->bypass.store(false, std::memory_order_relaxed); + return kResultOk; + } + + return kNotImplemented; +} + +/* IUnitHandler methods */ +tresult PLUGIN_API VST3ComponentHolder::notifyUnitSelection(UnitID) +{ + return kResultTrue; +} + +tresult PLUGIN_API VST3ComponentHolder::notifyProgramListChange(ProgramListID, int32) +{ + return kResultTrue; +} + +/* FUnknown methods */ +tresult PLUGIN_API VST3ComponentHolder::queryInterface(const TUID _iid, void **obj) +{ + if (FUnknownPrivate::iidEqual(_iid, IComponentHandler::iid)) { + *obj = static_cast(this); + return kResultOk; + } + if (FUnknownPrivate::iidEqual(_iid, IUnitHandler::iid)) { + *obj = static_cast(this); + return kResultOk; + } + *obj = nullptr; + return kNoInterface; +} diff --git a/plugins/obs-vst3/VST3ComponentHolder.h b/plugins/obs-vst3/VST3ComponentHolder.h new file mode 100644 index 00000000000000..388ed7396c2294 --- /dev/null +++ b/plugins/obs-vst3/VST3ComponentHolder.h @@ -0,0 +1,57 @@ +/****************************************************************************** + Copyright (C) 2025-2026 pkv + This file is part of obs-vst3. + It uses the Steinberg VST3 SDK, which is licensed under MIT license. + See https://github.com/steinbergmedia/vst3sdk for details. + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with this program. If not, see . +******************************************************************************/ +#pragma once +#include "VST3ParamEditQueue.h" +#include "pluginterfaces/vst/ivstaudioprocessor.h" +#include "pluginterfaces/vst/ivstcomponent.h" +#include "pluginterfaces/vst/ivsteditcontroller.h" +#include "pluginterfaces/vst/ivstunits.h" + +#include + +using namespace Steinberg; +using namespace Vst; + +class VST3Plugin; + +class VST3ComponentHolder : public IComponentHandler, public IUnitHandler { +public: + VST3ComponentHolder(VST3Plugin *plugin_); + ~VST3ComponentHolder() noexcept; + + //========== IComponentHandler methods ===========// + VST3ParamEditQueue *guiToDsp = nullptr; + + tresult PLUGIN_API beginEdit(ParamID) override; + tresult PLUGIN_API performEdit(ParamID id, ParamValue valueNormalized) override; + tresult PLUGIN_API endEdit(ParamID) override; + tresult PLUGIN_API restartComponent(int32 flags) override; + //========== IIUnitHandler methods ===========// + tresult PLUGIN_API notifyUnitSelection(UnitID) override; + tresult PLUGIN_API notifyProgramListChange(ProgramListID, int32) override; + + //========== FUnknown methods ===========// + tresult PLUGIN_API queryInterface(const TUID _iid, void **obj) override; + // we do not care here of the ref-counting. A plug-in call of release should not destroy this class ! + uint32 PLUGIN_API addRef() override { return 1000; } + uint32 PLUGIN_API release() override { return 1000; } + + IComponentHandler *getComponentHandler() noexcept { return static_cast(this); } + + //========== VST3 plugin ==========// + VST3Plugin *plugin = nullptr; +}; diff --git a/plugins/obs-vst3/VST3EditorWindow.h b/plugins/obs-vst3/VST3EditorWindow.h new file mode 100644 index 00000000000000..8e8a4f355b4470 --- /dev/null +++ b/plugins/obs-vst3/VST3EditorWindow.h @@ -0,0 +1,46 @@ +/****************************************************************************** + Copyright (C) 2025-2026 pkv + This file is part of obs-vst3. + It uses the Steinberg VST3 SDK, which is licensed under MIT license. + See https://github.com/steinbergmedia/vst3sdk for details. + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with this program. If not, see . +******************************************************************************/ +#pragma once +#include "pluginterfaces/gui/iplugview.h" +#include "pluginterfaces/gui/iplugviewcontentscalesupport.h" +#include +#ifdef __linux__ +#include "editor/linux/RunLoopImpl.h" +#endif + +class VST3EditorWindow { +public: +#ifdef __linux__ + VST3EditorWindow(Steinberg::IPlugView *view, const std::string &title, Display *display, RunLoopImpl *runloop); +#else + VST3EditorWindow(Steinberg::IPlugView *view, const std::string &title); +#endif + ~VST3EditorWindow(); + + bool create(int width, int height); + void show(); + void close(); + // Our design of the VST3 GUI is such that it is hidden when one clicks on the x (close). + // The reason is that we want to retain position and size of the GUI. But this can create a desync of the + // visibility state. So we need to retrieve whether the GUI was closed with a click on x. + bool getClosedState(); +#if defined(__APPLE__) + void setClosedState(bool closed); +#endif + class Impl; + Impl *impl_; +}; diff --git a/plugins/obs-vst3/VST3HostApp.cpp b/plugins/obs-vst3/VST3HostApp.cpp new file mode 100644 index 00000000000000..bcee64b1c20d62 --- /dev/null +++ b/plugins/obs-vst3/VST3HostApp.cpp @@ -0,0 +1,100 @@ +/****************************************************************************** + Copyright (C) 2025-2026 pkv + This file is part of obs-vst3. + It uses the Steinberg VST3 SDK, which is licensed under MIT license. + See https://github.com/steinbergmedia/vst3sdk for details. + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with this program. If not, see . +******************************************************************************/ +#include "VST3HostApp.h" +#include "VST3Plugin.h" +#include "pluginterfaces/vst/ivstaudioprocessor.h" +#include "pluginterfaces/vst/ivsteditcontroller.h" +#include "pluginterfaces/vst/ivstunits.h" +#include "pluginterfaces/vst/ivstmessage.h" +#ifdef _WIN32 +#include +#endif +#include +using namespace Steinberg; +using namespace Vst; + +VST3HostApp::VST3HostApp() +{ + addPlugInterfaceSupported(IComponent::iid); + addPlugInterfaceSupported(IAudioProcessor::iid); + addPlugInterfaceSupported(IEditController::iid); + addPlugInterfaceSupported(IConnectionPoint::iid); +} + +VST3HostApp::~VST3HostApp() noexcept {FUNKNOWN_DTOR} + +/* IHostApplication methods */ +tresult PLUGIN_API VST3HostApp::getName(String128 name) +{ + std::memset(name, 0, sizeof(String128)); +#if defined(_WIN32) + const char *src = "OBS VST3 Host"; + MultiByteToWideChar(CP_UTF8, 0, src, -1, (wchar_t *)name, 128); +#else + std::u16string src = u"OBS VST3 Host"; + src.copy(name, src.size()); +#endif + return kResultOk; +} + +tresult PLUGIN_API VST3HostApp::createInstance(TUID cid, TUID _iid, void **obj) +{ + if (FUnknownPrivate::iidEqual(cid, IMessage::iid) && FUnknownPrivate::iidEqual(_iid, IMessage::iid)) { + *obj = new HostMessage; + return kResultTrue; + } + if (FUnknownPrivate::iidEqual(cid, IAttributeList::iid) && + FUnknownPrivate::iidEqual(_iid, IAttributeList::iid)) { + if (auto al = HostAttributeList::make()) { + *obj = al.take(); + return kResultTrue; + } + return kOutOfMemory; + } + *obj = nullptr; + return kResultFalse; +} + +/* IPlugInterfaceSupport methods */ +tresult PLUGIN_API VST3HostApp::isPlugInterfaceSupported(const TUID _iid) +{ + auto uid = FUID::fromTUID(_iid); + if (std::find(mFUIDArray.begin(), mFUIDArray.end(), uid) != mFUIDArray.end()) + return kResultTrue; + return kResultFalse; +} + +/* FUnknown methods */ +tresult PLUGIN_API VST3HostApp::queryInterface(const TUID _iid, void **obj) +{ + if (FUnknownPrivate::iidEqual(_iid, IHostApplication::iid)) { + *obj = static_cast(this); + return kResultOk; + } + if (FUnknownPrivate::iidEqual(_iid, IPlugInterfaceSupport::iid)) { + *obj = static_cast(this); + return kResultOk; + } +#ifdef __linux__ + if (runLoop && FUnknownPrivate::iidEqual(_iid, Linux::IRunLoop::iid)) { + *obj = static_cast(runLoop); + return kResultOk; + } +#endif + *obj = nullptr; + return kNoInterface; +} diff --git a/plugins/obs-vst3/VST3HostApp.h b/plugins/obs-vst3/VST3HostApp.h new file mode 100644 index 00000000000000..0f9bd13fd77e49 --- /dev/null +++ b/plugins/obs-vst3/VST3HostApp.h @@ -0,0 +1,57 @@ +/****************************************************************************** + Copyright (C) 2025-2026 pkv + This file is part of obs-vst3. + It uses the Steinberg VST3 SDK, which is licensed under MIT license. + See https://github.com/steinbergmedia/vst3sdk for details. + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with this program. If not, see . +******************************************************************************/ +#pragma once +#ifdef __linux__ +#include "editor/linux/RunLoopImpl.h" +#endif +#include "pluginterfaces/vst/ivstpluginterfacesupport.h" +#include "public.sdk/source/vst/hosting/hostclasses.h" +using namespace Steinberg; +using namespace Vst; + +class VST3Plugin; + +class VST3HostApp : public IHostApplication, public IPlugInterfaceSupport { +public: + VST3HostApp(); + ~VST3HostApp() noexcept; + //========== IHostApplication methods ===========// + tresult PLUGIN_API getName(String128 name) override; + tresult PLUGIN_API createInstance(TUID cid, TUID _iid, void **obj) override; + + //========== IPlugInterfaceSupport methods ===========// + tresult PLUGIN_API isPlugInterfaceSupported(const TUID _iid) override; + + //========== FUnknown methods ===========// + tresult PLUGIN_API queryInterface(const TUID _iid, void **obj) override; + // we do not care here of the ref-counting. A plug-in call of release should not destroy this class ! + uint32 PLUGIN_API addRef() override { return 1000; } + uint32 PLUGIN_API release() override { return 1000; } + + FUnknown *getFUnknown() noexcept { return static_cast(static_cast(this)); } + +#ifdef __linux__ + //========== Pass Runloop ===========// + void setRunLoop(Steinberg::Linux::IRunLoop *rl) { runLoop = rl; } + +private: + Steinberg::Linux::IRunLoop *runLoop = nullptr; +#endif +private: + std::vector mFUIDArray; + void addPlugInterfaceSupported(const TUID _iid) { mFUIDArray.push_back(FUID::fromTUID(_iid)); } +}; diff --git a/plugins/obs-vst3/VST3ParamEditQueue.h b/plugins/obs-vst3/VST3ParamEditQueue.h new file mode 100644 index 00000000000000..1fccb367328e46 --- /dev/null +++ b/plugins/obs-vst3/VST3ParamEditQueue.h @@ -0,0 +1,52 @@ +/* Copyright (c) 2025 pkv + * This file is part of obs-vst3. + * + * Portions are derived from EasyVst (https://github.com/iffyloop/EasyVst), + * licensed under Public Domain (Unlicense) or MIT No Attribution. + * + * This file uses the Steinberg VST3 SDK, which is licensed under MIT license. + * See https://github.com/steinbergmedia/vst3sdk for details. + * + * This file and all modifications by pkv are licensed under + * the GNU General Public License, version 3 or later, to comply with the SDK license. + */ +#pragma once +#include "pluginterfaces/vst/vsttypes.h" +#include + +using namespace Steinberg; +using namespace Vst; + +struct ParamEdit { + ParamID id; + ParamValue value; +}; + +class VST3ParamEditQueue { + static constexpr size_t CAP = 1024; + ParamEdit buf[CAP]; + std::atomic head{0}, tail{0}; + +public: + bool push(const ParamEdit &e) + { + size_t h = head.load(std::memory_order_relaxed); + size_t n = (h + 1) % CAP; + if (n == tail.load(std::memory_order_acquire)) + return false; + + buf[h] = e; + head.store(n, std::memory_order_release); + return true; + } + bool pop(ParamEdit &out) + { + size_t t = tail.load(std::memory_order_relaxed); + if (t == head.load(std::memory_order_acquire)) + return false; + + out = buf[t]; + tail.store((t + 1) % CAP, std::memory_order_release); + return true; + } +}; diff --git a/plugins/obs-vst3/VST3Plugin.cpp b/plugins/obs-vst3/VST3Plugin.cpp new file mode 100644 index 00000000000000..ec0720bb54a85f --- /dev/null +++ b/plugins/obs-vst3/VST3Plugin.cpp @@ -0,0 +1,653 @@ +/****************************************************************************** + Copyright (C) 2025-2026 pkv + This file is part of obs-vst3. + It uses the Steinberg VST3 SDK, which is licensed under MIT license. + See https://github.com/steinbergmedia/vst3sdk for details. + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with this program. If not, see . +******************************************************************************/ +// on linux Qt must be included before X11 headers +#include +#include +#include +#include +#include "VST3HostApp.h" +#include "VST3ComponentHolder.h" +#include "VST3EditorWindow.h" +#include "public.sdk/source/vst/hosting/eventlist.h" +#include "public.sdk/source/common/memorystream.h" + +using namespace Steinberg; +using namespace Steinberg::Vst; + +namespace Steinberg { +const FUID IPlugView::iid(0x5BC32507, 0xD06049EA, 0xA6151B52, 0x2B755B29); +const FUID IPlugViewContentScaleSupport::iid(0x65ED9690, 0x8AC44525, 0x8AADEF7A, 0x72EA703F); +const FUID IPlugFrame::iid(0x367FAF01, 0xAFA94693, 0x8D4DA2A0, 0xED0882A3); +#if defined(__linux__) +const FUID Linux::IRunLoop::iid(0x18C35366, 0x97764F1A, 0x9C5B8385, 0x7A871389); +#endif +} // namespace Steinberg + +extern VST3HostApp *g_host_app; +#ifdef __linux__ +extern RunLoopImpl *g_run_loop; +extern Display *g_display; +/* Fix for LSP plugins which crash with NVIDIA drivers. Switch LSP UI rendering from OpenGL(GLX) -> Cairo (software). */ +static void ensure_lsp_cairo_backend() +{ + setenv("LSP_WS_LIB_GLXSURFACE", "off", 1); + infovst3plugin("Workaround for LSP plugins on linux; LSP_WS_LIB_GLXSURFACE=off set (forcing Cairo backend)\n"); +} +#endif + +VST3Plugin::VST3Plugin() +{ + if (qApp && QThread::currentThread() != qApp->thread()) + this->moveToThread(qApp->thread()); + + if (!hostContext) { + hostContext = g_host_app; + } + + componentContext = new VST3ComponentHolder(this); + +#ifdef __linux__ + ensure_lsp_cairo_backend(); + display = g_display; + XSync(display, False); + runLoop = g_run_loop; +#endif +}; + +VST3Plugin::~VST3Plugin() +{ + if (view) { + if (window) { + hideEditor(); + view->removed(); + view->setFrame(nullptr); + } + } + view = nullptr; + + if (window) { + delete window; + window = nullptr; + } + + if (processData.inputEvents) { + auto *in = dynamic_cast(processData.inputEvents); + delete in; + processData.inputEvents = nullptr; + } + if (processData.outputEvents) { + auto *out = dynamic_cast(processData.outputEvents); + delete out; + processData.outputEvents = nullptr; + } + processData.unprepare(); + processData = {}; + processSetup = {}; + processContext = {}; + + plugProvider = nullptr; + module = nullptr; + + inAudioBusInfos.clear(); + outAudioBusInfos.clear(); + numInAudioBuses = 0; + numOutAudioBuses = 0; + inSpeakerArrs.clear(); + outSpeakerArrs.clear(); + sampleRate = 0; + maxBlockSize = 0; + symbolicSampleSize = 0; + realtime = false; + + path = ""; + name = ""; + + if (componentContext) { + delete componentContext; + } +} + +void VST3Plugin::deactivateComponent() const +{ + vstPlug->setActive(false); +} + +/** Buses :enable, scans and sets up input/output audio buses (called during init) */ +void VST3Plugin::setBusActive(MediaType type, BusDirection direction, int which, bool active) const +{ + vstPlug->activateBus(type, direction, which, active); +} + +bool VST3Plugin::scanAudioBuses(SpeakerArrangement arr) +{ + int countAuxBus = 0; + int countMainBus = 0; + numInAudioBuses = vstPlug->getBusCount(MediaTypes::kAudio, BusDirections::kInput); + numOutAudioBuses = vstPlug->getBusCount(MediaTypes::kAudio, BusDirections::kOutput); + + infovst3plugin("Input audio buses: %i\n Output audio buses: %i\n", numInAudioBuses, numOutAudioBuses); + + inAudioBusInfos.clear(); + inSpeakerArrs.clear(); + outAudioBusInfos.clear(); + outSpeakerArrs.clear(); + // We enable the 1st compatible Main bus and the 1st Aux bus + for (int i = 0; i < numInAudioBuses; ++i) { + BusInfo info = {}; + vstPlug->getBusInfo(kAudio, kInput, i, info); + inAudioBusInfos.push_back(info); + bool isMain = (info.busType == Steinberg::Vst::BusTypes::kMain); + // only 1 Main input bus is enabled by obs + 1 Aux (side-channel) Bus if it is available + if (isMain) { + if (countMainBus == 0) { + setBusActive(kAudio, kInput, i, true); + mainInputBusNumChannels = info.channelCount; + mainInputBusIndex = i; + inSpeakerArrs.push_back(arr); + numEnabledInAudioBuses++; + countMainBus = 1; + } else { + setBusActive(kAudio, kInput, i, false); + } + } else { + // The 1st aux bus (sidechannel) is enabled only if it is mono or stereo + if (countAuxBus == 0 && (info.channelCount == 1 || info.channelCount == 2)) { + setBusActive(kAudio, kInput, i, true); + SpeakerArrangement speakerArr = info.channelCount == 1 + ? Steinberg::Vst::SpeakerArr::kMono + : Steinberg::Vst::SpeakerArr::kStereo; + inSpeakerArrs.push_back(speakerArr); + numEnabledInAudioBuses++; + sidechainNumChannels = info.channelCount; + auxBusIndex = i; + countAuxBus = 1; + } else { + setBusActive(kAudio, kInput, i, false); + } + } + } + // We disable the plugin if it has no Input bus. + if (!numEnabledInAudioBuses) { + infovst3plugin( + "No input bus detected ! OBS VST3 Host only supports audio effects VST3 with 1 Main Input Bus (+ 1 Sidechannel Bus)."); + vstPlug->setActive(false); + return false; + } + // Only the 1st Main output bus is enabled + for (int i = 0; i < numOutAudioBuses; ++i) { + BusInfo info = {}; + vstPlug->getBusInfo(kAudio, kOutput, i, info); + outAudioBusInfos.push_back(info); + bool isMain = (info.busType == Steinberg::Vst::BusTypes::kMain); + if (isMain && !numEnabledOutAudioBuses) { + setBusActive(kAudio, kOutput, i, isMain); + mainOutputBusIndex = i; + mainOutputBusNumChannels = info.channelCount; + outSpeakerArrs.push_back(arr); + numEnabledOutAudioBuses++; + return true; + } + } + return false; +} + +/** initialization : loading of the VST3 and of the various required interfaces */ +bool VST3Plugin::init(const std::string &classId, const std::string &path_, int sample_rate, int max_blocksize, + SpeakerArrangement arr) +{ + std::string error; + + path = path_; + + sampleRate = sample_rate; + maxBlockSize = max_blocksize; + symbolicSampleSize = kSample32; + realtime = kRealtime; + + processSetup.processMode = realtime; + processSetup.symbolicSampleSize = symbolicSampleSize; + processSetup.sampleRate = sampleRate; + processSetup.maxSamplesPerBlock = maxBlockSize; + + processContext.state = ProcessContext::kPlaying | ProcessContext::kRecording | ProcessContext::kSystemTimeValid; + processContext.sampleRate = sampleRate; + + processData.numSamples = 0; + processData.symbolicSampleSize = symbolicSampleSize; + processData.processContext = &processContext; + + // module creation + module = VST3::Hosting::Module::create(path, error); + if (!module) { + infovst3plugin("%s", error.c_str()); + return false; + } + + // enable parameter changes + componentContext->guiToDsp = &guiToDsp; + inChanges = std::make_unique(); + outChanges = std::make_unique(); + + // get factory & set host context + VST3::Hosting::PluginFactory factory = module->getFactory(); + factory.setHostContext(hostContext->getFUnknown()); + + // use plugProvider to retrieve & setup component, processor & controller + for (auto &classInfo : factory.classInfos()) { + if (classInfo.category() == kVstAudioEffectClass && classInfo.ID().toString() == classId) { + if (classId != classInfo.ID().toString()) + continue; + plugProvider = owned(new OBSPlugProvider(factory, classInfo, false)); + if (plugProvider->setup(hostContext->getFUnknown()) == false) + plugProvider = nullptr; + name = classInfo.name(); + break; + } + } + if (!plugProvider) { + infovst3plugin("No VST3 Audio Module Class with UID %s found. You probably uninstalled the VST3.", + classId.c_str()); + return false; + } + + vstPlug = plugProvider->getComponentPtr(); // IComponent* + if (!vstPlug) { + infovst3plugin("No VST3 Component class found."); + return false; + } + + editController = plugProvider->getControllerPtr(); // IEditController* + if (!editController) { + infovst3plugin("No VST3 EditorController class found."); + return false; + } else { + editController->setComponentHandler(componentContext->getComponentHandler()); + } + + audioEffect = FUnknownPtr(vstPlug).getInterface(); + if (!audioEffect) { + infovst3plugin("Failed to get an audio processor from VST3"); + // try to get audioProcessor from EditorController, à la Juce, from badly coded VST3. + audioEffect = FUnknownPtr(editController).getInterface(); + if (!audioEffect) + return false; + } + + // Getting the audio buses; event buses (for midi) are not scanned. + if (!scanAudioBuses(arr)) { + obsVst3Struct->bypass.store(true, std::memory_order_relaxed); + infovst3plugin("Error during the bus scan."); + return false; + } + + // Some plug-ins will crash if we pass a nullptr to setBusArrangements! + SpeakerArrangement nullArrangement = {}; + auto *inData = inSpeakerArrs.empty() ? &nullArrangement : inSpeakerArrs.data(); + auto *outData = outSpeakerArrs.empty() ? &nullArrangement : outSpeakerArrs.data(); + tresult res = audioEffect->setBusArrangements(inData, numEnabledInAudioBuses, outData, numEnabledOutAudioBuses); + if (res != kResultTrue) { + // We check the speaker arrangements to detect what went wrong. + SpeakerArrangement speakerArr; + audioEffect->getBusArrangement(kInput, mainInputBusIndex, speakerArr); + if (speakerArr != arr) { + infovst3plugin("Failed to set input bus to obs speaker layout."); + return false; + } + + if (numEnabledInAudioBuses == 2) { + audioEffect->getBusArrangement(kInput, auxBusIndex, speakerArr); + SpeakerArrangement sideArr = sidechainNumChannels == 1 ? Steinberg::Vst::SpeakerArr::kMono + : Steinberg::Vst::SpeakerArr::kStereo; + if (speakerArr != sideArr) { + infovst3plugin("Failed to set side chain bus to desired speaker layout!"); + return false; + } + } + + audioEffect->getBusArrangement(kOutput, mainOutputBusIndex, speakerArr); + if (speakerArr != arr) { + infovst3plugin("Failed to set output bus to obs speaker layout."); + return false; + } + } + + // End of setup stage, activation of VST3 + res = audioEffect->setupProcessing(processSetup); + if (res == kResultOk) { + processData.prepare(*vstPlug, maxBlockSize, processSetup.symbolicSampleSize); + // silence outputs on preparation, safety move + for (int32 busIdx = 0; busIdx < processData.numOutputs; ++busIdx) { + auto &bus = processData.outputs[busIdx]; + + if (bus.channelBuffers32) { + for (int32 ch = 0; ch < bus.numChannels; ++ch) + std::fill_n(bus.channelBuffers32[ch], maxBlockSize, 0.0f); + } + } + } else { + infovst3plugin("Failed to setup VST3 processing."); + return false; + } + + if (vstPlug->setActive(true) != kResultTrue) { + infovst3plugin("Failed to activate VST3 component."); + return false; + } + // this often reports 0, which probably means that the VST3 authors didn't really measure the value, lol + uint32 latency = audioEffect->getLatencySamples(); + infovst3plugin("Latency of the plugin is %i samples", latency); + + return true; +} + +/** The preprocess and postprocess functions deal with the exchange of parameters between GUI Editor and AudioProcessor. */ +void VST3Plugin::drainDspToGui() +{ + ParamEdit e = {}; + while (dspToGui.pop(e)) { + if (editController) + editController->setParamNormalized(e.id, e.value); + } + uiDrainScheduled.store(false, std::memory_order_release); +} + +void VST3Plugin::preprocess() +{ + inChanges->clearQueue(); + outChanges->clearQueue(); + processData.inputParameterChanges = inChanges.get(); + processData.outputParameterChanges = outChanges.get(); + + // Drain GUI->DSP + if (processData.inputParameterChanges) { + ParamEdit e = {}; + while (guiToDsp.pop(e)) { + int32 idx = -1; + if (auto *q = processData.inputParameterChanges->addParameterData(e.id, idx)) { + int32 pt = 0; + q->addPoint(0, e.value, pt); + } + } + } +} + +void VST3Plugin::postprocess() +{ + bool hadUpdate = false; + if (auto *out = processData.outputParameterChanges) { + for (int32 i = 0, n = out->getParameterCount(); i < n; ++i) { + if (auto *q = out->getParameterData(i)) { + const auto id = q->getParameterId(); + const int32 cnt = q->getPointCount(); + if (cnt > 0) { + int32 off; + Steinberg::Vst::ParamValue v; + if (q->getPoint(cnt - 1, off, v) == Steinberg::kResultOk) { + dspToGui.push({id, v}); + hadUpdate = true; + } + } + } + } + } + + if (hadUpdate && !uiDrainScheduled.exchange(true, std::memory_order_acq_rel)) { + QObject *receiver = QCoreApplication::instance(); + if (receiver) { + QMetaObject::invokeMethod(receiver, [this] { this->drainDspToGui(); }, Qt::QueuedConnection); + } else { + uiDrainScheduled.store(false, std::memory_order_release); + } + } +} + +void VST3Plugin::setProcessing(bool processing) const +{ + audioEffect->setProcessing(processing); +} + +/** the main processing function & buffers */ +bool VST3Plugin::process(int numSamples) +{ + if (!audioEffect) + return false; + + preprocess(); + + if (numSamples > maxBlockSize) { +#ifdef _DEBUG + infovst3plugin("numSamples > _maxBlockSize"); +#endif + numSamples = maxBlockSize; + } + + processData.numSamples = numSamples; + processContext.projectTimeSamples += numSamples; + processContext.systemTime = static_cast(os_gettime_ns()); + + tresult result = audioEffect->process(processData); + + if (result != kResultOk) { + return false; + } + + postprocess(); + + return true; +} + +Steinberg::Vst::Sample32 *VST3Plugin::channelBuffer32(const BusDirection direction, const int ch) const +{ + if (direction == kInput) { + return processData.inputs[mainInputBusIndex].channelBuffers32[ch]; + } else if (direction == kOutput) { + return processData.outputs[mainOutputBusIndex].channelBuffers32[ch]; + } else { + return nullptr; + } +} + +Steinberg::Vst::Sample32 *VST3Plugin::sidechannelBuffer32(const BusDirection direction, const int ch) const +{ + if (direction == kInput) { + return processData.inputs[auxBusIndex].channelBuffers32[ch]; + } else { + return nullptr; + } +} + +/** GUI functions w/ a hack ripped from Juce, to create the view even with badly coded VST3s... */ +void VST3Plugin::tryCreatingView() +{ + view = editController->createView(Vst::ViewType::kEditor); + + if (view == nullptr) + view = editController->createView(nullptr); + + if (view == nullptr) + editController->queryInterface(IPlugView::iid, reinterpret_cast(&view)); +} + +bool VST3Plugin::createView() +{ + if (!editController) { + infovst3plugin("VST3 does not provide an edit controller"); + return false; + } + + if (view) { + debugvst3plugin("Editor view or window already exists"); + return false; + } else { + tryCreatingView(); + if (view) + view->release(); + } + + if (!view) { + infovst3plugin("EditController does not provide its own view"); + return false; + } + +#ifdef _WIN32 + if (view->isPlatformTypeSupported(Steinberg::kPlatformTypeHWND) != Steinberg::kResultTrue) { + infovst3plugin("Editor view does not support HWND"); + return false; + } +#elif defined(__APPLE__) + if (view->isPlatformTypeSupported(Steinberg::kPlatformTypeNSView) != Steinberg::kResultTrue) { + infovst3plugin("Editor view does not support NSView"); + return false; + } +#elif defined(__linux__) + if (view->isPlatformTypeSupported(Steinberg::kPlatformTypeX11EmbedWindowID) != Steinberg::kResultTrue) { + infovst3plugin("Editor view does not support X11"); + return false; + } +#else + infovst3plugin("Platform is not supported yet"); + return false; +#endif + + return true; +} + +void VST3Plugin::showEditor() +{ + if (!window) { + int width = 800, height = 600; + if (view) { + Steinberg::ViewRect rect; + if (view->getSize(&rect) == Steinberg::kResultOk) { + width = rect.getWidth(); + height = rect.getHeight(); + } else { + infovst3plugin("Failed to get size before attaching an IFrame. Not SDK compliant."); + } + } + std::string sourceName = obs_source_get_name(obsVst3Struct->context); + std::string windowName = sourceName + ": VST3 Plugin - " + name; +#ifdef __linux__ + window = new VST3EditorWindow(view, windowName, display, runLoop); +#else + window = new VST3EditorWindow(view, windowName); +#endif + if (window->create(width, height)) { + window->show(); + } + } else { + window->show(); + } + editorVisible = true; +} + +void VST3Plugin::hideEditor() +{ + if (window && view) { + window->close(); + } + editorVisible = false; +} + +// This function is required because we don't really close the GUI window; we hide it on Windows & macOS. +// This then means we have to track when a GUI has been closed by the user when clicking X. I decided to just hide because +// creating the GUI each time the user wants to display it, was prone to crashes. This also simplified the coding. +bool VST3Plugin::isEditorVisible() +{ + if (window) { + bool wasClosed = window->getClosedState(); + if (wasClosed && editorVisible) { + editorVisible = false; + } + } + return editorVisible; +} + +/** Load/Save the VST3 settings */ +bool VST3Plugin::saveStates(std::vector &compOut, std::vector &ctrlOut) const +{ + compOut.clear(); + ctrlOut.clear(); + + if (!vstPlug) + return false; + + // --- component save + { + Steinberg::MemoryStream s; + if (vstPlug->getState(&s) != Steinberg::kResultOk) + return false; + + Steinberg::int64 size = 0, seekRes = 0; + s.tell(&size); + if (size <= 0) + return false; + + compOut.resize(static_cast(size)); + s.seek(0, Steinberg::IBStream::kIBSeekSet, &seekRes); + Steinberg::int32 actuallyRead = 0; + s.read(compOut.data(), static_cast(size), &actuallyRead); + if (actuallyRead < size) + compOut.resize(static_cast(actuallyRead)); + } + + // --- controller save + if (editController) { + Steinberg::MemoryStream s; + if (editController->getState(&s) == Steinberg::kResultOk) { + Steinberg::int64 size = 0, seekRes = 0; + s.tell(&size); + if (size > 0) { + ctrlOut.resize(static_cast(size)); + s.seek(0, Steinberg::IBStream::kIBSeekSet, &seekRes); + Steinberg::int32 actuallyRead = 0; + s.read(ctrlOut.data(), static_cast(size), &actuallyRead); + if (actuallyRead < size) + ctrlOut.resize(static_cast(actuallyRead)); + } + } + } + + return true; +} + +bool VST3Plugin::loadStates(const std::vector &comp, const std::vector &ctrl) +{ + if (!vstPlug || comp.empty()) + return false; + + Steinberg::MemoryStream compStream; + Steinberg::int32 w = 0; + Steinberg::int64 dummy = 0; + + compStream.write((void *)comp.data(), (Steinberg::int32)comp.size(), &w); + compStream.seek(0, Steinberg::IBStream::kIBSeekSet, &dummy); + if (vstPlug->setState(&compStream) != Steinberg::kResultOk) + return false; + + if (editController) { + compStream.seek(0, Steinberg::IBStream::kIBSeekSet, &dummy); + (void)editController->setComponentState(&compStream); + if (!ctrl.empty()) { + Steinberg::MemoryStream ctrlStream; + ctrlStream.write((void *)ctrl.data(), (Steinberg::int32)ctrl.size(), &w); + ctrlStream.seek(0, Steinberg::IBStream::kIBSeekSet, &dummy); + (void)editController->setState(&ctrlStream); + } + } + return true; +} diff --git a/plugins/obs-vst3/VST3Plugin.h b/plugins/obs-vst3/VST3Plugin.h new file mode 100644 index 00000000000000..451b4c65b1d106 --- /dev/null +++ b/plugins/obs-vst3/VST3Plugin.h @@ -0,0 +1,149 @@ +/****************************************************************************** + Copyright (C) 2025-2026 pkv + This file is part of obs-vst3. + It uses the Steinberg VST3 SDK, which is licensed under MIT license. + See https://github.com/steinbergmedia/vst3sdk for details. + Portions are derived from EasyVst (https://github.com/iffyloop/EasyVst), + licensed under Public Domain (Unlicense) or MIT No Attribution. + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with this program. If not, see . +******************************************************************************/ +#pragma once +#include "obs-vst3.h" +#include "VST3ParamEditQueue.h" + +// on linux Qt must be included before X11 headers +#include "VST3ComponentHolder.h" + +#include +#include "pluginterfaces/base/smartpointer.h" +#include "pluginterfaces/gui/iplugview.h" +#include "pluginterfaces/gui/iplugviewcontentscalesupport.h" +#include "pluginterfaces/vst/ivstprocesscontext.h" +#include "pluginterfaces/vst/ivstmessage.h" + +#include "public.sdk/source/vst/hosting/plugprovider.h" +#include "public.sdk/source/vst/hosting/module.h" +#include "public.sdk/source/vst/hosting/parameterchanges.h" +#include "public.sdk/source/vst/hosting/processdata.h" + +#ifdef __linux__ +struct _XDisplay; +typedef _XDisplay Display; +class RunLoopImpl; +#endif + +#define do_logVST3(level, format, ...) \ + blog(level, "[VST3 Plugin:] " format, ## __VA_ARGS__) + +#define warnvst3plugin(format, ...) do_logVST3(LOG_WARNING, format, ## __VA_ARGS__) +#define infovst3plugin(format, ...) do_logVST3(LOG_INFO, format, ## __VA_ARGS__) +#define debugvst3plugin(format, ...) do_logVST3(LOG_DEBUG, format, ## __VA_ARGS__) + +using namespace Steinberg; +using namespace Vst; + +class VST3EditorWindow; +class VST3ComponentHolder; +class VST3HostApp; + +/** + * PlugProvider is a helper class from the sdk which takes care of initializing an IComponent & an IEditController w/ + * the hostContext & detects if the plugin bundled the 2 interfaces; in case they are distinct as advised by the SDK, + * it takes care of connecting them as IConnectionPoints. We wrap it into our own tiny class to be able to do the setup + * directly. + * */ +class OBSPlugProvider : public PlugProvider { +public: + using PlugProvider::PlugProvider; + bool setup(FUnknown *context) { return setupPlugin(context); } +}; + +class VST3Plugin : public QObject { + Q_OBJECT +public: + VST3Plugin(); + ~VST3Plugin(); + + // glue to obs-vst3 filter + struct vst3_audio_data *obsVst3Struct = nullptr; + + // Basic Interfaces + VST3HostApp *hostContext = nullptr; + VST3ComponentHolder *componentContext = nullptr; + VST3::Hosting::Module::Ptr module = nullptr; + IPtr plugProvider = nullptr; + IComponent *vstPlug = nullptr; + IAudioProcessor *audioEffect = nullptr; + IEditController *editController = nullptr; + HostProcessData processData = {}; + ProcessSetup processSetup = {}; + ProcessContext processContext = {}; + + IPtr view = nullptr; + + // load and save VST3 state + bool loadStates(const std::vector &comp, const std::vector &ctrl); + bool saveStates(std::vector &compOut, std::vector &ctrlOut) const; + + // initialization stage + bool scanAudioBuses(SpeakerArrangement arr); + void setBusActive(MediaType type, BusDirection direction, int which, bool active) const; + bool init(const std::string &classId, const std::string &path, int sampleRate, int maxBlockSize, + SpeakerArrangement arr); + void deactivateComponent() const; + + // processing stage + void setProcessing(bool processing) const; + bool process(int numSamples); + void preprocess(); + void postprocess(); + [[nodiscard]] Sample32 *channelBuffer32(BusDirection direction, int which) const; + [[nodiscard]] Sample32 *sidechannelBuffer32(BusDirection direction, int ch) const; + // GUI Editor window + void tryCreatingView(); + bool createView(); + void showEditor(); + void hideEditor(); + VST3EditorWindow *window = nullptr; + bool editorVisible = false; + bool isEditorVisible(); + + // Communication EditController <=> Processor IComponent / IAudioProcessor + VST3ParamEditQueue guiToDsp; + VST3ParamEditQueue dspToGui; + std::unique_ptr inChanges; + std::unique_ptr outChanges; + std::atomic uiDrainScheduled{false}; + void drainDspToGui(); + + // buses & audio + int sampleRate = 0, maxBlockSize = 0, symbolicSampleSize = 0; + bool realtime = kRealtime; + std::vector inAudioBusInfos, outAudioBusInfos; + int numInAudioBuses = 0, numOutAudioBuses = 0; + int numEnabledInAudioBuses = 0, numEnabledOutAudioBuses = 0; + int mainInputBusNumChannels = 0, mainOutputBusNumChannels = 0; + int sidechainNumChannels = 0; + int mainInputBusIndex = 0, mainOutputBusIndex = 0; + int auxBusIndex = 0; + std::vector inSpeakerArrs, outSpeakerArrs; + + // misc + std::string path; + std::string name; + +#ifdef __linux__ + Display *display; + RunLoopImpl *runLoop; +#endif +}; diff --git a/plugins/obs-vst3/VST3Scanner.cpp b/plugins/obs-vst3/VST3Scanner.cpp new file mode 100644 index 00000000000000..15867b166f6a1b --- /dev/null +++ b/plugins/obs-vst3/VST3Scanner.cpp @@ -0,0 +1,293 @@ +/****************************************************************************** + Copyright (C) 2025-2026 pkv + This file is part of obs-vst3. + It uses the Steinberg VST3 SDK, which is licensed under MIT license. + See https://github.com/steinbergmedia/vst3sdk for details. + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with this program. If not, see . +******************************************************************************/ +#include "VST3Scanner.h" +#include "util/bmem.h" +#include + +std::vector VST3Scanner::getDefaultSearchPaths() +{ + std::vector paths; + +#ifdef _WIN32 + char *programFiles = std::getenv("ProgramFiles"); + if (programFiles) + paths.emplace_back(std::string(programFiles) + "\\Common Files\\VST3"); + + char *localAppData = std::getenv("LOCALAPPDATA"); + if (localAppData) + paths.emplace_back(std::string(localAppData) + "\\Programs\\Common\\VST3"); +#elif defined(__APPLE__) + paths.emplace_back("/Library/Audio/Plug-Ins/VST3"); + if (const char *home = std::getenv("HOME")) + paths.emplace_back(std::string(home) + "/Library/Audio/Plug-Ins/VST3"); +#elif defined(__linux__) + paths.emplace_back("/usr/lib/vst3"); + paths.emplace_back("/usr/local/lib/vst3"); + if (const char *home = std::getenv("HOME")) + paths.emplace_back(std::string(home) + "/.vst3"); +#endif + return paths; +} + +// this retrieves the location of all VST3s (files on windows and dirs on other OSes) +std::unordered_set VST3Scanner::getVST3Paths() +{ + std::unordered_set fsPaths; + + for (const auto &folder : getDefaultSearchPaths()) { + if (!std::filesystem::exists(folder)) + continue; + + for (const auto &entry : std::filesystem::recursive_directory_iterator(folder)) { + if (!entry.exists()) + continue; + +#if defined(_WIN32) + if (entry.is_regular_file() && entry.path().extension() == ".vst3") +#else + if (entry.is_directory() && entry.path().extension() == ".vst3") +#endif + fsPaths.insert(entry.path().string()); + } + } + + return fsPaths; +} + +static std::string lowerAscii(std::string s) noexcept +{ + for (auto &c : s) + if (c >= 'A' && c <= 'Z') + c = static_cast(c - 'A' + 'a'); + return s; +} + +// Alphabetical sorting of vst3 classes; note that a VST3 plugin can have multiple classes (ex: LSP). The sorting is +// done across VST3s. In case of multiple classes, the VST3 name is appended. +void VST3Scanner::sort() +{ + std::sort(pluginList.begin(), pluginList.end(), [](const VST3ClassInfo &a, const VST3ClassInfo &b) { + const auto an = lowerAscii(a.name); + const auto bn = lowerAscii(b.name); + if (an != bn) + return an < bn; + + const auto ap = lowerAscii(a.pluginName); + const auto bp = lowerAscii(b.pluginName); + if (ap != bp) + return ap < bp; + + if (a.path != b.path) + return a.path < b.path; + + return a.id < b.id; + }); +} + +bool VST3Scanner::hasVST3() +{ + auto paths = getDefaultSearchPaths(); + + for (const auto &folder : paths) { + if (!std::filesystem::exists(folder)) + continue; + + try { + for (const auto &entry : std::filesystem::directory_iterator(folder)) { + if (!entry.exists()) + continue; + + const auto &p = entry.path(); + +#if defined(_WIN32) + if (entry.is_regular_file() && p.extension() == ".vst3") + return true; +#elif defined(__APPLE__) || defined(__linux__) + if (entry.is_directory() && p.extension() == ".vst3") + return true; +#endif + } + } catch (const std::exception &e) { + (void)e; + } + } + + return false; +} + +// try to load the moduleinfo.json when provided by VST3 +bool VST3Scanner::tryReadModuleInfo(const std::string &bundlePath) +{ + namespace fs = std::filesystem; + + fs::path p(bundlePath); + fs::path bundleRoot; + +#ifdef _WIN32 + if (!fs::is_regular_file(p)) + return false; + + // path is /Contents/Resources/moduleinfo.json + bundleRoot = p.parent_path().parent_path().parent_path(); +#else + if (!fs::is_directory(p)) + return false; + + bundleRoot = p; +#endif + + fs::path jsonPath = bundleRoot / "Contents" / "Resources" / "moduleinfo.json"; + + if (!fs::exists(jsonPath) || !fs::is_regular_file(jsonPath)) + return false; + + char *jsonBuf = os_quick_read_utf8_file(jsonPath.string().c_str()); + if (!jsonBuf) + return false; + + std::string json(jsonBuf); + bfree(jsonBuf); + + auto parsed = Steinberg::ModuleInfoLib::parseJson(json, nullptr); + if (!parsed) + return false; + + bool discardable = parsed.value().factoryInfo.flags & Steinberg::PFactoryInfo::kClassesDiscardable; + + if (discardable) + return addModuleClasses(bundlePath); + + return loadFromModuleInfo(*parsed, bundleRoot.string()); +} + +// load classes from moduleinfo.json +bool VST3Scanner::loadFromModuleInfo(const Steinberg::ModuleInfo &info, const std::string &bundleRoot) +{ + const std::string pluginName = std::filesystem::path(bundleRoot).stem().string(); + size_t added = 0; + bool discardable = info.factoryInfo.flags & Steinberg::PFactoryInfo::kClassesDiscardable; + for (const auto &c : info.classes) { + // We only accept audio effects, not MIDI nor instruments ... + if (c.category != kVstAudioEffectClass) + continue; + + VST3ClassInfo entry; + entry.id = c.cid; + entry.name = c.name; + entry.path = bundleRoot; + entry.pluginName = pluginName; + entry.discardable = discardable; + + pluginList.push_back(std::move(entry)); + ++classCount[bundleRoot]; + ++added; + } + + return added > 0; +} + +// this updates the json in case some VST3s were installed or removed since last obs startup +void VST3Scanner::updateModulesList(std::unordered_map &modules, + const std::unordered_set &cachedPaths) +{ + std::unordered_set fsPaths = getVST3Paths(); + + for (auto it = modules.begin(); it != modules.end();) { + if (!fsPaths.count(it->first)) + it = modules.erase(it); + else + ++it; + } + + for (const auto &path : fsPaths) { + if (!cachedPaths.count(path)) { + if (!tryReadModuleInfo(path)) + addModuleClasses(path); + } + } +} + +// load classes directly from binary; very close to previous function, a pity that factorization is not possible. +bool VST3Scanner::addModuleClasses(const std::string &bundlePath) +{ + std::string error; + const std::string pluginName = std::filesystem::path(bundlePath).stem().string(); + size_t added = 0; + VST3::Hosting::Module::Ptr module = VST3::Hosting::Module::create(bundlePath, error); + + if (!module) { + blog(LOG_ERROR, "[VST3 Scanner] Module failed to load with error %s", error.c_str()); + return false; + } + + VST3::Hosting::PluginFactory factory = module->getFactory(); + bool discardable = factory.info().classesDiscardable(); + for (const auto &classInfo : factory.classInfos()) { + if (classInfo.category() == kVstAudioEffectClass) { + VST3ClassInfo entry; + entry.id = classInfo.ID().toString(); + entry.name = classInfo.name(); + entry.pluginName = pluginName; + entry.path = bundlePath; + entry.discardable = discardable; + + pluginList.push_back(std::move(entry)); + ++classCount[bundlePath]; + ++added; + } + } + + return added > 0; +} + +// scan done by fully loading the module, very costly in time so this is done on a separate thread +bool VST3Scanner::scanForVST3Plugins() +{ + pluginList.clear(); + classCount.clear(); + auto paths = getVST3Paths(); + + for (const auto &bundlePath : paths) { + if (!tryReadModuleInfo(bundlePath)) + addModuleClasses(bundlePath); + } + sort(); + return !pluginList.empty(); +} + +bool VST3Scanner::ModWithMultipleClasses(const std::string &bundlePath) const +{ + auto it = classCount.find(bundlePath); + return it != classCount.end() && it->second > 1; +} + +std::string VST3Scanner::getNameById(const std::string &class_id) const +{ + for (const auto &c : pluginList) + if (c.id == class_id) + if (c.id == class_id) + return c.path; + return {}; +} + +std::string VST3Scanner::getPathById(const std::string &class_id) const +{ + for (const auto &c : pluginList) + if (c.id == class_id) + return c.path; + return {}; +} diff --git a/plugins/obs-vst3/VST3Scanner.h b/plugins/obs-vst3/VST3Scanner.h new file mode 100644 index 00000000000000..80c69f54af9b3e --- /dev/null +++ b/plugins/obs-vst3/VST3Scanner.h @@ -0,0 +1,67 @@ +/****************************************************************************** + Copyright (C) 2025-2026 pkv + This file is part of obs-vst3. + It uses the Steinberg VST3 SDK, which is licensed under MIT license. + See https://github.com/steinbergmedia/vst3sdk for details. + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with this program. If not, see . +******************************************************************************/ +#pragma once +#include "public.sdk/source/vst/hosting/module.h" +#include "public.sdk/source/vst/moduleinfo/moduleinfo.h" +#include "public.sdk/source/vst/moduleinfo/moduleinfoparser.h" +#include +#include +#include +#include +#include +#include +#ifdef _WIN32 +#include +#endif + +#ifndef kVstAudioEffectClass +#define kVstAudioEffectClass "Audio Module Class" +#endif + +struct VST3ClassInfo { + std::string name; + std::string id; + std::string path; + std::string pluginName; + bool discardable; // the classes need to be reloaded from the module at each host startup and can not be cached +}; + +struct ModuleCache { + bool discardable = false; + std::vector classes; +}; + +class VST3Scanner { +public: + std::vector getDefaultSearchPaths(); + std::string getNameById(const std::string &class_id) const; + std::string getPathById(const std::string &class_id) const; + bool ModWithMultipleClasses(const std::string &bundlePath) const; + bool hasVST3(); + bool addModuleClasses(const std::string &bundlePath); + bool scanForVST3Plugins(); + std::vector pluginList; + void sort(); + std::unordered_map classCount; + void updateModulesList(std::unordered_map &modules, + const std::unordered_set &cachedPaths); + +private: + std::unordered_set getVST3Paths(); + bool tryReadModuleInfo(const std::string &bundlePath); + bool loadFromModuleInfo(const Steinberg::ModuleInfo &info, const std::string &bundlePath); +}; diff --git a/plugins/obs-vst3/cmake/windows/obs-module.rc.in b/plugins/obs-vst3/cmake/windows/obs-module.rc.in new file mode 100644 index 00000000000000..538bab872d2ce2 --- /dev/null +++ b/plugins/obs-vst3/cmake/windows/obs-module.rc.in @@ -0,0 +1,27 @@ +#define IDI_OBSICON 101 +IDI_OBSICON ICON "obs-studio.ico" + +1 VERSIONINFO +FILEVERSION ${OBS_VERSION_MAJOR},${OBS_VERSION_MINOR},${OBS_VERSION_PATCH},0 +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904B0" + BEGIN + VALUE "CompanyName", "${OBS_COMPANY_NAME}" + VALUE "FileDescription", "VST3 filters" + VALUE "FileVersion", "${OBS_VERSION_CANONICAL}" + VALUE "ProductName", "${OBS_PRODUCT_NAME}" + VALUE "ProductVersion", "${OBS_VERSION_CANONICAL}" + VALUE "Comments", "${OBS_COMMENTS}" + VALUE "LegalCopyright", "${OBS_LEGAL_COPYRIGHT}" + VALUE "InternalName", "obs-vst3" + VALUE "OriginalFilename", "obs-vst3" + END + END + + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x0409, 0x04B0 + END +END diff --git a/plugins/obs-vst3/cmake/windows/obs-studio.ico b/plugins/obs-vst3/cmake/windows/obs-studio.ico new file mode 100644 index 0000000000000000000000000000000000000000..7127372917d0ae308412302998833d60e420fd4f GIT binary patch literal 93455 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

C#Vyy0Xt+}qRZF9tgd$_hM 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 + This file is part of obs-vst3. + It uses the Steinberg VST3 SDK, which is licensed under MIT license. + See https://github.com/steinbergmedia/vst3sdk for details. + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with this program. If not, see . +******************************************************************************/ +#include +#include +#include "RunLoopImpl.h" + +RunLoopImpl::RunLoopImpl(Display *dpy) : QObject(), display(dpy) +{ + hostEventTimer = new QTimer(this); + hostEventTimer->setSingleShot(false); + hostEventTimer->setInterval(20); + QObject::connect(hostEventTimer, &QTimer::timeout, [this]() { this->tickHostEvents(); }); +} + +void RunLoopImpl::UpdateTimer(TimerSlot *slot) +{ + if (stopping || !slot) + return; + + auto *handler = slot->handler; + if (handler) + handler->onTimer(); +} + +void RunLoopImpl::tickHostEvents() +{ + if (stopping || windowHandlers.empty()) + return; + + static const long mask = StructureNotifyMask | SubstructureNotifyMask | PropertyChangeMask | FocusChangeMask | + ExposureMask; + XEvent ev; + + auto it = windowHandlers.begin(); + while (it != windowHandlers.end()) { + Window w = it->first; + auto handler = it->second; + bool erased = false; + while (XCheckWindowEvent(display, w, mask, &ev)) { + if (ev.type == DestroyNotify) { + it = windowHandlers.erase(it); + erased = true; + break; + } + handler(ev); + } + if (erased) + continue; + + while (XCheckTypedWindowEvent(display, w, ClientMessage, &ev)) + handler(ev); + + ++it; + } +} + +void RunLoopImpl::dispatchFD(int fd) +{ + if (stopping) + return; + + Steinberg::Linux::IEventHandler *handler = nullptr; + { + std::lock_guard lock(eventMutex); + auto it = fdHandlers.find(fd); + if (it == fdHandlers.end() || it->second == nullptr) + return; + handler = it->second; + handler->addRef(); + } + handler->onFDIsSet(fd); + handler->release(); +} + +Steinberg::tresult RunLoopImpl::registerEventHandler(Steinberg::Linux::IEventHandler *handler, int fd) +{ + if (!handler || fd < 0) + return Steinberg::kInvalidArgument; + { + std::lock_guard lock(eventMutex); + if (fdHandlers.count(fd)) + return Steinberg::kInvalidArgument; + fdHandlers[fd] = handler; + handler->addRef(); + } + auto *rn = new QSocketNotifier(fd, QSocketNotifier::Read, this); + + QObject::connect(rn, &QSocketNotifier::activated, this, [this, fd](int) { + QMetaObject::invokeMethod(this, "dispatchFD", Qt::QueuedConnection, Q_ARG(int, fd)); + }); + + { + std::lock_guard lock(eventMutex); + fdReadNotifiers[fd] = rn; + } + + return Steinberg::kResultTrue; +} + +Steinberg::tresult PLUGIN_API RunLoopImpl::unregisterEventHandler(Steinberg::Linux::IEventHandler *handler) +{ + if (!handler) + return Steinberg::kInvalidArgument; + + std::vector toRemoveFds; + std::vector toDeleteNotifiers; + + { + std::lock_guard lock(eventMutex); + for (auto it = fdHandlers.begin(); it != fdHandlers.end();) { + if (it->second == handler) { + int fd = it->first; + toRemoveFds.push_back(fd); + + auto rnIt = fdReadNotifiers.find(fd); + if (rnIt != fdReadNotifiers.end()) { + toDeleteNotifiers.push_back(rnIt->second); + fdReadNotifiers.erase(rnIt); + } + + it = fdHandlers.erase(it); + } else { + ++it; + } + } + } + + if (toRemoveFds.empty()) + return Steinberg::kResultFalse; + + for (QSocketNotifier *sn : toDeleteNotifiers) { + if (!sn) + continue; + + sn->setEnabled(false); + sn->deleteLater(); + } + + for (size_t i = 0; i < toRemoveFds.size(); ++i) { + handler->release(); + } + + return Steinberg::kResultTrue; +} + +Steinberg::tresult RunLoopImpl::registerTimer(Steinberg::Linux::ITimerHandler *handler, uint64_t ms) +{ + if (!handler || ms == 0) + return Steinberg::kInvalidArgument; + + for (const auto *slot : pluginTimers) { + if (slot->handler == handler) + return Steinberg::kResultFalse; + } + handler->addRef(); + auto *slot = new TimerSlot{handler, new QTimer(this)}; + slot->qt->setInterval(static_cast(ms)); + QObject::connect(slot->qt, &QTimer::timeout, this, [this, slot]() { this->UpdateTimer(slot); }); + slot->qt->setSingleShot(false); + slot->qt->start(); + pluginTimers.push_back(slot); + + return Steinberg::kResultTrue; +} + +Steinberg::tresult RunLoopImpl::unregisterTimer(Steinberg::Linux::ITimerHandler *handler) +{ + if (!handler) + return Steinberg::kInvalidArgument; + + auto it = std::find_if(pluginTimers.begin(), pluginTimers.end(), + [&](TimerSlot *slot) { return slot && slot->handler == handler; }); + + if (it == pluginTimers.end()) + return Steinberg::kResultFalse; + + TimerSlot *slot = *it; + if (slot->qt) { + slot->qt->stop(); + delete slot->qt; + } + if (slot->handler) { + slot->handler->release(); + } + delete slot; + pluginTimers.erase(it); + return Steinberg::kResultTrue; +} + +void RunLoopImpl::registerWindow(Window win, std::function handler) +{ + std::lock_guard lock(hostEventMutex); + windowHandlers[win] = std::move(handler); + if (!hostEventTimer->isActive()) + hostEventTimer->start(); +} + +void RunLoopImpl::unregisterWindow(Window win) +{ + std::lock_guard lock(hostEventMutex); + windowHandlers.erase(win); + if (windowHandlers.empty()) + hostEventTimer->stop(); +} + +// FUnknown support +uint32_t RunLoopImpl::addRef() +{ + return 1000; +} + +uint32_t RunLoopImpl::release() +{ + return 1000; +} + +Steinberg::tresult RunLoopImpl::queryInterface(const Steinberg::TUID iid, void **obj) +{ + if (Steinberg::FUnknownPrivate::iidEqual(iid, Steinberg::Linux::IRunLoop::iid)) { + *obj = static_cast(this); + return Steinberg::kResultOk; + } + *obj = nullptr; + return Steinberg::kNoInterface; +} + +RunLoopImpl::~RunLoopImpl() +{ + stopping = true; + if (hostEventTimer) { + std::lock_guard lock(hostEventMutex); + hostEventTimer->stop(); + delete hostEventTimer; + } + for (auto slot : pluginTimers) { + slot->qt->stop(); + slot->qt->deleteLater(); + slot->handler->release(); + delete slot; + } + pluginTimers.clear(); +} diff --git a/plugins/obs-vst3/editor/linux/RunLoopImpl.h b/plugins/obs-vst3/editor/linux/RunLoopImpl.h new file mode 100644 index 00000000000000..6dc500a28d993a --- /dev/null +++ b/plugins/obs-vst3/editor/linux/RunLoopImpl.h @@ -0,0 +1,71 @@ +/****************************************************************************** + Copyright (C) 2025-2026 pkv + This file is part of obs-vst3. + It uses the Steinberg VST3 SDK, which is licensed under MIT license. + See https://github.com/steinbergmedia/vst3sdk for details. + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with this program. If not, see . +******************************************************************************/ +#pragma once +#include "pluginterfaces/gui/iplugview.h" + +#include +#include +#include +#include +#include + +#include +#include + +class QSocketNotifier; +class QTimer; + +class RunLoopImpl : public QObject, public Steinberg::Linux::IRunLoop { + Q_OBJECT +public: + explicit RunLoopImpl(Display *dpy); + ~RunLoopImpl() override; + Steinberg::tresult PLUGIN_API registerEventHandler(Steinberg::Linux::IEventHandler *handler, int fd) override; + Steinberg::tresult PLUGIN_API unregisterEventHandler(Steinberg::Linux::IEventHandler *handler) override; + Steinberg::tresult PLUGIN_API registerTimer(Steinberg::Linux::ITimerHandler *handler, uint64_t ms) override; + Steinberg::tresult PLUGIN_API unregisterTimer(Steinberg::Linux::ITimerHandler *handler) override; + Steinberg::tresult PLUGIN_API queryInterface(const Steinberg::TUID iid, void **obj) override; + uint32_t PLUGIN_API addRef() override; + uint32_t PLUGIN_API release() override; + + void registerWindow(Window win, std::function handler); + void unregisterWindow(Window win); + void stop() { stopping = true; }; + +public slots: + Q_INVOKABLE void dispatchFD(int fd); + +private: + bool stopping = false; + Display *display; + std::mutex eventMutex; + std::map fdHandlers; + std::map fdReadNotifiers; + std::mutex hostEventMutex; + QTimer *hostEventTimer = nullptr; + void tickHostEvents(); + std::unordered_map> windowHandlers; + + struct TimerSlot { + Steinberg::Linux::ITimerHandler *handler; + QTimer *qt; + }; + std::vector pluginTimers; + +public: + void UpdateTimer(TimerSlot *slot); +}; diff --git a/plugins/obs-vst3/editor/linux/VST3EditorWindow.cpp b/plugins/obs-vst3/editor/linux/VST3EditorWindow.cpp new file mode 100644 index 00000000000000..6c3aa2772a7c0c --- /dev/null +++ b/plugins/obs-vst3/editor/linux/VST3EditorWindow.cpp @@ -0,0 +1,633 @@ +/****************************************************************************** + Copyright (C) 2025-2026 pkv + This file is part of obs-vst3. + It uses the Steinberg VST3 SDK, which is licensed under MIT license. + See https://github.com/steinbergmedia/vst3sdk for details. + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with this program. If not, see . +******************************************************************************/ +#include "VST3EditorWindow.h" +#include "pluginterfaces/base/funknown.h" +#include "util/c99defs.h" + +#include +#include +#include + +using Coord = int32_t; + +struct Size { + Coord width; + Coord height; +}; + +inline bool operator!=(const Size &lhs, const Size &rhs) +{ + return lhs.width != rhs.width || lhs.height != rhs.height; +} + +inline bool operator==(const Size &lhs, const Size &rhs) +{ + return lhs.width == rhs.width && lhs.height == rhs.height; +} + +inline bool operator==(const Steinberg::ViewRect &r1, const Steinberg::ViewRect &r2) +{ + return memcmp(&r1, &r2, sizeof(Steinberg::ViewRect)) == 0; +} + +inline bool operator!=(const Steinberg::ViewRect &r1, const Steinberg::ViewRect &r2) +{ + return !(r1 == r2); +} + +/* XEMBED messages */ +#define XEMBED_EMBEDDED_NOTIFY 0 +#define XEMBED_WINDOW_ACTIVATE 1 +#define XEMBED_WINDOW_DEACTIVATE 2 +#define XEMBED_REQUEST_FOCUS 3 +#define XEMBED_FOCUS_IN 4 +#define XEMBED_FOCUS_OUT 5 +#define XEMBED_FOCUS_NEXT 6 +#define XEMBED_FOCUS_PREV 7 +/* 8-9 were used for XEMBED_GRAB_KEY/XEMBED_UNGRAB_KEY */ +#define XEMBED_MODALITY_ON 10 +#define XEMBED_MODALITY_OFF 11 +#define XEMBED_REGISTER_ACCELERATOR 12 +#define XEMBED_UNREGISTER_ACCELERATOR 13 +#define XEMBED_ACTIVATE_ACCELERATOR 14 + +#define XEMBED_MAPPED (1 << 0) +#ifndef XEMBED_EMBEDDED_NOTIFY +#define XEMBED_EMBEDDED_NOTIFY 0 +#endif +#ifndef XEMBED_FOCUS_IN +#define XEMBED_FOCUS_IN 4 +#define XEMBED_FOCUS_CURRENT 0 +#endif + +struct XEmbedInfo { + uint32_t version; + uint32_t flags; +}; + +void send_xembed_message(Display *dpy, /* display */ + Window w, /* receiver */ + Atom messageType, long message, /* message opcode */ + long detail, /* message detail */ + long data1, /* message data 1 */ + long data2 /* message data 2 */ +) +{ + XEvent ev; + memset(&ev, 0, sizeof(ev)); + ev.xclient.type = ClientMessage; + ev.xclient.window = w; + ev.xclient.message_type = messageType; + ev.xclient.format = 32; + ev.xclient.data.l[0] = CurrentTime; + ev.xclient.data.l[1] = message; + ev.xclient.data.l[2] = detail; + ev.xclient.data.l[3] = data1; + ev.xclient.data.l[4] = data2; + XSendEvent(dpy, w, False, NoEventMask, &ev); + XSync(dpy, False); +} + +class VST3EditorWindow::Impl { +public: + Impl(Steinberg::IPlugView *view, std::string title, Display *display, RunLoopImpl *runloop) + : view_(view), + win_(0), + frame_(nullptr), + title_(std::move(title)), + display_(display), + runloop_(runloop) + { + resizeable_ = view->canResize() == Steinberg::kResultTrue; + resizeDebounceTimer_ = new QTimer(); + resizeDebounceTimer_->setSingleShot(true); + resizeDebounceTimer_->setInterval(100); + QObject::connect(resizeDebounceTimer_, &QTimer::timeout, [this]() { this->handleDebouncedResize(); }); + } + ~Impl() + { + if (win_ && display_) { + if (plugWindow_) { + runloop_->unregisterWindow(plugWindow_); + XUnmapWindow(display_, plugWindow_); + XReparentWindow(display_, plugWindow_, RootWindow(display_, DefaultScreen(display_)), 0, + 0); + XSync(display_, False); + plugWindow_ = 0; + } + XDestroyWindow(display_, win_); + win_ = 0; + } + + if (frame_) { + delete frame_; + frame_ = nullptr; + } + view_ = nullptr; + } + + auto getXEmbedInfo() -> XEmbedInfo * + { + int actualFormat; + unsigned long itemsReturned; + unsigned long bytesAfterReturn; + Atom actualType; + XEmbedInfo *xembedInfo = NULL; + if (xEmbedInfoAtom == None) + xEmbedInfoAtom = XInternAtom(display_, "_XEMBED_INFO", true); + auto err = XGetWindowProperty(display_, plugWindow_, xEmbedInfoAtom, 0, sizeof(xembedInfo), false, + xEmbedInfoAtom, &actualType, &actualFormat, &itemsReturned, + &bytesAfterReturn, reinterpret_cast(&xembedInfo)); + if (err != Success) + return nullptr; + return xembedInfo; + } + + void setClient(Window client) + { + if (client != 0) { + plugWindow_ = client; + fprintf(stderr, "[vst3][xembed] handshake parent=0x%lx child=0x%lx\n", (unsigned long)win_, + (unsigned long)client); + + XClearArea(display_, client, 0, 0, 1, 1, True); + runloop_->registerWindow(plugWindow_, + [this](const XEvent &e) { return handleMainWindowEvent(e); }); + XResizeWindow(display_, plugWindow_, mCurrentSize.width, mCurrentSize.height); + auto eventMask = StructureNotifyMask | PropertyChangeMask | FocusChangeMask | ExposureMask; + XWindowAttributes parentAttr, clientAttr; + XGetWindowAttributes(display_, win_, &parentAttr); + XGetWindowAttributes(display_, plugWindow_, &clientAttr); + + if (parentAttr.colormap != clientAttr.colormap) { + fprintf(stderr, "WARNING: Colormap mismatch; fixing\n"); + XSetWindowColormap(display_, win_, clientAttr.colormap); + } + + if ((eventMask & clientAttr.your_event_mask) != eventMask) + XSelectInput(display_, client, clientAttr.your_event_mask | eventMask); + + if (xEmbedAtom == None) + xEmbedAtom = XInternAtom(display_, "_XEMBED", False); + send_xembed_message(display_, plugWindow_, xEmbedAtom, XEMBED_EMBEDDED_NOTIFY, 0, win_, 0); + + if (xEmbedInfoAtom == None) + xEmbedInfoAtom = XInternAtom(display_, "_XEMBED_INFO", False); + + struct XEmbedInfo info{0, 0}; + XChangeProperty(display_, plugWindow_, xEmbedInfoAtom, xEmbedInfoAtom, 32, PropModeReplace, + reinterpret_cast(&info), 2); + + XMapWindow(display_, plugWindow_); + XSync(display_, False); + XFlush(display_); + } + } + + bool handleMainWindowEvent(const XEvent &event) + { + bool handled = false; + if (event.xany.window == plugWindow_ && plugWindow_ != 0) { + switch (event.type) { + case Expose: + /* DEBUG + if (view_) { + fprintf(stderr, "[vst3] Expose event received by plugWindow_\n"); + }*/ + return true; + case PropertyNotify: + if (event.xproperty.atom == xEmbedInfoAtom) { + // should Map or UnMap + } + return true; + case ConfigureNotify: + if (resizeable_) { + XWindowAttributes hostAttr; + XGetWindowAttributes(display_, win_, &hostAttr); + XWindowAttributes attr; + XGetWindowAttributes(display_, plugWindow_, &attr); + if (attr.width != hostAttr.width || attr.height != hostAttr.height) + XResizeWindow(display_, win_, attr.width, attr.height); + } + return true; + case ClientMessage: { + /* DEBUG: + auto name = XGetAtomName(display_, event.xclient.message_type); + printf("child x11 window received the ClientMessage atom %s", name);*/ + if (event.xclient.message_type == xEmbedAtom) { + switch (event.xclient.data.l[1]) { + case XEMBED_REQUEST_FOCUS: { + XSetInputFocus(display_, plugWindow_, RevertToParent, CurrentTime); + send_xembed_message(display_, plugWindow_, xEmbedAtom, XEMBED_FOCUS_IN, + 0, win_, 0); + return true; + } + default: + break; + } + } + break; + } + default: + break; + } + } else if (event.xany.window == win_ && win_ != 0) { + switch (event.type) { + case MapNotify: + case VisibilityNotify: + case Expose: + if (plugWindow_) { + XClearArea(display_, plugWindow_, 0, 0, 0, 0, True); + XFlush(display_); + } + return false; + case ClientMessage: { + /* DEBUG: + fprintf(stderr, "[handler] ClientMessage: win=0x%lx atom=%ld\n", event.xclient.window, + event.xclient.message_type);*/ + if ((Atom)event.xclient.message_type == _wmProtocols && + (Atom)event.xclient.data.l[0] == _wmDelete) { + close(); + wasClosed_ = true; + return true; + } + break; + } + case DestroyNotify: + if (event.xdestroywindow.window == win_) { + close(); + wasClosed_ = true; + return true; + } + break; + + case UnmapNotify: + if (event.xunmap.window == win_) { + close(); + wasClosed_ = true; + return false; + } + break; + case ReparentNotify: + if (event.xreparent.parent == win_ && event.xreparent.window != plugWindow_) { + setClient(event.xreparent.window); + return true; + } + break; + case CreateNotify: + if (event.xcreatewindow.parent != event.xcreatewindow.window && + event.xcreatewindow.parent == win_ && event.xcreatewindow.window != plugWindow_) { + setClient(event.xcreatewindow.window); + return true; + } + break; + case ConfigureNotify: { + int newWidth = event.xconfigure.width; + int newHeight = event.xconfigure.height; + Size newSize = {newWidth, newHeight}; + + if (mCurrentSize != newSize) { + pendingResize_ = newSize; + if (!resizeScheduled_) { + resizeScheduled_ = true; + resizeDebounceTimer_->start(); + } + } + return true; + } + case ResizeRequest: { + auto width = event.xresizerequest.width; + auto height = event.xresizerequest.height; + Size request{width, height}; + if (mCurrentSize != request) { + auto constraintSize = constrainSize(request); + if (constraintSize != request) { + } + resize(constraintSize, true); + } + return true; + } + default: + break; + } + } + + return handled; + } + + bool create(int width, int height) + { + if (!display_) + return false; + xEmbedInfoAtom = XInternAtom(display_, "_XEMBED_INFO", true); + + int screen = DefaultScreen(display_); + + XSetWindowAttributes winAttr{}; + winAttr.border_pixel = BlackPixel(display_, screen); + winAttr.background_pixmap = None; + winAttr.colormap = DefaultColormap(display_, screen); + winAttr.override_redirect = False; + unsigned long mask = CWEventMask | CWBorderPixel | CWBackPixmap | CWColormap; + + win_ = XCreateWindow(display_, RootWindow(display_, screen), 0, 0, width, height, 0, + DefaultDepth(display_, screen), InputOutput, DefaultVisual(display_, screen), mask, + &winAttr); + + // Register for events + runloop_->registerWindow(win_, [this](const XEvent &event) { return handleMainWindowEvent(event); }); + + Size size = {width, height}; + resize(size, true); + XFlush(display_); + XSelectInput(display_, win_, + ExposureMask | StructureNotifyMask | SubstructureNotifyMask | FocusChangeMask | + VisibilityChangeMask); + + auto sizeHints = XAllocSizeHints(); + sizeHints->flags = PMinSize; + if (!resizeable_) { + sizeHints->flags |= PMaxSize; + sizeHints->min_width = sizeHints->max_width = size.width; + sizeHints->min_height = sizeHints->max_height = size.height; + } else { + sizeHints->min_width = sizeHints->min_height = 80; + } + XSetWMNormalHints(display_, win_, sizeHints); + XFree(sizeHints); + + XStoreName(display_, win_, title_.data()); + + _wmProtocols = XInternAtom(display_, "WM_PROTOCOLS", False); + _wmDelete = XInternAtom(display_, "WM_DELETE_WINDOW", False); + Atom protos[1] = {_wmDelete}; + XSetWMProtocols(display_, win_, protos, 1); + + Atom windowType = XInternAtom(display_, "_NET_WM_WINDOW_TYPE", False); + Atom normalType = XInternAtom(display_, "_NET_WM_WINDOW_TYPE_NORMAL", False); + XChangeProperty(display_, win_, windowType, XA_ATOM, 32, PropModeReplace, (unsigned char *)&normalType, + 1); + + Atom allowedActions = XInternAtom(display_, "_NET_WM_ALLOWED_ACTIONS", False); + Atom actions[3] = {XInternAtom(display_, "_NET_WM_ACTION_MINIMIZE", False), + XInternAtom(display_, "_NET_WM_ACTION_CLOSE", False), + XInternAtom(display_, "_NET_WM_ACTION_MOVE", False)}; + XChangeProperty(display_, win_, allowedActions, XA_ATOM, 32, PropModeReplace, (unsigned char *)actions, + 3); + + XMapWindow(display_, win_); + XFlush(display_); + + frame_ = new PlugFrameImpl(display_, win_, this); + if (view_) { + view_->setFrame(frame_); + if (view_->attached((void *)win_, Steinberg::kPlatformTypeX11EmbedWindowID) != + Steinberg::kResultOk) { + view_->setFrame(nullptr); + return false; + } + attached_ = true; + // "Lazy size" plugin support + int _width = mCurrentSize.width; + int _height = mCurrentSize.height; + Steinberg::ViewRect vr(0, 0, _width, _height); + Steinberg::ViewRect rect; + if (view_->getSize(&rect) == Steinberg::kResultOk) { + if (rect.getWidth() != _width || rect.getHeight() != _height) { + view_->onSize(&rect); + } + + } else { + view_->onSize(&vr); + } + } + XSync(display_, False); + return true; + } + + void reattach() + { + view_->setFrame(frame_); + Steinberg::tresult res = view_->attached((void *)win_, Steinberg::kPlatformTypeX11EmbedWindowID); + if (res != Steinberg::kResultOk) { + view_->setFrame(nullptr); + } else { + attached_ = true; + } + } + + void show() + { + if (win_ && display_) { + if (!attached_) { + reattach(); + setClient(plugWindow_); + XReparentWindow(display_, plugWindow_, win_, 0, 0); + } + + if (attached_) { + Steinberg::ViewRect cur{}; + if (view_->getSize(&cur) != Steinberg::kResultTrue) + view_->onSize(&cur); + mCurrentSize.width = cur.getWidth(); + mCurrentSize.height = cur.getHeight(); + XMapWindow(display_, win_); + XMapWindow(display_, plugWindow_); + XMapRaised(display_, win_); + XSync(display_, False); + XFlush(display_); + } + } + wasClosed_ = false; + } + + void detach() + { + if (!attached_) + return; + if (plugWindow_) { + runloop_->unregisterWindow(plugWindow_); + XUnmapWindow(display_, plugWindow_); + XReparentWindow(display_, plugWindow_, RootWindow(display_, DefaultScreen(display_)), 0, 0); + XSync(display_, False); + } + + if (view_) { + view_->removed(); + view_->setFrame(nullptr); + attached_ = false; + } + } + + void close() + { + detach(); + if (win_ && display_) + XUnmapWindow(display_, win_); + + XFlush(display_); + } + + void resize(Size newSize, bool force) + { + if (!force && mCurrentSize == newSize) + return; + if (win_) + XResizeWindow(display_, win_, newSize.width, newSize.height); + mCurrentSize = newSize; + } + + void onResize(Size newSize) + { + if (view_) { + Steinberg::ViewRect r{}; + r.right = newSize.width; + r.bottom = newSize.height; + Steinberg::ViewRect r2{}; + if (view_->getSize(&r2) == Steinberg::kResultTrue && r != r2) + view_->onSize(&r); + } + } + + Size constrainSize(Size requestedSize) + { + if (view_) { + Steinberg::ViewRect r{}; + r.right = requestedSize.width; + r.bottom = requestedSize.height; + if (view_->checkSizeConstraint(&r) != Steinberg::kResultTrue) { + requestedSize.width = r.right - r.left; + requestedSize.height = r.bottom - r.top; + } + } + return requestedSize; + } + + [[nodiscard]] bool getClosedState() const { return wasClosed_; } + + RunLoopImpl *getRunLoop() { return runloop_; } + + void handleDebouncedResize() + { + resizeScheduled_ = false; + Size newSize = pendingResize_; + + auto constraintSize = constrainSize(newSize); + if (constraintSize != mCurrentSize) { + mCurrentSize = constraintSize; + onResize(constraintSize); + } + + if (plugWindow_) + XResizeWindow(display_, plugWindow_, constraintSize.width, constraintSize.height); + + XSync(display_, False); + } + + class PlugFrameImpl : public Steinberg::IPlugFrame { + public: + PlugFrameImpl(Display *display, Window win, Impl *owner) : display_(display), win_(win), owner_(owner) + { + } + Steinberg::tresult PLUGIN_API resizeView(Steinberg::IPlugView *view, + Steinberg::ViewRect *newSize) override + { + if (display_ && newSize) { + if (owner_->view_->checkSizeConstraint(newSize) == Steinberg::kResultOk) { + }; + XResizeWindow(display_, win_, newSize->getWidth(), newSize->getHeight()); + if (owner_->plugWindow_) + XResizeWindow(display_, owner_->plugWindow_, newSize->getWidth(), + newSize->getHeight()); + view->onSize(newSize); + return Steinberg::kResultTrue; + } + return Steinberg::kResultFalse; + } + Steinberg::tresult PLUGIN_API queryInterface(const Steinberg::TUID _iid, void **obj) override + { + if (Steinberg::FUnknownPrivate::iidEqual(_iid, Steinberg::IPlugFrame::iid)) { + *obj = this; + return Steinberg::kResultOk; + } + if (Steinberg::FUnknownPrivate::iidEqual(_iid, Steinberg::Linux::IRunLoop::iid)) { + *obj = static_cast(owner_->getRunLoop()); + return Steinberg::kResultOk; + } + *obj = nullptr; + return Steinberg::kNoInterface; + } + uint32_t PLUGIN_API addRef() override { return 1; } + uint32_t PLUGIN_API release() override { return 1; } + + private: + Display *display_; + Window win_; + Impl *owner_; + }; + + Steinberg::IPlugView *view_; + Window win_; + Window plugWindow_ = 0; + PlugFrameImpl *frame_; + std::string title_; + Display *display_; + RunLoopImpl *runloop_; + bool resizeable_; + bool wasClosed_ = false; + Size mCurrentSize{}; + bool isMapped = false; + Atom xEmbedInfoAtom{None}; + Atom xEmbedAtom{None}; + XEmbedInfo *xembedInfo{nullptr}; + bool attached_ = false; + Atom _wmDelete{None}; + Atom _wmProtocols{None}; + // A single-shot debounce timer used to coalesce rapid X11 ConfigureNotify events + // during user or plugin-driven resizing. Without this, parent and child windows + // can enter a resize ping-pong loop (plugin resizes → host resizes → plugin resizes). + Size pendingResize_ = {0, 0}; + bool resizeScheduled_ = false; + QTimer *resizeDebounceTimer_ = nullptr; +}; + +// --- Interface implementation --- +VST3EditorWindow::VST3EditorWindow(Steinberg::IPlugView *view, const std::string &title, Display *display, + RunLoopImpl *runloop) + : impl_(new Impl(view, title, display, runloop)) +{ +} +VST3EditorWindow::~VST3EditorWindow() +{ + delete impl_; +} +bool VST3EditorWindow::create(int width, int height) +{ + return impl_->create(width, height); +} +void VST3EditorWindow::show() +{ + impl_->show(); +} +void VST3EditorWindow::close() +{ + impl_->close(); +} +bool VST3EditorWindow::getClosedState() +{ + return impl_->getClosedState(); +} diff --git a/plugins/obs-vst3/editor/mac/VST3EditorWindow.mm b/plugins/obs-vst3/editor/mac/VST3EditorWindow.mm new file mode 100644 index 00000000000000..2cae90e8712e37 --- /dev/null +++ b/plugins/obs-vst3/editor/mac/VST3EditorWindow.mm @@ -0,0 +1,266 @@ +/****************************************************************************** + Copyright (C) 2025-2026 pkv + This file is part of obs-vst3. + It uses the Steinberg VST3 SDK, which is licensed under MIT license. + See https://github.com/steinbergmedia/vst3sdk for details. + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with this program. If not, see . +******************************************************************************/ +#import +#include "VST3EditorWindow.h" +#include "pluginterfaces/gui/iplugviewcontentscalesupport.h" +#include "pluginterfaces/base/funknown.h" + +#define UNUSED_PARAMETER(x) (void)(x) + +@interface VST3EditorWindowDelegate : NSObject +@property (nonatomic, assign) Steinberg::IPlugView *vst3View; +@property (nonatomic, assign) VST3EditorWindow *windowObj; +@end + +@implementation VST3EditorWindowDelegate +- (void)windowDidResize:(NSNotification *)notification +{ + NSWindow *window = (NSWindow *) notification.object; + NSSize size = [window.contentView frame].size; + if (self.vst3View) { + Steinberg::ViewRect vr(0, 0, size.width, size.height); + self.vst3View->onSize(&vr); + } +} +- (BOOL)windowShouldClose:(NSWindow *)sender +{ + if (self.windowObj) { + self.windowObj->setClosedState(true); + self.windowObj->close(); + return NO; + } + return YES; +} +@end + +void fail(const char *msg) +{ + fprintf(stderr, "VST3 Editor Fatal Error: %s\n", msg); +} + +//------------------------------------------------------------ + +class PlugFrameImpl : public Steinberg::IPlugFrame +{ + public: + explicit PlugFrameImpl(NSWindow *window) : window_(window) {} + + Steinberg::tresult PLUGIN_API resizeView(Steinberg::IPlugView *, Steinberg::ViewRect *newSize) override + { + if (window_ && newSize) { + [window_ setContentSize:NSMakeSize(newSize->getWidth(), newSize->getHeight())]; + return Steinberg::kResultTrue; + } + return Steinberg::kResultFalse; + } + + Steinberg::tresult PLUGIN_API queryInterface(const Steinberg::TUID _iid, void **obj) override + { + if (Steinberg::FUnknownPrivate::iidEqual(_iid, Steinberg::IPlugFrame::iid)) { + *obj = this; + return Steinberg::kResultOk; + } + *obj = nullptr; + return Steinberg::kNoInterface; + } + + uint32_t PLUGIN_API addRef() override + { + return 1; + } + uint32_t PLUGIN_API release() override + { + return 1; + } + + private: + NSWindow *window_; +}; + +//------------------------------------------------------------ + +class VST3EditorWindow::Impl +{ + public: + Impl(Steinberg::IPlugView *view, const std::string &title, VST3EditorWindow *owner) : + view_(view), + window_(nil), + frame_(nullptr), + title_(title), + delegate_(nil), + owner_(owner), + resizeable_(view->canResize() == Steinberg::kResultTrue), + wasClosed_(false) + {} + + ~Impl() + { + if (window_) { + window_.delegate = nil; + [window_ orderOut:nil]; + [window_ close]; + window_ = nil; + } + if (frame_) { + delete frame_; + frame_ = nullptr; + } + view_ = nullptr; + } + + bool create(int width, int height) + { + NSUInteger styleMask = NSWindowStyleMaskTitled | NSWindowStyleMaskClosable | NSWindowStyleMaskMiniaturizable; + if (resizeable_) + styleMask |= NSWindowStyleMaskResizable; + + NSRect contentRect = NSMakeRect(0, 0, width, height); + window_ = [[NSPanel alloc] initWithContentRect:contentRect styleMask:styleMask backing:NSBackingStoreBuffered + defer:YES]; + + delegate_ = [[VST3EditorWindowDelegate alloc] init]; + delegate_.vst3View = view_; + delegate_.windowObj = owner_; + window_.delegate = delegate_; + + window_.releasedWhenClosed = NO; + [window_ center]; + [window_ setStyleMask:styleMask]; + [window_ setTitle:[[NSString alloc] initWithUTF8String:title_.c_str()]]; + [window_ setLevel:NSFloatingWindowLevel]; + [window_ setHidesOnDeactivate:NO]; + [window_ setIsVisible:YES]; + [window_ + setCollectionBehavior:(NSWindowCollectionBehaviorFullScreenAuxiliary | + NSWindowCollectionBehaviorCanJoinAllSpaces | NSWindowCollectionBehaviorTransient)]; + [window_ setOpaque:NO]; + [window_ setHasShadow:YES]; + [window_ setExcludedFromWindowsMenu:YES]; + [window_ setAutorecalculatesKeyViewLoop:NO]; + + frame_ = new PlugFrameImpl(window_); + NSView *contentView = [window_ contentView]; + + if (view_) { + view_->setFrame(frame_); + if (view_->attached((__bridge void *) contentView, Steinberg::kPlatformTypeNSView) != + Steinberg::kResultOk) { + view_->setFrame(nullptr); + fail("VST3: Plugin attachment to window failed!"); + return false; + } + + // Set plugin view size + Steinberg::ViewRect rect; + Steinberg::ViewRect vr(0, 0, width, height); + if (view_->getSize(&rect) == Steinberg::kResultOk && + (rect.getWidth() != width || rect.getHeight() != height)) + view_->onSize(&rect); + else + view_->onSize(&vr); + + // DPI scaling + Steinberg::FUnknownPtr scaleSupport(view_); + if (scaleSupport) { + CGFloat dpiScale = [contentView.window backingScaleFactor]; + scaleSupport->setContentScaleFactor(dpiScale); + } + } + [window_ makeKeyAndOrderFront:nil]; + [NSApp activateIgnoringOtherApps:YES]; + + return true; + } + + void show() + { + if (window_) { + [window_ makeKeyAndOrderFront:nil]; + [NSApp activateIgnoringOtherApps:YES]; + + NSView *contentView = [window_ contentView]; + Steinberg::FUnknownPtr scaleSupport(view_); + if (scaleSupport && contentView) { + CGFloat dpiScale = [contentView.window backingScaleFactor]; + scaleSupport->setContentScaleFactor(dpiScale); + } + + wasClosed_ = false; + } + } + + void hide() + { + if (window_) + [window_ orderOut:nil]; + } + + void setClosedState(bool closed) + { + wasClosed_ = closed; + } + bool getClosedState() const + { + return wasClosed_; + } + + private: + Steinberg::IPlugView *view_; + NSWindow *window_; + PlugFrameImpl *frame_; + std::string title_; + VST3EditorWindowDelegate *delegate_; + VST3EditorWindow *owner_; + bool resizeable_; + bool wasClosed_; +}; + +//------------------------------------------------------------ + +VST3EditorWindow::VST3EditorWindow(Steinberg::IPlugView *view, const std::string &title) : + impl_(new Impl(view, title, this)) +{} + +VST3EditorWindow::~VST3EditorWindow() +{ + delete impl_; +} + +bool VST3EditorWindow::create(int width, int height) +{ + return impl_->create(width, height); +} + +void VST3EditorWindow::show() +{ + impl_->show(); +} + +void VST3EditorWindow::close() +{ + impl_->hide(); +} + +bool VST3EditorWindow::getClosedState() +{ + return impl_->getClosedState(); +} + +void VST3EditorWindow::setClosedState(bool closed) +{ + impl_->setClosedState(closed); +} diff --git a/plugins/obs-vst3/editor/win/VST3EditorWindow.cpp b/plugins/obs-vst3/editor/win/VST3EditorWindow.cpp new file mode 100644 index 00000000000000..ac448d50bae6d8 --- /dev/null +++ b/plugins/obs-vst3/editor/win/VST3EditorWindow.cpp @@ -0,0 +1,287 @@ +/****************************************************************************** + Copyright (C) 2025-2026 pkv + This file is part of obs-vst3. + It uses the Steinberg VST3 SDK, which is licensed under MIT license. + See https://github.com/steinbergmedia/vst3sdk for details. + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with this program. If not, see . +******************************************************************************/ +#include "VST3EditorWindow.h" +#include +#include +#include +#include +#include "pluginterfaces/gui/iplugviewcontentscalesupport.h" +#include "pluginterfaces/base/funknown.h" +#include +#include + +#define IDI_OBSICON 101 + +void fail(const char *msg) +{ + fprintf(stderr, "VST3 Editor Fatal Error: %s\n", msg); + MessageBoxA(NULL, msg, "Fatal VST3 Host Error", MB_ICONERROR | MB_OK); + std::abort(); +} + +inline std::wstring utf8_to_wide(const std::string &str) +{ + if (str.empty()) + return std::wstring(); + + int sz = MultiByteToWideChar(CP_UTF8, 0, str.data(), (int)str.size(), NULL, 0); + std::wstring out(sz, 0); + MultiByteToWideChar(CP_UTF8, 0, str.data(), (int)str.size(), &out[0], sz); + return out; +} + +HMODULE GetCurrentModule() +{ + HMODULE hModule = nullptr; + GetModuleHandleEx(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, (LPCTSTR)GetCurrentModule, &hModule); + return hModule; +} + +class VST3EditorWindow::Impl { +public: + Impl(Steinberg::IPlugView *view, const std::string &title) + : view_(view), + hwnd_(nullptr), + frame_(nullptr), + title_(title) + { + s_instance = this; + resizeable_ = view->canResize() == Steinberg::kResultTrue; + } + + ~Impl() + { + if (hwnd_) + SetWindowLongPtr(hwnd_, GWLP_USERDATA, 0); + + if (hwnd_) { + DestroyWindow(hwnd_); + hwnd_ = nullptr; + } + if (frame_) { + delete frame_; + frame_ = nullptr; + } + s_instance = nullptr; + view_ = nullptr; + } + + bool create(int width, int height) + { + static bool classRegistered = false; + HINSTANCE hInstance = GetModuleHandle(NULL); + HINSTANCE hInstance2 = (HINSTANCE)GetCurrentModule(); + + if (!classRegistered) { + WNDCLASSEXW wc = {}; + wc.cbSize = sizeof(WNDCLASSEXW); + wc.style = CS_DBLCLKS; + wc.lpfnWndProc = WindowProc; + wc.hInstance = hInstance; + wc.hIcon = LoadIcon(hInstance2, MAKEINTRESOURCE(IDI_OBSICON)); + wc.hIconSm = LoadIcon(hInstance2, MAKEINTRESOURCE(IDI_OBSICON)); + wc.lpszClassName = L"VST3EditorWin32"; + wc.hCursor = LoadCursor(nullptr, IDC_ARROW); + wc.hbrBackground = nullptr; + RegisterClassExW(&wc); + classRegistered = true; + } + + DWORD exStyle = WS_EX_APPWINDOW; + DWORD dwStyle = WS_CAPTION | WS_SYSMENU | WS_CLIPCHILDREN | WS_CLIPSIBLINGS | WS_MINIMIZEBOX; + if (resizeable_) + dwStyle |= WS_SIZEBOX | WS_MAXIMIZEBOX; + + std::wstring windowTitleW = utf8_to_wide(title_); + + RECT rect = {0, 0, width, height}; + AdjustWindowRectEx(&rect, dwStyle, FALSE, exStyle); + + hwnd_ = CreateWindowExW(exStyle, L"VST3EditorWin32", windowTitleW.c_str(), dwStyle, CW_USEDEFAULT, + CW_USEDEFAULT, rect.right - rect.left, rect.bottom - rect.top, nullptr, nullptr, + GetModuleHandle(nullptr), nullptr); + + if (!hwnd_) { + fail("VST3: Plugin window creation failed!"); + return false; + } + + SetWindowLongPtr(hwnd_, GWLP_USERDATA, reinterpret_cast(this)); + SetWindowTextW(hwnd_, windowTitleW.c_str()); + + frame_ = new PlugFrameImpl(hwnd_); + + // Attach plugin view + if (view_) { + view_->setFrame(frame_); + if (view_->attached((void *)hwnd_, Steinberg::kPlatformTypeHWND) != Steinberg::kResultOk) { + view_->setFrame(nullptr); + fail("VST3: Plugin attachment to window failed!"); + return false; + } + // Initial size with a hack for VST3s which wait till being attached to return their size ... + Steinberg::ViewRect vr(0, 0, width, height); + Steinberg::ViewRect rect; + if (view_->getSize(&rect) == Steinberg::kResultOk) { + if (rect.getWidth() != width || rect.getHeight() != height) + view_->onSize(&rect); + } else { + view_->onSize(&vr); + } + // DPI + Steinberg::FUnknownPtr scaleSupport(view_); + if (scaleSupport) { + float dpi = GetDpiForWindow(hwnd_) / 96.0f; + scaleSupport->setContentScaleFactor(dpi); + } + } + return true; + } + + void show() + { + if (!hwnd_ || !view_) + return; + SetWindowPos(hwnd_, HWND_TOP, 0, 0, 0, 0, SWP_NOSIZE | SWP_NOMOVE | SWP_NOCOPYBITS | SWP_SHOWWINDOW); + wasClosed_ = false; + } + + void hide() + { + if (hwnd_) + ShowWindow(hwnd_, SW_HIDE); + } + + class PlugFrameImpl : public Steinberg::IPlugFrame { + public: + PlugFrameImpl(HWND hwnd) : hwnd_(hwnd) {} + Steinberg::tresult PLUGIN_API resizeView(Steinberg::IPlugView *view, + Steinberg::ViewRect *newSize) override + { + if (hwnd_ && newSize) { + RECT winRect = {0, 0, newSize->getWidth(), newSize->getHeight()}; + int style = GetWindowLong(hwnd_, GWL_STYLE); + int exStyle = GetWindowLong(hwnd_, GWL_EXSTYLE); + AdjustWindowRectEx(&winRect, style, FALSE, exStyle); + SetWindowPos(hwnd_, nullptr, 0, 0, winRect.right - winRect.left, + winRect.bottom - winRect.top, SWP_NOMOVE | SWP_NOZORDER); + return Steinberg::kResultTrue; + } + return Steinberg::kResultFalse; + } + Steinberg::tresult PLUGIN_API queryInterface(const Steinberg::TUID _iid, void **obj) override + { + if (Steinberg::FUnknownPrivate::iidEqual(_iid, Steinberg::IPlugFrame::iid)) { + *obj = this; + return Steinberg::kResultOk; + } + *obj = nullptr; + return Steinberg::kNoInterface; + } + // refcounting does not matter here + uint32_t PLUGIN_API addRef() override { return 1; } + uint32_t PLUGIN_API release() override { return 1; } + + private: + HWND hwnd_; + }; + + bool getClosedState() { return wasClosed_; } + + static LRESULT CALLBACK WindowProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) + { + if (!s_instance) + return FALSE; + if (hwnd == s_instance->hwnd_) { + switch (msg) { + case WM_SIZE: + if (s_instance->view_) { + RECT r; + GetClientRect(hwnd, &r); + Steinberg::ViewRect vr(0, 0, r.right - r.left, r.bottom - r.top); + s_instance->view_->onSize(&vr); + } + break; + case WM_CLOSE: + s_instance->hide(); + s_instance->wasClosed_ = true; + return TRUE; + case WM_DPICHANGED: + RECT *suggested = (RECT *)lParam; + SetWindowPos(hwnd, nullptr, suggested->left, suggested->top, + suggested->right - suggested->left, suggested->bottom - suggested->top, + SWP_NOZORDER | SWP_NOACTIVATE); + + UINT dpi = LOWORD(wParam); + float scale = static_cast(dpi) / 96.0f; + + if (s_instance && s_instance->view_) { + Steinberg::FUnknownPtr scaleSupport( + s_instance->view_); + if (scaleSupport) { + scaleSupport->setContentScaleFactor(scale); + } + RECT r; + GetClientRect(hwnd, &r); + Steinberg::ViewRect vr(0, 0, r.right - r.left, r.bottom - r.top); + s_instance->view_->onSize(&vr); + } + return FALSE; + } + } + return DefWindowProcW(hwnd, msg, wParam, lParam); + } + + Steinberg::IPlugView *view_; + HWND hwnd_; + PlugFrameImpl *frame_; + std::string title_; + static Impl *s_instance; + bool resizeable_; + bool wasClosed_ = false; +}; + +VST3EditorWindow::Impl *VST3EditorWindow::Impl::s_instance = nullptr; + +VST3EditorWindow::VST3EditorWindow(Steinberg::IPlugView *view, const std::string &title) : impl_(new Impl(view, title)) +{ +} + +VST3EditorWindow::~VST3EditorWindow() +{ + delete impl_; +} + +bool VST3EditorWindow::create(int width, int height) +{ + return impl_->create(width, height); +} + +void VST3EditorWindow::show() +{ + impl_->show(); +} + +void VST3EditorWindow::close() +{ + impl_->hide(); +} + +bool VST3EditorWindow::getClosedState() +{ + return impl_->getClosedState(); +} diff --git a/plugins/obs-vst3/obs-vst3.cpp b/plugins/obs-vst3/obs-vst3.cpp new file mode 100644 index 00000000000000..0d83f1d8102c54 --- /dev/null +++ b/plugins/obs-vst3/obs-vst3.cpp @@ -0,0 +1,1124 @@ +/****************************************************************************** + Copyright (C) 2025-2026 pkv + This file is part of obs-vst3. + It uses the Steinberg VST3 SDK, which is licensed under MIT license. + See https://github.com/steinbergmedia/vst3sdk for details. + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with this program. If not, see . +******************************************************************************/ +#include "obs-vst3.h" +// on linux Qt must be included before X11 headers due to a pesky redefinition +#include +#include +#include "VST3Scanner.h" +#include "VST3Plugin.h" +#include "VST3HostApp.h" +#ifdef __linux__ +#include "editor/linux/RunLoopImpl.h" +#endif + +#include +#include +#include +#include + +#define MT_ obs_module_text +#define S_PLUGIN "vst3_plugin" +#define S_EDITOR "vst3_open_gui" +#define S_SIDECHAIN_SOURCE "sidechain_source" +#define S_NOGUI "vst3_noview" +#define S_ERR "vst3_error" +#define S_SCAN "vst3_scan" + +#define TEXT_EDITOR MT_("VST3.Button") +#define TEXT_PLUGIN MT_("VST3.Plugin") +#define TEXT_SIDECHAIN_SOURCE MT_("VST3.SidechainSource") +#define TEXT_NOGUI MT_("VST3.NOGUI") +#define TEXT_ERR MT_("VST3.Init.Fail") +#define TEXT_SCAN MT_("VST3.Scan.Ongoing") + +/* -------------------------------------------------------- */ +#define do_log(level, format, ...) \ + blog(level, "[VST3 filter ('%s')]: " format, obs_source_get_name(vd->context), ## __VA_ARGS__) + +#define warnvst3(format, ...) do_log(LOG_WARNING, format, ## __VA_ARGS__) +#define infovst3(format, ...) do_log(LOG_INFO, format, ## __VA_ARGS__) + +#ifdef _DEBUG +#define debugvst3(format, ...) do_log(LOG_DEBUG, format, ## __VA_ARGS__) +#endif +/* -------------------------------------------------------- */ + +struct vst3_audio_info { + uint32_t frames; + uint64_t timestamp; +}; + +struct sidechain_prop_info { + obs_property_t *sources; + obs_source_t *parent; +}; +/* ----------------- global host & runloop ---------------- */ + +VST3HostApp *g_host_app = nullptr; +#ifdef __linux__ +RunLoopImpl *g_run_loop = nullptr; +Display *g_display = nullptr; +#endif + +void load_host() +{ + g_host_app = new VST3HostApp(); +#ifdef __linux__ + g_display = XOpenDisplay(nullptr); + g_run_loop = new RunLoopImpl(g_display); + g_host_app->setRunLoop(g_run_loop); +#endif +} + +void unload_host() +{ +#ifdef __linux__ + if (g_run_loop) + g_run_loop->stop(); + delete g_run_loop; +#endif + delete g_host_app; +} +/* -------------------- initial scanning ------------------ */ + +// global VST3Scanner +VST3Scanner *list; +std::atomic vst3_scan_done; + +static void vst3_cache_save() +{ + if (!list) + return; + + char *path = obs_module_config_path(NULL); + os_mkdirs(path); + bfree(path); + + char *filepath = obs_module_get_config_path(obs_current_module(), "vst3list.json"); + + obs_data_t *root = obs_data_create(); + obs_data_array_t *arr = obs_data_array_create(); + + for (const auto &p : list->pluginList) { + obs_data_t *obj = obs_data_create(); + obs_data_set_string(obj, "name", p.name.c_str()); + obs_data_set_string(obj, "id", p.id.c_str()); + obs_data_set_string(obj, "path", p.path.c_str()); + obs_data_set_string(obj, "pluginName", p.pluginName.c_str()); + obs_data_set_bool(obj, "discardable", p.discardable); + obs_data_array_push_back(arr, obj); + obs_data_release(obj); + } + + obs_data_set_int(root, "version", 1); + obs_data_set_array(root, "plugins", arr); + obs_data_array_release(arr); + + obs_data_save_json_safe(root, filepath, "tmp", "bak"); + obs_data_release(root); + bfree(filepath); +} + +static bool vst3_cache_load() +{ + char *path = obs_module_config_path("vst3list.json"); + if (!path) + return false; + + obs_data_t *root = obs_data_create_from_json_file_safe(path, "bak"); + bfree(path); + if (!root) + return false; + + obs_data_array_t *arr = obs_data_get_array(root, "plugins"); + if (!arr) { + obs_data_release(root); + return false; + } + + list->pluginList.clear(); + list->classCount.clear(); + + std::unordered_map modules; + + size_t count = obs_data_array_count(arr); + for (size_t i = 0; i < count; ++i) { + obs_data_t *obj = obs_data_array_item(arr, i); + VST3ClassInfo ci; + + ci.name = obs_data_get_string(obj, "name"); + ci.id = obs_data_get_string(obj, "id"); + ci.path = obs_data_get_string(obj, "path"); + ci.pluginName = obs_data_get_string(obj, "pluginName"); + ci.discardable = obs_data_get_bool(obj, "discardable"); + + obs_data_release(obj); + + if (ci.path.empty() || !std::filesystem::exists(ci.path)) + continue; + + auto &m = modules[ci.path]; + if (ci.discardable) + m.discardable = true; + + m.classes.push_back(std::move(ci)); + } + + obs_data_array_release(arr); + obs_data_release(root); + + // we need to update the json list in case VST3s have been removed or newly installed + std::unordered_set cachedPaths; + cachedPaths.reserve(modules.size()); + + for (auto &kv : modules) + cachedPaths.insert(kv.first); + + list->updateModulesList(modules, cachedPaths); + + for (auto &[modulePath, m] : modules) { + // if a module has the flag kClassesDiscardable, the SDK compels us to do a full load from binary, duh ... + if (m.discardable) { + list->addModuleClasses(modulePath); + } else { + for (auto &ci : m.classes) { + list->pluginList.push_back(ci); + ++list->classCount[modulePath]; + } + } + } + + list->sort(); + + return !list->pluginList.empty(); +} + +bool retrieve_vst3_list() +{ + vst3_scan_done.store(false, std::memory_order_relaxed); + list = new VST3Scanner(); + if (!list->hasVST3()) { + blog(LOG_INFO, "[VST3 Scanner] No VST3 were found"); + return false; + } + + // The VST3 enumeration can take several seconds slowing down OBS startup. So we do it in its own thread. + std::thread([] { + using clock = std::chrono::steady_clock; + auto start = clock::now(); + + bool loaded_from_cache = vst3_cache_load(); + if (!loaded_from_cache) { + if (!list->scanForVST3Plugins()) { + blog(LOG_INFO, "[VST3 Scanner] Error when scanning for VST3. Module will be unloaded."); + } + } + + blog(LOG_INFO, "[VST3 Scanner] Available plugins:"); + for (const auto &plugin : list->pluginList) { + blog(LOG_INFO, "[VST3 Scanner] %s", plugin.name.c_str()); + } + + auto end = clock::now(); + auto ms = std::chrono::duration_cast(end - start).count(); + blog(LOG_INFO, "[VST3 Scanner] %s in %lld ms, found %zu plugins", + loaded_from_cache ? "Loaded cache & non-cacheable VST3s" : "Completed scan", (long long)ms, + list->pluginList.size()); + + vst3_cache_save(); + vst3_scan_done.store(true, std::memory_order_relaxed); + }).detach(); + + return true; +} + +void free_vst3_list() +{ + delete list; +} + +/* --------------------- utilities ----------------------- */ +std::string toHex(const std::vector &data) +{ + std::ostringstream oss; + for (auto b : data) + oss << std::hex << std::setw(2) << std::setfill('0') << (int)b; + + return oss.str(); +} + +std::vector fromHex(const std::string &hex) +{ + std::vector data; + for (size_t i = 0; i + 1 < hex.size(); i += 2) + data.push_back((uint8_t)std::stoi(hex.substr(i, 2), nullptr, 16)); + + return data; +} + +Steinberg::Vst::SpeakerArrangement obs_to_vst3_speaker_arrangement(speaker_layout layout) +{ + switch (layout) { + case SPEAKERS_MONO: + return Steinberg::Vst::SpeakerArr::kMono; + case SPEAKERS_STEREO: + return Steinberg::Vst::SpeakerArr::kStereo; + case SPEAKERS_2POINT1: // Steinberg VST3 does not support 2.1 audio, so fallback to 3.0 + return Steinberg::Vst::SpeakerArr::k30Cine; + case SPEAKERS_4POINT0: + return Steinberg::Vst::SpeakerArr::k40Cine; + case SPEAKERS_4POINT1: + return Steinberg::Vst::SpeakerArr::k41Cine; + case SPEAKERS_5POINT1: + return Steinberg::Vst::SpeakerArr::k51; + case SPEAKERS_7POINT1: + return Steinberg::Vst::SpeakerArr::k71Music; + case SPEAKERS_UNKNOWN: + default: + return Steinberg::Vst::SpeakerArr::kEmpty; + } +} + +static inline enum speaker_layout convert_speaker_layout(uint8_t channels) +{ + switch (channels) { + case 0: + return SPEAKERS_UNKNOWN; + case 1: + return SPEAKERS_MONO; + case 2: + return SPEAKERS_STEREO; + case 3: + return SPEAKERS_2POINT1; + case 4: + return SPEAKERS_4POINT0; + case 5: + return SPEAKERS_4POINT1; + case 6: + return SPEAKERS_5POINT1; + case 8: + return SPEAKERS_7POINT1; + default: + return SPEAKERS_UNKNOWN; + } +} + +/* --------------------- deque mgt -------------------------- */ + +static inline void clear_deque(struct deque *buf) +{ + deque_pop_front(buf, NULL, buf->size); +} + +static void reset_data(struct vst3_audio_data *vd) +{ + for (size_t i = 0; i < vd->channels; i++) { + clear_deque(&vd->input_buffers[i]); + clear_deque(&vd->output_buffers[i]); + } + + clear_deque(&vd->info_buffer); +} + +static void reset_sidechain_data(struct vst3_audio_data *vd) +{ + std::lock_guard lock(vd->sidechain_mutex); + for (size_t i = 0; i < vd->channels; i++) + clear_deque(&vd->sc_input_buffers[i]); +} + +/* --------------------- main functions ------------------- */ + +static const char *vst3_name(void *unused) +{ + UNUSED_PARAMETER(unused); + return TEXT_PLUGIN; +} + +static void sidechain_capture(void *data, obs_source_t *source, const struct audio_data *audio, bool muted); + +static void vst3_destroy(void *data) +{ + struct vst3_audio_data *vd = (struct vst3_audio_data *)data; + vd->bypass.store(true, std::memory_order_relaxed); + vd->sidechain_enabled.store(false, std::memory_order_relaxed); + + if (vd->weak_sidechain) { + obs_source_t *sidechain = obs_weak_source_get_source(vd->weak_sidechain); + if (sidechain) { + obs_source_remove_audio_capture_callback(sidechain, sidechain_capture, vd); + obs_source_release(sidechain); + } + obs_weak_source_release(vd->weak_sidechain); + } + + std::atomic_store(&vd->sc_resampler, std::shared_ptr{}); + vd->sc_last_timestamp = 0; + + for (size_t i = 0; i < vd->channels; i++) { + deque_free(&vd->input_buffers[i]); + deque_free(&vd->output_buffers[i]); + { + std::lock_guard lock(vd->sidechain_mutex); + deque_free(&vd->sc_input_buffers[i]); + } + } + bfree(vd->copy_buffers[0]); + bfree(vd->sc_copy_buffers[0]); + deque_free(&vd->info_buffer); + da_free(vd->output_data); + + auto plugin = std::atomic_load(&vd->plugin); + if (plugin) { + plugin->setProcessing(false); + std::atomic_store(&vd->plugin, std::shared_ptr{}); + } + + delete vd; +} + +static void teardown_sidechain(vst3_audio_data *vd, obs_data *settings) +{ + if (!vd) + return; + + if (!vd->weak_sidechain || vd->sidechain_name.empty()) + return; + vd->sidechain_enabled.store(false, std::memory_order_relaxed); + + obs_weak_source_t *old_weak = nullptr; + { + std::lock_guard lock(vd->sidechain_update_mutex); + if (vd->weak_sidechain) { + old_weak = vd->weak_sidechain; + vd->weak_sidechain = nullptr; + } + vd->sidechain_name.clear(); + obs_data_set_string(settings, S_SIDECHAIN_SOURCE, NULL); + } + + if (old_weak) { + obs_source_t *old_source = obs_weak_source_get_source(old_weak); + + if (old_source) { + obs_source_remove_audio_capture_callback(old_source, sidechain_capture, vd); + + obs_source_release(old_source); + } + + obs_weak_source_release(old_weak); + } + + vd->sc_last_timestamp = 0; + std::atomic_store(&vd->sc_resampler, std::shared_ptr{}); +} + +static void destroy_current_VST3Plugin(vst3_audio_data *vd, obs_data *settings) +{ + if (!vd) + return; + + vd->bypass.store(true, std::memory_order_relaxed); + auto plugin = std::atomic_load(&vd->plugin); + if (!plugin) + return; + + std::atomic_store(&vd->plugin, std::shared_ptr{}); + + plugin->setProcessing(false); + plugin->hideEditor(); + plugin->deactivateComponent(); + vd->noview.store(true, std::memory_order_relaxed); + + if (vd->weak_sidechain) + teardown_sidechain(vd, settings); +} + +static bool create_VST3Plugin(vst3_audio_data *vd) +{ + if (!vd) + return false; + + if (vd->vst3_id.empty() || vd->vst3_path.empty()) + return true; + + const std::string class_id = vd->vst3_id; + const std::string vst3_path = vd->vst3_path; + const int sample_rate = vd->sample_rate; + const int max_block = FRAME_SIZE; + + Steinberg::Vst::SpeakerArrangement arr = obs_to_vst3_speaker_arrangement(vd->layout); + + VST3Plugin *raw = new VST3Plugin(); + raw->obsVst3Struct = vd; + + if (!raw->init(class_id, vst3_path, sample_rate, max_block, arr)) { + infovst3("Failed to initialize VST3 plugin %s", raw->name.c_str()); + vd->last_init_failed = true; + delete raw; + return false; + } else { + infovst3("Plugin %s was successfully initialized.", raw->name.c_str()); + } + + // not all VST3s have a GUI + if (!raw->createView()) { + infovst3("Failed to create editor view for plugin at: %s", vst3_path.c_str()); + vd->noview.store(true, std::memory_order_relaxed); + } else { + vd->noview.store(false, std::memory_order_relaxed); + infovst3("Plugin %s has a GUI.", raw->name.c_str()); + } + + // Wrap raw pointer in shared_ptr with deleteLater deleter + auto plugin = std::shared_ptr(raw, [](VST3Plugin *p) { + if (p) { + p->deleteLater(); + } + }); + + std::atomic_store(&vd->plugin, plugin); + vd->bypass.store(false, std::memory_order_relaxed); + + return true; +} + +/* main init function; in case of failure, the obs-vst3 filter is bypassed; if the new vst3 is empty, it just deletes +safely the previous vst3.*/ +static bool init_VST3Plugin(void *data, obs_data *settings) +{ + auto *vd = static_cast(data); + if (!vd) + return false; + + // Reentrancy guard + if (vd->init_in_progress.test_and_set()) + return false; + + struct ClearFlag { + std::atomic_flag &f; + ~ClearFlag() { f.clear(); } + } _guard{vd->init_in_progress}; + + destroy_current_VST3Plugin(vd, settings); + + return create_VST3Plugin(vd); +} + +static void sidechain_swap(vst3_audio_data *vd, obs_data *settings) +{ + if (!vd) + return; + + if (!vd->has_sidechain.load(std::memory_order_relaxed)) + return; + + vd->sidechain_enabled.store(false, std::memory_order_relaxed); + + std::string sidechain_name(obs_data_get_string(settings, S_SIDECHAIN_SOURCE)); + bool valid_sidechain = sidechain_name != "none" && !sidechain_name.empty(); + obs_weak_source_t *old_weak_sidechain = NULL; + + { + std::lock_guard lock(vd->sidechain_update_mutex); + if (!valid_sidechain) { + { + if (vd->weak_sidechain) { + old_weak_sidechain = vd->weak_sidechain; + vd->weak_sidechain = NULL; + } + vd->sidechain_name = ""; + } + } else { + + if (vd->sidechain_name.empty() || vd->sidechain_name != sidechain_name) { + if (vd->weak_sidechain) { + old_weak_sidechain = vd->weak_sidechain; + vd->weak_sidechain = NULL; + } + vd->sidechain_name = sidechain_name; + vd->sidechain_check_time = os_gettime_ns() - 3000000000; + } + } + } + vd->sidechain_enabled.store(true, std::memory_order_relaxed); + + if (old_weak_sidechain) { + obs_source_t *old_sidechain = obs_weak_source_get_source(old_weak_sidechain); + + if (old_sidechain) { + obs_source_remove_audio_capture_callback(old_sidechain, sidechain_capture, vd); + obs_source_release(old_sidechain); + } + + obs_weak_source_release(old_weak_sidechain); + } +} + +// Our logic differs significantly from a DAW. We indeed allow swapping of VST3s which may or may not have an sc. +// 2 or 3 threads are then involved (UI, audio and possibly video due to the trick of sidechain audio capture +// leveraging video_tick). We've taken great care to implement Ross Bencina's cardinal rule for audio programming. +// http://www.rossbencina.com/code/real-time-audio-programming-101-time-waits-for-nothing but for sidechain there's +// still a mutex. +static void vst3_update(void *data, obs_data_t *settings) +{ + auto *vd = static_cast(data); + if (!vd) + return; + + std::string vst3_plugin_id(obs_data_get_string(settings, S_PLUGIN)); + + if (vst3_plugin_id.empty()) { + vd->bypass.store(true, std::memory_order_relaxed); + vd->vst3_id.clear(); + vd->vst3_path.clear(); + vd->vst3_name.clear(); + vd->has_sidechain.store(false, std::memory_order_relaxed); + destroy_current_VST3Plugin(vd, settings); + + return; + } + + auto plugin = std::atomic_load(&vd->plugin); + bool initial_load = vd->vst3_id.empty() && !plugin; + bool is_swap = (vd->vst3_id != vst3_plugin_id); + + if (is_swap) { + if (!initial_load) + destroy_current_VST3Plugin(vd, settings); + + if (vd->output_data.array) { + da_free(vd->output_data); + } + vd->vst3_id = vst3_plugin_id; + vd->last_init_failed = false; + + // retrieve path and name from VST3Scanner or from settings (required because vst3 scan can be long) + if (!list->getPathById(vst3_plugin_id).empty()) { + vd->vst3_path = list->getPathById(vst3_plugin_id); + vd->vst3_name = list->getNameById(vst3_plugin_id); + } else { + vd->vst3_path = obs_data_get_string(settings, "vst3_path"); + vd->vst3_name = obs_data_get_string(settings, "vst3_name"); + } + + infovst3("filter applied: %s, path: %s", vd->vst3_name.c_str(), vd->vst3_path.c_str()); + + if (init_VST3Plugin(vd, settings)) { + auto plugin2 = std::atomic_load(&vd->plugin); + if (plugin2) { + plugin2->setProcessing(true); + vd->sc_channels = plugin2->sidechainNumChannels; + } + // we support sidechain only for mono or stereo buses (sanity check) + vd->has_sidechain.store(vd->sc_channels == 1 || vd->sc_channels == 2, + std::memory_order_relaxed); + vd->bypass.store(false, std::memory_order_relaxed); + plugin = plugin2; + } else { + infovst3("VST3 failure; plugin deactivated."); + vd->bypass.store(true, std::memory_order_relaxed); + vd->has_sidechain.store(false, std::memory_order_relaxed); + vd->sidechain_enabled.store(false, std::memory_order_relaxed); + } + } + + // Only load the state the first time the filter is loaded + if (plugin && initial_load) { + const char *hexComp = obs_data_get_string(settings, "vst3_state"); + const char *hexCtrl = obs_data_get_string(settings, "vst3_ctrl_state"); + if (hexComp && *hexComp) { + std::vector comp = fromHex(hexComp); + std::vector ctrl; + if (hexCtrl && *hexCtrl) + ctrl = fromHex(hexCtrl); + plugin->loadStates(comp, ctrl); + } + } + // Sidechain specific code starts here, cf obs-filters/compressor-filter.c for the logic. The sidechain swap is + // done in 2 steps with the swapping proper in the video tick callback after a 3 sec wait. + if (vd->has_sidechain.load(std::memory_order_relaxed)) + sidechain_swap(vd, settings); +} + +static void *vst3_create(obs_data_t *settings, obs_source_t *filter) +{ + auto *vd = new vst3_audio_data(); + + if (!vd) + return NULL; + + vd->context = filter; + vd->vst3_id = {}; + vd->vst3_name = {}; + vd->vst3_path = {}; + + audio_t *audio = obs_get_audio(); + const struct audio_output_info *aoi = audio_output_get_info(audio); + + size_t frames = (size_t)FRAME_SIZE; + vd->frames = frames; + size_t channels = audio_output_get_channels(audio); + vd->channels = channels; + vd->sample_rate = audio_output_get_sample_rate(audio); + vd->layout = aoi->speakers; + vd->has_sidechain.store(false, std::memory_order_relaxed); + vd->sidechain_enabled.store(false, std::memory_order_relaxed); + vd->noview.store(true, std::memory_order_relaxed); + + vd->latency = 1000000000LL / (1000 / BUFFER_SIZE_MSEC); + + // allocate copy buffers(which are contiguous for the channels) + vd->copy_buffers[0] = (float *)bmalloc((size_t)FRAME_SIZE * channels * sizeof(float)); + vd->sc_copy_buffers[0] = (float *)bmalloc(FRAME_SIZE * channels * sizeof(float)); + + for (size_t c = 1; c < channels; ++c) { + vd->copy_buffers[c] = vd->copy_buffers[c - 1] + frames; + vd->sc_copy_buffers[c] = vd->sc_copy_buffers[c - 1] + frames; + } + + // reserve deques (about 4 ticks, quite large but better safe than sorry) + for (size_t i = 0; i < channels; i++) { + deque_reserve(&vd->input_buffers[i], 8 * frames * sizeof(float)); + deque_reserve(&vd->output_buffers[i], 8 * frames * sizeof(float)); + deque_reserve(&vd->sc_input_buffers[i], 8 * frames * sizeof(float)); + } + + vd->bypass.store(true, std::memory_order_relaxed); + + vst3_update(vd, settings); + return vd; +} + +void vst3_save(void *data, obs_data_t *settings) +{ + vst3_audio_data *vd = (vst3_audio_data *)data; + if (!vd) + return; + + auto plugin = std::atomic_load(&vd->plugin); + if (plugin) { + std::vector comp, ctrl; + if (plugin->saveStates(comp, ctrl)) { + obs_data_set_string(settings, "vst3_state", toHex(comp).c_str()); + if (!ctrl.empty()) + obs_data_set_string(settings, "vst3_ctrl_state", toHex(ctrl).c_str()); + else + obs_data_set_string(settings, "vst3_ctrl_state", ""); + } + // We store these because the filter might load before VST3s list has been populated with this info. + obs_data_set_string(settings, "vst3_path", vd->vst3_path.c_str()); + obs_data_set_string(settings, "vst3_name", vd->vst3_name.c_str()); + } +} + +/* -------------- audio processing (incl. sc) --------------- */ +static inline void preprocess_input(struct vst3_audio_data *vd, const std::shared_ptr &plugin) +{ + int num_channels = (int)vd->channels; + int sc_num_channels = (int)vd->sc_channels; + int frames = (int)vd->frames; + size_t segment_size = vd->frames * sizeof(float); + bool has_sc = vd->has_sidechain.load(std::memory_order_relaxed); + bool sc_enabled = vd->sidechain_enabled.load(std::memory_order_relaxed); + + if (has_sc && sc_enabled) { + std::lock_guard lock(vd->sidechain_mutex); + for (int i = 0; i < num_channels; i++) { + if (vd->sc_input_buffers[i].size < segment_size) + deque_push_back_zero(&vd->sc_input_buffers[i], segment_size); + } + } + + // Pop from input deque (main + sc) + for (int i = 0; i < num_channels; i++) + deque_pop_front(&vd->input_buffers[i], vd->copy_buffers[i], vd->frames * sizeof(float)); + + if (has_sc && sc_enabled) { + std::lock_guard lock(vd->sidechain_mutex); + for (int i = 0; i < num_channels; i++) + deque_pop_front(&vd->sc_input_buffers[i], vd->sc_copy_buffers[i], vd->frames * sizeof(float)); + } + + // Copy input OBS buffer to VST input buffers + for (int ch = 0; ch < num_channels; ++ch) { + float *inBuf = (float *)vd->copy_buffers[ch]; + float *vstIn = plugin->channelBuffer32(Steinberg::Vst::kInput, ch); + if (inBuf && vstIn) + memcpy(vstIn, inBuf, frames * sizeof(float)); + } + + // Copy sidechain input OBS buffer to VST input buffers (upmix or downmix if necessary) + if (has_sc && sc_enabled) { + bool needs_resampling = vd->channels != vd->sc_channels && + (vd->sc_channels == 1 || vd->sc_channels == 2); + auto sc_resampler = std::atomic_load(&vd->sc_resampler); + if (needs_resampling && sc_resampler) { + uint8_t *resampled[2] = {nullptr, nullptr}; + uint32_t out_frames; + uint64_t ts_offset; + + if (audio_resampler_resample(sc_resampler.get(), resampled, &out_frames, &ts_offset, + (const uint8_t **)vd->sc_copy_buffers, (uint32_t)vd->frames)) { + for (int ch = 0; ch < sc_num_channels; ++ch) { + float *inBuf = (float *)resampled[ch]; + float *vstIn = plugin->sidechannelBuffer32(Steinberg::Vst::kInput, ch); + if (inBuf && vstIn) + memcpy(vstIn, inBuf, out_frames * sizeof(float)); + } + } + } else { + for (int ch = 0; ch < sc_num_channels; ++ch) { + float *inBuf = vd->sc_copy_buffers[ch]; + float *vstIn = plugin->sidechannelBuffer32(Steinberg::Vst::kInput, ch); + if (inBuf && vstIn) + memcpy(vstIn, inBuf, frames * sizeof(float)); + } + } + } +} + +static inline void process(struct vst3_audio_data *vd, const std::shared_ptr &plugin) +{ + int num_channels = (int)vd->channels; + int frames = (int)vd->frames; + + preprocess_input(vd, plugin); + plugin->process(frames); + + // Retrieve processed buffers from VST + for (int ch = 0; ch < num_channels; ++ch) { + uint8_t *outBuf = (uint8_t *)vd->copy_buffers[ch]; + float *vstOut = plugin->channelBuffer32(Steinberg::Vst::kOutput, ch); + if (outBuf && vstOut) + memcpy(outBuf, vstOut, frames * sizeof(float)); + } + // Push to output deque + for (size_t i = 0; i < vd->channels; i++) + deque_push_back(&vd->output_buffers[i], vd->copy_buffers[i], vd->frames * sizeof(float)); +} + +// This re-uses the main logic from obs-filters/noise-suppress.c +static struct obs_audio_data *vst3_filter_audio(void *data, struct obs_audio_data *audio) +{ + vst3_audio_data *vd = (vst3_audio_data *)data; + struct vst3_audio_info info; + size_t segment_size = vd->frames * sizeof(float); + size_t out_size; + auto p = std::atomic_load(&vd->plugin); + bool bypass = vd->bypass.load(std::memory_order_relaxed); + + if (bypass || !p) + return audio; + + if (!p->numEnabledOutAudioBuses) + return audio; + + /* If timestamp has dramatically changed, consider it a new stream of audio data. Clear all deques to prevent + * old audio data from being processed as part of the new data. */ + if (vd->last_timestamp) { + int64_t diff = llabs((int64_t)vd->last_timestamp - (int64_t)audio->timestamp); + + if (diff > 1000000000LL) + reset_data(vd); + } + + vd->last_timestamp = audio->timestamp; + + /* push audio packet info (timestamp/frame count) to info deque */ + info.frames = audio->frames; + info.timestamp = audio->timestamp; + deque_push_back(&vd->info_buffer, &info, sizeof(info)); + + /* push back current audio data to input deque */ + for (size_t i = 0; i < vd->channels; i++) + deque_push_back(&vd->input_buffers[i], audio->data[i], audio->frames * sizeof(float)); + + /* pop/process each 10ms segments, push back to output deque */ + while (vd->input_buffers[0].size >= segment_size) + process(vd, p); + + /* peek front of info deque, check to see if we have enough to pop the expected packet size, if not, return null */ + memset(&info, 0, sizeof(info)); + deque_peek_front(&vd->info_buffer, &info, sizeof(info)); + out_size = info.frames * sizeof(float); + + if (vd->output_buffers[0].size < out_size) + return NULL; + + /* if there's enough audio data buffered in the output deque, pop and return a packet */ + deque_pop_front(&vd->info_buffer, NULL, sizeof(info)); + da_resize(vd->output_data, out_size * vd->channels); + + for (size_t i = 0; i < vd->channels; i++) { + vd->output_audio.data[i] = (uint8_t *)&vd->output_data.array[i * out_size]; + + deque_pop_front(&vd->output_buffers[i], vd->output_audio.data[i], out_size); + } + + vd->running_sample_count += info.frames; + vd->system_time = os_gettime_ns(); + vd->output_audio.frames = info.frames; + vd->output_audio.timestamp = info.timestamp - vd->latency; + return &vd->output_audio; +} + +static void sidechain_capture(void *data, obs_source_t *source, const struct audio_data *audio, bool muted) +{ + UNUSED_PARAMETER(source); + UNUSED_PARAMETER(muted); + struct vst3_audio_data *vd = (struct vst3_audio_data *)data; + auto p = std::atomic_load(&vd->plugin); + bool bypass = vd->bypass.load(std::memory_order_relaxed); + bool sc_enabled = vd->sidechain_enabled.load(std::memory_order_relaxed); + + if (bypass || !p) + return; + + if (!sc_enabled) + return; + + if (vd->sc_channels != 1 && vd->sc_channels != 2) + return; + + /* If timestamp has dramatically changed, consider it a new stream of audio data. Clear all deques to prevent old + * audio data from being processed as part of the new data. */ + if (vd->sc_last_timestamp) { + int64_t diff = llabs((int64_t)vd->sc_last_timestamp - (int64_t)audio->timestamp); + + if (diff > 1000000000LL) + reset_sidechain_data(vd); + } + + vd->sc_last_timestamp = audio->timestamp; + + /* push back current audio data to input deque */ + { + std::lock_guard lock(vd->sidechain_mutex); + for (size_t i = 0; i < vd->channels; i++) + deque_push_back(&vd->sc_input_buffers[i], audio->data[i], audio->frames * sizeof(float)); + } +} + +// written after obs-filters/compressor-filter.c for the sidechain logic +static void vst3_tick(void *data, float seconds) +{ + struct vst3_audio_data *vd = (struct vst3_audio_data *)data; + if (!vd) + return; + + bool has_sc = vd->has_sidechain.load(std::memory_order_relaxed); + + if (!has_sc) + return; + + std::string new_name = {}; + { + std::lock_guard lock(vd->sidechain_update_mutex); + if (!vd->sidechain_name.empty() && !vd->weak_sidechain) { + uint64_t t = os_gettime_ns(); + + if (t - vd->sidechain_check_time > 3000000000) { + new_name = vd->sidechain_name; + vd->sidechain_check_time = t; + } + } + } + + if (!new_name.empty()) { + obs_source_t *sidechain = obs_get_source_by_name(new_name.c_str()); + obs_weak_source_t *weak_sidechain = sidechain ? obs_source_get_weak_source(sidechain) : NULL; + { + std::lock_guard lock(vd->sidechain_update_mutex); + if (!vd->sidechain_name.empty() && vd->sidechain_name == new_name) { + vd->weak_sidechain = weak_sidechain; + weak_sidechain = NULL; + } + } + if (sidechain) { + // downmix or upmix if channel count is mismatched + bool needs_resampling = vd->channels != vd->sc_channels; + if (needs_resampling) { + struct resample_info src, dst; + src.samples_per_sec = vd->sample_rate; + src.format = AUDIO_FORMAT_FLOAT_PLANAR; + src.speakers = convert_speaker_layout((uint8_t)vd->channels); + + dst.samples_per_sec = vd->sample_rate; + dst.format = AUDIO_FORMAT_FLOAT_PLANAR; + dst.speakers = convert_speaker_layout((uint8_t)vd->sc_channels); + + audio_resampler *raw = audio_resampler_create(&dst, &src); + if (!raw) { + std::atomic_store(&vd->sc_resampler, std::shared_ptr{}); + } else { + std::shared_ptr sp(raw, [](audio_resampler *r) { + if (r) + audio_resampler_destroy(r); + }); + std::atomic_store(&vd->sc_resampler, sp); + } + } else { + std::atomic_store(&vd->sc_resampler, std::shared_ptr{}); + } + obs_source_add_audio_capture_callback(sidechain, sidechain_capture, vd); + obs_weak_source_release(weak_sidechain); + obs_source_release(sidechain); + } + } + UNUSED_PARAMETER(seconds); +} + +/* ---------------- properties functions --------------------- */ + +static bool vst3_show_gui_callback(obs_properties_t *props, obs_property_t *p, void *data) +{ + UNUSED_PARAMETER(props); + UNUSED_PARAMETER(p); + vst3_audio_data *vd = static_cast(data); + if (!vd) + return false; + + auto plugin = std::atomic_load(&vd->plugin); + if (!plugin) + return false; + + bool noview = vd->noview.load(std::memory_order_relaxed); + if (noview) + return false; + + if (!plugin->isEditorVisible()) + plugin->showEditor(); + else + plugin->hideEditor(); + + return true; +} + +static bool add_sources(void *data, obs_source_t *source) +{ + struct sidechain_prop_info *info = (struct sidechain_prop_info *)data; + uint32_t caps = obs_source_get_output_flags(source); + + if (source == info->parent) + return true; + + if ((caps & OBS_SOURCE_AUDIO) == 0) + return true; + + const char *name = obs_source_get_name(source); + obs_property_list_add_string(info->sources, name, name); + return true; +} + +bool on_vst3_changed_cb(void *priv, obs_properties_t *props, obs_property_t *property, obs_data_t *settings) +{ + UNUSED_PARAMETER(property); + UNUSED_PARAMETER(settings); + auto vd = (struct vst3_audio_data *)priv; + if (!vd) + return false; + + bool has_sc = vd->has_sidechain.load(std::memory_order_relaxed); + + obs_property_t *gui = obs_properties_get(props, S_EDITOR); + obs_property_set_visible(gui, !vd->noview.load(std::memory_order_relaxed) && !vd->last_init_failed); + + obs_property_t *p = obs_properties_get(props, S_SIDECHAIN_SOURCE); + if (has_sc && !vd->last_init_failed) { + obs_source_t *parent = obs_filter_get_parent(vd->context); + obs_property_list_clear(p); + obs_property_list_add_string(p, obs_module_text("None"), "none"); + struct sidechain_prop_info info = {p, parent}; + obs_enum_sources(add_sources, &info); + obs_property_set_visible(p, true); + } else { + obs_property_set_visible(p, false); + } + + obs_property_t *noview = obs_properties_get(props, S_NOGUI); + obs_property_set_visible(noview, vd->noview.load(std::memory_order_relaxed) && !vd->last_init_failed); + + obs_property_t *err = obs_properties_get(props, S_ERR); + if (err) { + obs_properties_remove_by_name(props, S_ERR); + } + if (vd->last_init_failed) { + obs_property_t *err2 = obs_properties_add_text(props, S_ERR, TEXT_ERR, OBS_TEXT_INFO); + obs_property_text_set_info_type(err2, OBS_TEXT_INFO_ERROR); + } + return true; +} + +static obs_properties_t *vst3_properties(void *data) +{ + auto vd = (struct vst3_audio_data *)data; + obs_properties_t *props = obs_properties_create(); + obs_property_t *sources; + obs_property_t *vst3list = + obs_properties_add_list(props, S_PLUGIN, TEXT_PLUGIN, OBS_COMBO_TYPE_LIST, OBS_COMBO_FORMAT_STRING); + + obs_property_list_add_string(vst3list, obs_module_text("VST3.Select"), ""); + for (const auto &plugin : list->pluginList) { + const bool multi = list->ModWithMultipleClasses(plugin.path); + std::string display = multi ? plugin.name + " (" + plugin.pluginName + ")" : plugin.name; + std::string value = plugin.id; + obs_property_list_add_string(vst3list, display.c_str(), value.c_str()); + } + obs_property_t *gui = + obs_properties_add_button(props, S_EDITOR, obs_module_text(TEXT_EDITOR), vst3_show_gui_callback); + obs_property_set_visible(gui, !vd->noview.load(std::memory_order_relaxed) && !vd->last_init_failed); + + sources = obs_properties_add_list(props, S_SIDECHAIN_SOURCE, TEXT_SIDECHAIN_SOURCE, OBS_COMBO_TYPE_LIST, + OBS_COMBO_FORMAT_STRING); + obs_property_set_visible(sources, !vd->last_init_failed); + + obs_property_set_modified_callback2(vst3list, on_vst3_changed_cb, data); + + obs_property_t *noview = obs_properties_add_text(props, S_NOGUI, TEXT_NOGUI, OBS_TEXT_INFO); + obs_property_text_set_info_type(noview, OBS_TEXT_INFO_WARNING); + obs_property_set_visible(noview, vd->noview.load(std::memory_order_relaxed) && !vd->last_init_failed); + + if (vd->last_init_failed) { + obs_property_t *err = obs_properties_add_text(props, S_ERR, TEXT_ERR, OBS_TEXT_INFO); + obs_property_text_set_info_type(err, OBS_TEXT_INFO_ERROR); + } + + if (!vst3_scan_done.load(std::memory_order_relaxed)) { + obs_property_t *scan_err = obs_properties_add_text(props, S_SCAN, TEXT_SCAN, OBS_TEXT_INFO); + obs_property_text_set_info_type(scan_err, OBS_TEXT_INFO_ERROR); + } + + return props; +} + +void register_vst3_source() +{ + struct obs_source_info vst3_filter = {}; + vst3_filter.id = "vst3_filter"; + vst3_filter.type = OBS_SOURCE_TYPE_FILTER; + vst3_filter.output_flags = OBS_SOURCE_AUDIO; + vst3_filter.get_name = vst3_name; + vst3_filter.create = vst3_create; + vst3_filter.destroy = vst3_destroy; + vst3_filter.update = vst3_update; + vst3_filter.filter_audio = vst3_filter_audio; + vst3_filter.get_properties = vst3_properties; + vst3_filter.save = vst3_save; + vst3_filter.video_tick = vst3_tick; + obs_register_source(&vst3_filter); +} diff --git a/plugins/obs-vst3/obs-vst3.h b/plugins/obs-vst3/obs-vst3.h new file mode 100644 index 00000000000000..d20ad01657434f --- /dev/null +++ b/plugins/obs-vst3/obs-vst3.h @@ -0,0 +1,85 @@ +/****************************************************************************** + Copyright (C) 2025-2026 pkv + This file is part of obs-vst3. + It uses the Steinberg VST3 SDK, which is licensed under MIT license. + See https://github.com/steinbergmedia/vst3sdk for details. + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with this program. If not, see . +******************************************************************************/ +#pragma once +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#define MAX_PREPROC_CHANNELS 8 +#define MAX_SC_CHANNELS 2 +#define BUFFER_SIZE_MSEC 10 +#define FRAME_SIZE 480 + +class VST3Plugin; + +struct vst3_audio_data { + obs_source_t *context; + + std::shared_ptr plugin = nullptr; + std::string vst3_id; + std::string vst3_path; + std::string vst3_name; + + uint32_t sample_rate; + size_t frames; + size_t channels; + speaker_layout layout; + int64_t running_sample_count = 0; + uint64_t system_time = 0; + uint64_t last_timestamp; + uint64_t latency; + + struct deque info_buffer; + struct deque input_buffers[MAX_PREPROC_CHANNELS]; + struct deque output_buffers[MAX_PREPROC_CHANNELS]; + struct deque sc_input_buffers[MAX_PREPROC_CHANNELS]; + + /* PCM buffers */ + float *copy_buffers[MAX_PREPROC_CHANNELS]; + float *sc_copy_buffers[MAX_PREPROC_CHANNELS]; + + /* output data */ + struct obs_audio_data output_audio; + DARRAY(float) output_data; + + /* state vars */ + std::atomic bypass; + std::atomic sidechain_enabled; + std::atomic noview; + std::atomic_flag init_in_progress = ATOMIC_FLAG_INIT; + + /* Sidechain */ + std::atomic has_sidechain; + obs_weak_source_t *weak_sidechain; + std::string sidechain_name; + uint64_t sidechain_check_time; + std::shared_ptr sc_resampler; + size_t sc_channels; + uint64_t sc_last_timestamp; + std::mutex sidechain_update_mutex; // controls sc updates + std::mutex sidechain_mutex; + + /* error messages */ + bool last_init_failed; +}; diff --git a/plugins/obs-vst3/plugin-main.cpp b/plugins/obs-vst3/plugin-main.cpp new file mode 100644 index 00000000000000..46ae511f3eddc8 --- /dev/null +++ b/plugins/obs-vst3/plugin-main.cpp @@ -0,0 +1,66 @@ +/****************************************************************************** + Copyright (C) 2025 pkv + This file is part of obs-vst3. + It uses the Steinberg VST3 SDK, which is licensed under MIT license. + See https://github.com/steinbergmedia/vst3sdk for details. + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + You should have received a copy of the GNU General Public License + along with this program. If not, see . +******************************************************************************/ +#include +#include +#ifdef __linux__ +#include +#endif +#include + +extern void load_host(); +extern void unload_host(); +extern std::atomic vst3_scan_done; +extern bool retrieve_vst3_list(); +extern void free_vst3_list(); +extern void register_vst3_source(); + +const char *PLUGIN_VERSION = "1.0.0"; +OBS_DECLARE_MODULE() +OBS_MODULE_USE_DEFAULT_LOCALE("obs-vst3", "en-US") +MODULE_EXPORT const char *obs_module_description(void) +{ + return "VST3 audio plugin"; +} + +bool obs_module_load(void) +{ +#if defined(__linux__) + if (obs_get_nix_platform() != OBS_NIX_PLATFORM_X11_EGL) { + blog(LOG_INFO, "OBS-VST3 filter disabled due to X11 requirement."); + return false; + } +#endif + load_host(); + if (!retrieve_vst3_list()) + blog(LOG_INFO, "OBS-VST3: you'll have to install VST3s in orer to use this filter."); + + register_vst3_source(); + blog(LOG_INFO, "OBS-VST3 filter loaded successfully (version %s)", PLUGIN_VERSION); + return true; +} + +void obs_module_post_load(void) +{ + for (int i = 0; i < 50 && !vst3_scan_done; ++i) + os_sleep_ms(100); +} + +void obs_module_unload() +{ + free_vst3_list(); + unload_host(); +}