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 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": { 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", 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." +) 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 00000000000000..7127372917d0ae Binary files /dev/null and b/plugins/obs-vst3/cmake/windows/obs-studio.ico differ diff --git a/plugins/obs-vst3/data/locale/en-US.ini b/plugins/obs-vst3/data/locale/en-US.ini new file mode 100644 index 00000000000000..bfe5028862c934 --- /dev/null +++ b/plugins/obs-vst3/data/locale/en-US.ini @@ -0,0 +1,7 @@ +VST3.Plugin="VST3 Plugin" +VST3.Button="Open/Close VST3 Editor" +VST3.Select="Select a VST3 ..." +VST3.SidechainSource="Sidechain source" +VST3.Init.Fail="VST3 disabled due to initialization failure.\nCheck the log for more info." +VST3.NOGUI="VST3 has no GUI" +VST3.Scan.Ongoing="WARNING: VST3 scan ongoing – properties may show partial list" diff --git a/plugins/obs-vst3/editor/linux/RunLoopImpl.cpp b/plugins/obs-vst3/editor/linux/RunLoopImpl.cpp new file mode 100644 index 00000000000000..58234436ee760e --- /dev/null +++ b/plugins/obs-vst3/editor/linux/RunLoopImpl.cpp @@ -0,0 +1,256 @@ +/****************************************************************************** + 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 +#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(); +}