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/build-aux/com.obsproject.Studio.json b/build-aux/com.obsproject.Studio.json index 9ff7c8652989b0..a2dbf6c2e2c48e 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 1bea2e4ff92d02..9357b7d9908905 100644 --- a/plugins/CMakeLists.txt +++ b/plugins/CMakeLists.txt @@ -91,6 +91,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..752cd7cb3bdd1f --- /dev/null +++ b/plugins/obs-vst3/CMakeLists.txt @@ -0,0 +1,145 @@ +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) +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.cpp + VST3EditorWindow.h +) + +# Editor window sources +set(VST3EDITORWINDOW_SRC) +if(OS_LINUX) + list(APPEND VST3EDITORWINDOW_SRC 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(VST3EditorWindow.cpp PROPERTIES LANGUAGE OBJCXX COMPILE_OPTIONS "-fobjc-arc") + set_source_files_properties( + "${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/RunLoopImpl.cpp b/plugins/obs-vst3/RunLoopImpl.cpp new file mode 100644 index 00000000000000..8b46cba1481dee --- /dev/null +++ b/plugins/obs-vst3/RunLoopImpl.cpp @@ -0,0 +1,246 @@ +/****************************************************************************** + 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 "RunLoopImpl.h" + +#include + +#include + +DEF_CLASS_IID(Steinberg::Linux::IRunLoop) + +RunLoopImpl::RunLoopImpl() = default; + +void RunLoopImpl::updateTimer(TimerSlot *slot) +{ + if (stopping || !slot) { + return; + } + + auto *handler = slot->handler; + if (handler) { + handler->addRef(); + handler->onTimer(); + handler->release(); + } +} + +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 *notifier = new QSocketNotifier(fd, QSocketNotifier::Read, this); + + QObject::connect(notifier, &QSocketNotifier::activated, this, [this, fd] { dispatchFD(fd); }); + + { + std::lock_guard lock(eventMutex); + fdReadNotifiers[fd] = notifier; + } + + 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 notifierIt = fdReadNotifiers.find(fd); + if (notifierIt != fdReadNotifiers.end()) { + toDeleteNotifiers.push_back(notifierIt->second); + fdReadNotifiers.erase(notifierIt); + } + + 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]() { updateTimer(slot); }); + 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(); + slot->qt->disconnect(this); + slot->qt->deleteLater(); + } + if (slot->handler) { + slot->handler->release(); + } + delete slot; + pluginTimers.erase(it); + return Steinberg::kResultTrue; +} + +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) || + Steinberg::FUnknownPrivate::iidEqual(iid, Steinberg::FUnknown::iid)) { + *obj = static_cast(this); + return Steinberg::kResultOk; + } + *obj = nullptr; + return Steinberg::kNoInterface; +} + +RunLoopImpl::~RunLoopImpl() +{ + stopping = true; + + std::vector eventHandlers; + { + std::lock_guard lock(eventMutex); + + for (const auto &entry : fdReadNotifiers) { + auto *notifier = entry.second; + if (notifier) { + notifier->setEnabled(false); + } + } + fdReadNotifiers.clear(); + + for (const auto &entry : fdHandlers) { + auto *handler = entry.second; + if (handler) { + eventHandlers.push_back(handler); + } + } + fdHandlers.clear(); + } + + for (auto *handler : eventHandlers) { + handler->release(); + } + + for (auto *slot : pluginTimers) { + if (slot->qt) { + slot->qt->stop(); + delete slot->qt; + } + if (slot->handler) { + slot->handler->release(); + } + delete slot; + } + + pluginTimers.clear(); +} diff --git a/plugins/obs-vst3/RunLoopImpl.h b/plugins/obs-vst3/RunLoopImpl.h new file mode 100644 index 00000000000000..76604203e55930 --- /dev/null +++ b/plugins/obs-vst3/RunLoopImpl.h @@ -0,0 +1,65 @@ +/****************************************************************************** + 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 + +class QSocketNotifier; + +class RunLoopImpl : public QObject, public Steinberg::Linux::IRunLoop { +public: + RunLoopImpl(); + ~RunLoopImpl() override; + + RunLoopImpl(const RunLoopImpl &) = delete; + RunLoopImpl &operator=(const RunLoopImpl &) = delete; + RunLoopImpl(RunLoopImpl &&) = delete; + RunLoopImpl &operator=(RunLoopImpl &&) = delete; + + 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 stop() { stopping = true; } + +private: + struct TimerSlot { + Steinberg::Linux::ITimerHandler *handler; + QTimer *qt; + }; + + void dispatchFD(int fd); + void updateTimer(TimerSlot *slot); + + bool stopping = false; + std::mutex eventMutex; + std::map fdHandlers; + std::map fdReadNotifiers; + std::vector pluginTimers; +}; \ No newline at end of file diff --git a/plugins/obs-vst3/VST3ComponentHolder.cpp b/plugins/obs-vst3/VST3ComponentHolder.cpp new file mode 100644 index 00000000000000..773443f9776e0f --- /dev/null +++ b/plugins/obs-vst3/VST3ComponentHolder.cpp @@ -0,0 +1,116 @@ +/****************************************************************************** + 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" +#include "obs-vst3.h" + +using namespace Steinberg; +using namespace Vst; + +VST3ComponentHolder::VST3ComponentHolder(VST3Plugin *plugin_) : plugin(plugin_) {} + +VST3ComponentHolder::~VST3ComponentHolder() noexcept {FUNKNOWN_DTOR} + +tresult PLUGIN_API VST3ComponentHolder::beginEdit(ParamID) +{ + return kResultOk; +} + +tresult PLUGIN_API VST3ComponentHolder::performEdit(ParamID id, ParamValue valueNormalized) +{ + if (guiToDsp) { + guiToDsp->addChange(id, valueNormalized, 0); + } + + return kResultOk; +} + +tresult PLUGIN_API VST3ComponentHolder::endEdit(ParamID) +{ + return kResultOk; +} + +tresult PLUGIN_API VST3ComponentHolder::restartComponent(int32 flags) +{ + if (!plugin) { + return kInvalidArgument; + } + + if (flags & kLatencyChanged) { + plugin->obsVst3Data->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->obsVst3Data->bypass.store(false, std::memory_order_relaxed); + return kResultOk; + } + if (flags & kParamValuesChanged) { + if (plugin->editController) { + int32 count = plugin->editController->getParameterCount(); + for (int32 i = 0; i < count; ++i) { + Steinberg::Vst::ParameterInfo info{}; + if (plugin->editController->getParameterInfo(i, info) == kResultOk) { + ParamValue value = plugin->editController->getParamNormalized(info.id); + plugin->guiToDsp.addChange(info.id, value, 0); + } + } + } + return kResultOk; + } + + return kNotImplemented; +} + +tresult PLUGIN_API VST3ComponentHolder::notifyUnitSelection(UnitID) +{ + return kResultTrue; +} + +tresult PLUGIN_API VST3ComponentHolder::notifyProgramListChange(ProgramListID, int32) +{ + return kResultTrue; +} + +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..5033a364dbf819 --- /dev/null +++ b/plugins/obs-vst3/VST3ComponentHolder.h @@ -0,0 +1,59 @@ +/****************************************************************************** + 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/vst/ivstaudioprocessor.h" +#include "pluginterfaces/vst/ivstcomponent.h" +#include "pluginterfaces/vst/ivsteditcontroller.h" +#include "pluginterfaces/vst/ivstunits.h" +#include "public.sdk/source/vst/hosting/parameterchanges.h" + +using namespace Steinberg; +using namespace Vst; + +class VST3Plugin; + +class VST3ComponentHolder : public IComponentHandler, public IUnitHandler { +public: + explicit VST3ComponentHolder(VST3Plugin *plugin_); + ~VST3ComponentHolder() noexcept; + + VST3ComponentHolder(const VST3ComponentHolder &) = delete; + VST3ComponentHolder &operator=(const VST3ComponentHolder &) = delete; + VST3ComponentHolder(VST3ComponentHolder &&) = delete; + VST3ComponentHolder &operator=(VST3ComponentHolder &&) = delete; + + ParameterChangeTransfer *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; + + tresult PLUGIN_API notifyUnitSelection(UnitID) override; + tresult PLUGIN_API notifyProgramListChange(ProgramListID, int32) override; + + 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); } + + VST3Plugin *plugin = nullptr; +}; diff --git a/plugins/obs-vst3/VST3EditorWindow.cpp b/plugins/obs-vst3/VST3EditorWindow.cpp new file mode 100644 index 00000000000000..74bd5c43de4dc4 --- /dev/null +++ b/plugins/obs-vst3/VST3EditorWindow.cpp @@ -0,0 +1,517 @@ +/****************************************************************************** + 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" +#ifdef __linux__ +#include "VST3HostApp.h" +#endif +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#include +#include +#endif + +#ifdef __linux__ +#include +#endif + +#include +#include +#include + +#ifdef _WIN32 +#include + +constexpr int obsIconId = 101; +#endif + +#ifdef __APPLE__ +#import +#endif + +#ifdef __linux__ +VST3HostApp *get_host_app() noexcept; +#endif + +using namespace Steinberg; + +namespace { + +bool sameSize(const ViewRect &lhs, const ViewRect &rhs) +{ + return lhs.getWidth() == rhs.getWidth() && lhs.getHeight() == rhs.getHeight(); +} + +void reportError(const char *message) +{ + std::fprintf(stderr, "VST3 editor error: %s\n", message); +} + +} // namespace + +#ifdef _WIN32 +HMODULE getCurrentModule() +{ + HMODULE module = nullptr; + + GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + reinterpret_cast(&getCurrentModule), &module); + + return module; +} +#endif + +class VST3EditorWindow::PlugFrameImpl : public IPlugFrame { +public: + explicit PlugFrameImpl(VST3EditorWindow *window) : window_(window) {} + tresult PLUGIN_API resizeView(IPlugView *view, ViewRect *newSize) override + { + if (!window_ || !view || view != window_->view_ || !newSize || newSize->getWidth() <= 0 || + newSize->getHeight() <= 0) { + return kInvalidArgument; + } + + return window_->resizeFromPlugin(*newSize); + } + tresult PLUGIN_API queryInterface(const TUID _iid, void **obj) override + { + if (FUnknownPrivate::iidEqual(_iid, IPlugFrame::iid)) { + *obj = static_cast(this); + return kResultOk; + } +#ifdef __linux__ + if (get_host_app() && FUnknownPrivate::iidEqual(_iid, Linux::IRunLoop::iid)) { + return get_host_app()->queryInterface(_iid, obj); + } +#endif + *obj = nullptr; + return kNoInterface; + } + // refcounting does not matter here, cf SDK + uint32_t PLUGIN_API addRef() override { return 1; } + uint32_t PLUGIN_API release() override { return 1; } + +private: + VST3EditorWindow *window_; +}; + +VST3EditorWindow::VST3EditorWindow(IPlugView *view, const std::string &title) : QWidget(nullptr), view_(view) +{ + viewContainer_ = new QWidget(this); + + resizable_ = view_->canResize() == kResultTrue; + + Qt::WindowFlags flags = Qt::Tool | Qt::WindowTitleHint | Qt::WindowCloseButtonHint | + Qt::WindowMinimizeButtonHint; + + if (resizable_) { + flags |= Qt::WindowMaximizeButtonHint; + } + + setWindowFlags(flags); + setWindowTitle(QString::fromUtf8(title.data(), static_cast(title.size()))); + +#ifdef __APPLE__ + setAttribute(Qt::WA_MacAlwaysShowToolWindow); +#endif +#ifdef _WIN32 + if (const HICON icon = LoadIconW(getCurrentModule(), MAKEINTRESOURCEW(obsIconId))) { + setWindowIcon(QIcon(QPixmap::fromImage(QImage::fromHICON(icon)))); + } +#endif +#ifdef __linux__ + resizeLinuxTimer_ = new QTimer(this); + resizeLinuxTimer_->setSingleShot(true); + resizeLinuxTimer_->setInterval(10); + connect(resizeLinuxTimer_, &QTimer::timeout, this, [this] { handleLinuxResize(); }); +#endif +} + +VST3EditorWindow::~VST3EditorWindow() = default; + +bool VST3EditorWindow::create(int width, int height) +{ + if (!view_ || attached_ || width <= 0 || height <= 0) { + return false; + } + + ViewRect initialSize(0, 0, width, height); + view_->checkSizeConstraint(&initialSize); + const QSize qtInitialSize = vst3ToQtSize(initialSize); + if (resizable_) { + resize(qtInitialSize); + } else { + setFixedSize(qtInitialSize); + } + + viewContainer_->setGeometry(0, 0, qtInitialSize.width(), qtInitialSize.height()); + + QWidget::create(); +#ifdef __APPLE__ + if (resizable_) { + setMacContentAspectRatio(qtInitialSize); + } +#endif + const WId nativeParentId = viewContainer_->winId(); + if (!windowHandle() || !nativeParentId) { + reportError("failed to create the native window"); + return false; + } + + connect(windowHandle(), &QWindow::screenChanged, this, [this] { handleScaleChange(); }); + + frame_ = std::make_unique(this); + view_->setFrame(frame_.get()); + void *nativeParent = reinterpret_cast(nativeParentId); +#ifdef _WIN32 + const FIDString platformType = kPlatformTypeHWND; +#elif defined(__APPLE__) + const FIDString platformType = kPlatformTypeNSView; +#else + const FIDString platformType = kPlatformTypeX11EmbedWindowID; +#endif + + if (view_->attached(nativeParent, platformType) != kResultOk) { + view_->setFrame(nullptr); + frame_.reset(); + reportError("failed to attach the plug-in view"); + + return false; + } + + attached_ = true; + + ViewRect windowSize = qtToVst3Rect(viewContainer_->size()); + view_->onSize(&windowSize); + + (void)updateContentScaleFactor(); + + return true; +} + +void VST3EditorWindow::show() +{ + if (!attached_) { + return; + } + + QWidget::show(); + raise(); + activateWindow(); + + wasClosed_ = false; + + handleScaleChange(); +} + +void VST3EditorWindow::close() +{ + hide(); +} + +bool VST3EditorWindow::getClosedState() const +{ + return wasClosed_; +} + +bool VST3EditorWindow::event(QEvent *event) +{ +#if QT_VERSION >= QT_VERSION_CHECK(6, 6, 0) && (defined(_WIN32) || defined(__APPLE__)) + if (event->type() == QEvent::DevicePixelRatioChange) { + handlingDpiChange_ = true; + + const bool handled = QWidget::event(event); + const bool scaleChanged = updateContentScaleFactor(); + + handlingDpiChange_ = false; + + if (scaleChanged && attached_) { + ViewRect actualRect = qtToVst3Rect(viewContainer_->size()); + view_->onSize(&actualRect); + } + + return handled; + } +#endif + return QWidget::event(event); +} + +void VST3EditorWindow::closeEvent(QCloseEvent *event) +{ + wasClosed_ = true; + hide(); + event->ignore(); +} + +void VST3EditorWindow::resizeEvent(QResizeEvent *event) +{ + QWidget::resizeEvent(event); + viewContainer_->setGeometry(0, 0, event->size().width(), event->size().height()); + + if (!view_ || !attached_ || resizingFromPlugin_ || correctingHostResize_ || handlingDpiChange_) { + return; + } + +#ifdef _WIN32 + ViewRect actualRect = qtToVst3Rect(viewContainer_->size()); + view_->onSize(&actualRect); +#elif defined(__APPLE__) + ViewRect requested = qtToVst3Rect(event->size()); + ViewRect constrained = requested; + + if (resizable_) { + view_->checkSizeConstraint(&constrained); + } + + if (!sameSize(requested, constrained)) { + correctingHostResize_ = true; + resize(vst3ToQtSize(constrained)); + correctingHostResize_ = false; + } + + ViewRect actualRect = qtToVst3Rect(viewContainer_->size()); + view_->onSize(&actualRect); +#else + resizeLinuxTimer_->start(); +#endif +} + +#ifdef _WIN32 +bool VST3EditorWindow::nativeEvent(const QByteArray &eventType, void *message, qintptr *result) +{ + if (eventType != QByteArrayLiteral("windows_generic_MSG")) { + return QWidget::nativeEvent(eventType, message, result); + } + + auto *msg = static_cast(message); + + if (!view_ || !attached_ || !resizable_ || msg->hwnd != reinterpret_cast(winId()) || + msg->message != WM_SIZING) { + return QWidget::nativeEvent(eventType, message, result); + } + + auto *windowRect = reinterpret_cast(msg->lParam); + if (!windowRect) { + return QWidget::nativeEvent(eventType, message, result); + } + + const LONG style = GetWindowLong(msg->hwnd, GWL_STYLE); + const LONG exStyle = GetWindowLong(msg->hwnd, GWL_EXSTYLE); + + RECT nonClientRect{0, 0, 0, 0}; + AdjustWindowRectEx(&nonClientRect, style, FALSE, exStyle); + + const int extraWidth = nonClientRect.right - nonClientRect.left; + const int extraHeight = nonClientRect.bottom - nonClientRect.top; + + const int windowWidth = windowRect->right - windowRect->left; + const int windowHeight = windowRect->bottom - windowRect->top; + + ViewRect constrained(0, 0, windowWidth - extraWidth, windowHeight - extraHeight); + view_->checkSizeConstraint(&constrained); + + const int newWindowWidth = constrained.getWidth() + extraWidth; + const int newWindowHeight = constrained.getHeight() + extraHeight; + + switch (msg->wParam) { + case WMSZ_LEFT: + case WMSZ_TOPLEFT: + case WMSZ_BOTTOMLEFT: + windowRect->left = windowRect->right - newWindowWidth; + break; + + default: + windowRect->right = windowRect->left + newWindowWidth; + break; + } + + switch (msg->wParam) { + case WMSZ_TOP: + case WMSZ_TOPLEFT: + case WMSZ_TOPRIGHT: + windowRect->top = windowRect->bottom - newWindowHeight; + break; + + default: + windowRect->bottom = windowRect->top + newWindowHeight; + break; + } + + if (result) { + *result = TRUE; + } + + return true; +} +#endif + +#ifdef __APPLE__ +void VST3EditorWindow::setMacContentAspectRatio(const QSize &size) +{ + if (size.isEmpty()) { + return; + } + + NSView *nativeView = (__bridge NSView *)(reinterpret_cast(winId())); + + if (!nativeView || !nativeView.window) { + return; + } + + nativeView.window.contentAspectRatio = + NSMakeSize(static_cast(size.width()), static_cast(size.height())); +} +#endif + +qreal VST3EditorWindow::vst3CoordinateScaleFactor() const +{ +#if defined(_WIN32) + return windowHandle() ? windowHandle()->devicePixelRatio() : devicePixelRatioF(); +#elif defined(__linux__) + if (QGuiApplication::platformName() == QStringLiteral("xcb")) { + return windowHandle() ? windowHandle()->devicePixelRatio() : devicePixelRatioF(); + } +#endif + return 1.0; +} + +QSize VST3EditorWindow::vst3ToQtSize(const ViewRect &rect) const +{ + const qreal scale = vst3CoordinateScaleFactor(); + const int width = std::max(1, static_cast(std::lround(rect.getWidth() / scale))); + const int height = std::max(1, static_cast(std::lround(rect.getHeight() / scale))); + + return {width, height}; +} + +ViewRect VST3EditorWindow::qtToVst3Rect(const QSize &size) const +{ + const qreal scale = vst3CoordinateScaleFactor(); + const auto width = static_cast(std::lround(size.width() * scale)); + const auto height = static_cast(std::lround(size.height() * scale)); + + return {0, 0, std::max(1, width), std::max(1, height)}; +} + +tresult PLUGIN_API VST3EditorWindow::resizeFromPlugin(const ViewRect &rect) +{ + if (!view_ || resizeViewRecursionGuard_ || rect.getWidth() <= 0 || rect.getHeight() <= 0) { + return kResultFalse; + } + + const QSize targetSize = vst3ToQtSize(rect); + + if (viewContainer_->size() == targetSize) { + return kResultTrue; + } + + resizeViewRecursionGuard_ = true; + resizingFromPlugin_ = true; + + if (resizable_) { + resize(targetSize); + } else { + setFixedSize(targetSize); + } + + resizingFromPlugin_ = false; + + ViewRect actualRect = qtToVst3Rect(viewContainer_->size()); + view_->onSize(&actualRect); + + resizeViewRecursionGuard_ = false; + + return kResultTrue; +} + +#ifdef __linux__ +// TODO: improve X11 handling. It gave me a lot of headaches. Currently we use a throttle timer to call the plugin +// onSize. Without it I had repaint issues. Known BUG: currently there's still repaint issues with LSP VST3s when +// they are resized with their inner handle but not when the container is resized. Other VST3s on linux are fine. +void VST3EditorWindow::handleLinuxResize() +{ + if (!view_ || !attached_ || resizingFromPlugin_ || correctingHostResize_) { + return; + } + + const QSize requestedQtSize = viewContainer_->size(); + ViewRect requestedRect = qtToVst3Rect(requestedQtSize); + ViewRect constrainedRect = requestedRect; + + tresult constraintResult = kResultFalse; + + if (resizable_) { + constraintResult = view_->checkSizeConstraint(&constrainedRect); + } + + if (constraintResult == kResultTrue && !sameSize(requestedRect, constrainedRect)) { + const QSize constrainedQtSize = vst3ToQtSize(constrainedRect); + + correctingHostResize_ = true; + if (constrainedRect.getWidth() > requestedRect.getWidth() && + constrainedRect.getHeight() > requestedRect.getHeight()) { + setMinimumSize(constrainedQtSize); + } else { + resize(constrainedQtSize); + } + correctingHostResize_ = false; + + ViewRect actualRect = qtToVst3Rect(viewContainer_->size()); + view_->onSize(&actualRect); + return; + } + view_->onSize(&requestedRect); +} +#endif + +bool VST3EditorWindow::updateContentScaleFactor() +{ +#if defined(_WIN32) || defined(__APPLE__) + const qreal scale = devicePixelRatioF(); + + if (contentScaleFactor_ == scale) { + return false; + } + + contentScaleFactor_ = scale; + + FUnknownPtr scaleSupport(view_); + + if (scaleSupport) { + scaleSupport->setContentScaleFactor(static_cast(scale)); + } + + return true; +#else + return false; +#endif +} + +void VST3EditorWindow::handleScaleChange() +{ + if (updateContentScaleFactor() && attached_) { + ViewRect currentSize = qtToVst3Rect(viewContainer_->size()); + view_->onSize(¤tSize); + } +} diff --git a/plugins/obs-vst3/VST3EditorWindow.h b/plugins/obs-vst3/VST3EditorWindow.h new file mode 100644 index 00000000000000..6a7ea791c41768 --- /dev/null +++ b/plugins/obs-vst3/VST3EditorWindow.h @@ -0,0 +1,89 @@ +/****************************************************************************** + 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 +#include + +#include +#include + +class QCloseEvent; +class QEvent; +class QResizeEvent; +class QTimer; + +class VST3EditorWindow : public QWidget { +public: + VST3EditorWindow(Steinberg::IPlugView *view, const std::string &title); + ~VST3EditorWindow() override; + + VST3EditorWindow(const VST3EditorWindow &) = delete; + VST3EditorWindow &operator=(const VST3EditorWindow &) = delete; + VST3EditorWindow(VST3EditorWindow &&) = delete; + VST3EditorWindow &operator=(VST3EditorWindow &&) = delete; + + bool create(int width, int height); + void show(); + void close(); + // The window is hidden rather than destroyed to preserve its size and position. + bool getClosedState() const; + +protected: + bool event(QEvent *event) override; + void closeEvent(QCloseEvent *event) override; + void resizeEvent(QResizeEvent *event) override; +#ifdef _WIN32 + bool nativeEvent(const QByteArray &eventType, void *message, qintptr *result) override; +#endif +private: + class PlugFrameImpl; + + QSize vst3ToQtSize(const Steinberg::ViewRect &rect) const; + Steinberg::ViewRect qtToVst3Rect(const QSize &size) const; + qreal vst3CoordinateScaleFactor() const; + + Steinberg::tresult PLUGIN_API resizeFromPlugin(const Steinberg::ViewRect &rect); + bool updateContentScaleFactor(); + void handleScaleChange(); + + Steinberg::IPlugView *view_ = nullptr; + std::unique_ptr frame_; + QWidget *viewContainer_ = nullptr; + + bool resizable_ = false; + bool attached_ = false; + bool wasClosed_ = false; + + bool resizingFromPlugin_ = false; + bool correctingHostResize_ = false; + bool resizeViewRecursionGuard_ = false; + + bool handlingDpiChange_ = false; + qreal contentScaleFactor_ = 0.0; + +#ifdef __APPLE__ + void setMacContentAspectRatio(const QSize &size); +#endif +#ifdef __linux__ + void handleLinuxResize(); + QTimer *resizeLinuxTimer_ = nullptr; +#endif +}; diff --git a/plugins/obs-vst3/VST3HostApp.cpp b/plugins/obs-vst3/VST3HostApp.cpp new file mode 100644 index 00000000000000..129065cb0174aa --- /dev/null +++ b/plugins/obs-vst3/VST3HostApp.cpp @@ -0,0 +1,96 @@ +/****************************************************************************** + 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 "pluginterfaces/vst/ivstaudioprocessor.h" +#include "pluginterfaces/vst/ivsteditcontroller.h" +#include "pluginterfaces/vst/ivstmessage.h" +#include "public.sdk/source/vst/utility/stringconvert.h" + +#include +#include +#include + +#ifdef _WIN32 +#include +#endif + +using namespace Steinberg; +using namespace Vst; + +VST3HostApp::VST3HostApp(VST3Backend backend) : backend_(backend) +{ + addPlugInterfaceSupported(IComponent::iid); + addPlugInterfaceSupported(IAudioProcessor::iid); + addPlugInterfaceSupported(IEditController::iid); + addPlugInterfaceSupported(IConnectionPoint::iid); +} + +VST3HostApp::~VST3HostApp() noexcept {FUNKNOWN_DTOR} + +tresult PLUGIN_API VST3HostApp::getName(String128 name) +{ + return StringConvert::convert("OBS VST3 Host", name) ? kResultTrue : kInternalError; +} + +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; +} + +tresult PLUGIN_API VST3HostApp::isPlugInterfaceSupported(const TUID iid_) +{ + auto uid = FUID::fromTUID(iid_); + if (std::find(FUIDArray_.begin(), FUIDArray_.end(), uid) != FUIDArray_.end()) { + return kResultTrue; + } + return kResultFalse; +} + +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..6391c87b1d1030 --- /dev/null +++ b/plugins/obs-vst3/VST3HostApp.h @@ -0,0 +1,73 @@ +/****************************************************************************** + 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 "RunLoopImpl.h" +#endif + +#include "public.sdk/source/vst/hosting/hostclasses.h" + +using namespace Steinberg; +using namespace Vst; + +class VST3Plugin; + +enum class VST3Backend { + Unknown, + Windows, + MacOS, + X11, + Wayland, +}; + +class VST3HostApp : public IHostApplication, public IPlugInterfaceSupport { +public: + explicit VST3HostApp(VST3Backend backend); + ~VST3HostApp() noexcept; + + VST3Backend backend() const noexcept { return backend_; } + + VST3HostApp(const VST3HostApp &) = delete; + VST3HostApp &operator=(const VST3HostApp &) = delete; + VST3HostApp(VST3HostApp &&) = delete; + VST3HostApp &operator=(VST3HostApp &&) = delete; + + tresult PLUGIN_API getName(String128 name) override; + tresult PLUGIN_API createInstance(TUID cid, TUID iid_, void **obj) override; + tresult PLUGIN_API isPlugInterfaceSupported(const TUID iid_) override; + + 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: + const VST3Backend backend_; + + std::vector FUIDArray_; + void addPlugInterfaceSupported(const TUID iid_) { FUIDArray_.push_back(FUID::fromTUID(iid_)); } +}; diff --git a/plugins/obs-vst3/VST3Plugin.cpp b/plugins/obs-vst3/VST3Plugin.cpp new file mode 100644 index 00000000000000..77cf957361195d --- /dev/null +++ b/plugins/obs-vst3/VST3Plugin.cpp @@ -0,0 +1,623 @@ +/****************************************************************************** + 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 "VST3EditorWindow.h" +#include "VST3HostApp.h" +#include "obs-vst3.h" + +#include "public.sdk/source/common/memorystream.h" + +#include + +#include +#include + +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); +} // namespace Steinberg + +#ifdef __linux__ +/* Hack for LSP plugins which crash with NVIDIA drivers. Switch LSP UI rendering from OpenGL(GLX) to 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 (!hostContext) { + hostContext = get_host_app(); + } + + componentContext = new VST3ComponentHolder(this); + +#ifdef __linux__ + ensure_lsp_cairo_backend(); +#endif +}; + +VST3Plugin::~VST3Plugin() +{ + if (window) { + hideEditor(); + + if (view) { + view->removed(); + view->setFrame(nullptr); + } + + delete window; + window = nullptr; + } + + view = nullptr; + + processData.unprepare(); + + plugProvider = nullptr; + module = nullptr; + + delete componentContext; + componentContext = nullptr; +} + +void VST3Plugin::deactivateComponent() const +{ + vstPlug->setActive(false); +} + +void VST3Plugin::setBusActive(MediaType type, BusDirection direction, int which, bool active) const +{ + vstPlug->activateBus(type, direction, which, active); +} + +bool VST3Plugin::scanAudioBuses(SpeakerArrangement arr) +{ + int auxBusCount = 0; + int mainBusCount = 0; + numInputAudioBuses = vstPlug->getBusCount(MediaTypes::kAudio, BusDirections::kInput); + numOutputAudioBuses = vstPlug->getBusCount(MediaTypes::kAudio, BusDirections::kOutput); + + infovst3plugin("Input audio buses: %i\n Output audio buses: %i\n", numInputAudioBuses, numOutputAudioBuses); + + inputAudioBusInfos.clear(); + inputSpeakerArrangements.clear(); + outputAudioBusInfos.clear(); + outputSpeakerArrangements.clear(); + // We enable the 1st compatible Main bus and the 1st Aux bus + for (int i = 0; i < numInputAudioBuses; ++i) { + BusInfo info = {}; + vstPlug->getBusInfo(kAudio, kInput, i, info); + inputAudioBusInfos.push_back(info); + // only 1 Main input bus is enabled by obs + 1 Aux (side-channel) Bus if it is available + if (info.busType == Steinberg::Vst::BusTypes::kMain) { + if (mainBusCount == 0) { + setBusActive(kAudio, kInput, i, true); + mainInputBusNumChannels = info.channelCount; + mainInputBusIndex = i; + inputSpeakerArrangements.push_back(arr); + numEnabledInputAudioBuses++; + mainBusCount = 1; + } else { + setBusActive(kAudio, kInput, i, false); + } + } else { + // The 1st aux bus (sidechain) is enabled only if it is mono or stereo + if (auxBusCount == 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; + inputSpeakerArrangements.push_back(speakerArr); + numEnabledInputAudioBuses++; + sidechainNumChannels = info.channelCount; + auxBusIndex = i; + auxBusCount = 1; + } else { + setBusActive(kAudio, kInput, i, false); + } + } + } + // We disable the plugin if it has no Input bus. + if (!numEnabledInputAudioBuses) { + 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 < numOutputAudioBuses; ++i) { + BusInfo info = {}; + vstPlug->getBusInfo(kAudio, kOutput, i, info); + outputAudioBusInfos.push_back(info); + bool isMain = (info.busType == Steinberg::Vst::BusTypes::kMain); + if (isMain && !numEnabledOutputAudioBuses) { + setBusActive(kAudio, kOutput, i, isMain); + mainOutputBusIndex = i; + mainOutputBusNumChannels = info.channelCount; + outputSpeakerArrangements.push_back(arr); + numEnabledOutputAudioBuses++; + return true; + } + } + return false; +} + +bool VST3Plugin::init(const std::string &classId, const std::string &path_, int sample_rate, int max_blocksize, + SpeakerArrangement arrangement) +{ + 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 = VST3::Hosting::Module::create(path, error); + if (!module) { + infovst3plugin("%s", error.c_str()); + return false; + } + + componentContext->guiToDsp = &guiToDsp; + inputParameterChanges = std::make_unique(); + outputParameterChanges = std::make_unique(); + + VST3::Hosting::PluginFactory factory = module->getFactory(); + factory.setHostContext(hostContext->getFUnknown()); + + 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(); + if (!vstPlug) { + infovst3plugin("No VST3 Component class found."); + return false; + } + + editController = plugProvider->getControllerPtr(); + if (!editController) { + infovst3plugin("No VST3 EditorController class found."); + return false; + } else { + editController->setComponentHandler(componentContext->getComponentHandler()); + } + + const int32 paramCount = editController ? editController->getParameterCount() : 0; + guiToDsp.setMaxParameters(paramCount > 0 ? paramCount : 256); + dspToGui.setMaxParameters(paramCount > 0 ? paramCount : 256); + + 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; + } + } + + if (!scanAudioBuses(arrangement)) { + obsVst3Data->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 *inputArrangements = inputSpeakerArrangements.empty() ? &nullArrangement : inputSpeakerArrangements.data(); + auto *outputArrangements = outputSpeakerArrangements.empty() ? &nullArrangement + : outputSpeakerArrangements.data(); + tresult res = audioEffect->setBusArrangements(inputArrangements, numEnabledInputAudioBuses, outputArrangements, + numEnabledOutputAudioBuses); + if (res != kResultTrue) { + SpeakerArrangement speakerArrangement; + audioEffect->getBusArrangement(kInput, mainInputBusIndex, speakerArrangement); + if (speakerArrangement != arrangement) { + infovst3plugin("Failed to set input bus to obs speaker layout."); + return false; + } + + if (numEnabledInputAudioBuses == 2) { + audioEffect->getBusArrangement(kInput, auxBusIndex, speakerArrangement); + SpeakerArrangement sideArr = sidechainNumChannels == 1 ? Steinberg::Vst::SpeakerArr::kMono + : Steinberg::Vst::SpeakerArr::kStereo; + if (speakerArrangement != sideArr) { + infovst3plugin("Failed to set side chain bus to desired speaker layout!"); + return false; + } + } + + audioEffect->getBusArrangement(kOutput, mainOutputBusIndex, speakerArrangement); + if (speakerArrangement != arrangement) { + infovst3plugin("Failed to set output bus to obs speaker layout."); + return false; + } + } + + res = audioEffect->setupProcessing(processSetup); + if (res == kResultOk) { + processData.prepare(*vstPlug, maxBlockSize, processSetup.symbolicSampleSize); + // silence outputs on preparation, better safe than sorry + 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 in my tests, 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; +} + +void VST3Plugin::drainDspToGui() +{ + ParamID id; + ParamValue value; + int32 sampleOffset; + + while (dspToGui.getNextChange(id, value, sampleOffset)) { + if (editController) { + editController->setParamNormalized(id, value); + } + } + uiDrainScheduled.store(false, std::memory_order_release); +} + +void VST3Plugin::preprocess() +{ + inputParameterChanges->clearQueue(); + outputParameterChanges->clearQueue(); + processData.inputParameterChanges = inputParameterChanges.get(); + processData.outputParameterChanges = outputParameterChanges.get(); + guiToDsp.transferChangesTo(*inputParameterChanges); +} + +void VST3Plugin::postprocess() +{ + if (!processData.outputParameterChanges || outputParameterChanges->getParameterCount() == 0) { + return; + } + + dspToGui.transferChangesFrom(*outputParameterChanges); + + if (!uiDrainScheduled.exchange(true, std::memory_order_acq_rel)) { + QObject *receiver = QCoreApplication::instance(); + if (receiver) { + QPointer self(this); + QMetaObject::invokeMethod( + receiver, + [self] { + if (self) { + self->drainDspToGui(); + } + }, + Qt::QueuedConnection); + } else { + uiDrainScheduled.store(false, std::memory_order_release); + } + } +} + +void VST3Plugin::setProcessing(bool processing) const +{ + audioEffect->setProcessing(processing); +} + +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::auxChannelBuffer32(const BusDirection direction, const int ch) const +{ + if (direction == kInput) { + return processData.inputs[auxBusIndex].channelBuffers32[ch]; + } else { + return nullptr; + } +} + +/* hack ripped from Juce, to create the view even with badly coded VST3s... */ +void VST3Plugin::tryCreatingView() +{ + if (auto *raw = editController->createView(Vst::ViewType::kEditor)) { + view = IPtr::adopt(raw); + return; + } + + if (auto *raw = editController->createView(nullptr)) { + view = IPtr::adopt(raw); + return; + } + + IPlugView *raw = nullptr; + if (editController->queryInterface(IPlugView::iid, reinterpret_cast(&raw)) == kResultOk) { + view = IPtr::adopt(raw); + } +} + +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) { + 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 (hostContext->backend() == VST3Backend::X11 && + view->isPlatformTypeSupported(Steinberg::kPlatformTypeX11EmbedWindowID) != Steinberg::kResultTrue) { + infovst3plugin("Editor view does not support X11"); + return false; + } + if (hostContext->backend() == VST3Backend::Wayland) { + infovst3plugin( + "Our VST3 host does not support Wayland for GUI. VST3s will be active in headless mode."); + return false; + } +#else + infovst3plugin("Platform is not supported yet"); + return false; +#endif + + return true; +} + +void VST3Plugin::showEditor() +{ + if (!view) { + return; + } + + if (!window) { + int width = 800, height = 600; + 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(obsVst3Data->context); + std::string windowName = sourceName + ": VST3 Plugin - " + name; + + window = new VST3EditorWindow(view, windowName); + + if (window->create(width, height)) { + window->show(); + editorVisible = true; + } else { + infovst3plugin("Failed to create editor window"); + delete window; + window = nullptr; + } + } 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. 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; +} + +bool VST3Plugin::saveStates(std::vector &compOut, std::vector &ctrlOut) const +{ + compOut.clear(); + ctrlOut.clear(); + + if (!vstPlug) { + return false; + } + + { + 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)); + } + } + + 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) const +{ + if (!vstPlug || comp.empty()) { + return false; + } + + Steinberg::MemoryStream compStream; + Steinberg::int32 w = 0; + Steinberg::int64 dummy = 0; + + compStream.write((void *)comp.data(), static_cast(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(), static_cast(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..e5dd5953b5a1c5 --- /dev/null +++ b/plugins/obs-vst3/VST3Plugin.h @@ -0,0 +1,133 @@ +/****************************************************************************** + 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 "VST3ComponentHolder.h" + +#include "pluginterfaces/base/smartpointer.h" +#include "pluginterfaces/gui/iplugview.h" +#include "pluginterfaces/vst/ivstprocesscontext.h" + +#include "public.sdk/source/vst/hosting/module.h" +#include "public.sdk/source/vst/hosting/parameterchanges.h" +#include "public.sdk/source/vst/hosting/plugprovider.h" +#include "public.sdk/source/vst/hosting/processdata.h" + +#include + +#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; +struct vst3_audio_data; + +class OBSPlugProvider : public PlugProvider { +public: + using PlugProvider::PlugProvider; + bool setup(FUnknown *context) { return setupPlugin(context); } +}; + +class VST3Plugin : public QObject { + Q_OBJECT +public: + VST3Plugin(); + ~VST3Plugin(); + + VST3Plugin(const VST3Plugin &) = delete; + VST3Plugin &operator=(const VST3Plugin &) = delete; + VST3Plugin(VST3Plugin &&) = delete; + VST3Plugin &operator=(VST3Plugin &&) = delete; + + struct vst3_audio_data *obsVst3Data = nullptr; + + 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; + + bool loadStates(const std::vector &comp, const std::vector &ctrl) const; + bool saveStates(std::vector &compOut, std::vector &ctrlOut) const; + + 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; + + void setProcessing(bool processing) const; + bool process(int numSamples); + void preprocess(); + void postprocess(); + [[nodiscard]] Sample32 *channelBuffer32(BusDirection direction, int which) const; + [[nodiscard]] Sample32 *auxChannelBuffer32(BusDirection direction, int ch) const; + + void tryCreatingView(); + bool createView(); + void showEditor(); + void hideEditor(); + VST3EditorWindow *window = nullptr; + bool editorVisible = false; + bool isEditorVisible(); + + ParameterChangeTransfer guiToDsp; + ParameterChangeTransfer dspToGui; + std::unique_ptr inputParameterChanges; + std::unique_ptr outputParameterChanges; + std::atomic uiDrainScheduled{false}; + void drainDspToGui(); + + int sampleRate = 0; + int maxBlockSize = 0; + int symbolicSampleSize = 0; + bool realtime = kRealtime; + std::vector inputAudioBusInfos, outputAudioBusInfos; + int numInputAudioBuses = 0; + int numOutputAudioBuses = 0; + int numEnabledInputAudioBuses = 0; + int numEnabledOutputAudioBuses = 0; + int mainInputBusNumChannels = 0; + int mainOutputBusNumChannels = 0; + int sidechainNumChannels = 0; + int mainInputBusIndex = 0; + int mainOutputBusIndex = 0; + int auxBusIndex = 0; + std::vector inputSpeakerArrangements, outputSpeakerArrangements; + + std::string path; + std::string name; +}; diff --git a/plugins/obs-vst3/VST3Scanner.cpp b/plugins/obs-vst3/VST3Scanner.cpp new file mode 100644 index 00000000000000..c64561a4a712d0 --- /dev/null +++ b/plugins/obs-vst3/VST3Scanner.cpp @@ -0,0 +1,333 @@ +/****************************************************************************** + 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 "public.sdk/source/vst/hosting/module.h" +#include "public.sdk/source/vst/moduleinfo/moduleinfoparser.h" + +#include "util/bmem.h" +#include + +#include +#include +#include +#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; +} + +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; + } + +#ifdef _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; +} + +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(); + +#ifdef _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; +} + +static bool isInstrument(const std::vector &subCategories) +{ + for (const auto &category : subCategories) { + if (category.find("Instrument") != std::string::npos) { + return true; + } + } + + return false; +} + +bool VST3Scanner::scanForVST3Plugins() +{ + pluginList.clear(); + classCount.clear(); + auto paths = getVST3Paths(); + + for (const auto &bundlePath : paths) { + if (!tryReadModuleInfo(bundlePath)) { + addModuleClasses(bundlePath); + } + } + sort(); + return !pluginList.empty(); +} + +void VST3Scanner::updateModulesList(std::unordered_map &modules) +{ + 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 (!modules.count(path)) { + if (!tryReadModuleInfo(path)) { + addModuleClasses(path); + } + } + } +} + +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; + } + + 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()); +} + +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) { + if (c.category != kVstAudioEffectClass || isInstrument(c.subCategories)) { + 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; +} + +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 || isInstrument(classInfo.subCategories())) { + continue; + } + + 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; +} + +bool VST3Scanner::moduleHasMultipleClasses(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) { + return c.name; + } + } + 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..b8dbff70f5a58c --- /dev/null +++ b/plugins/obs-vst3/VST3Scanner.h @@ -0,0 +1,62 @@ +/****************************************************************************** + 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/moduleinfo/moduleinfo.h" + +#include +#include +#include +#include + +#ifndef kVstAudioEffectClass +#define kVstAudioEffectClass "Audio Module Class" +#endif + +struct VST3ClassInfo { + std::string name; + std::string id; + std::string path; + std::string pluginName; + bool discardable; // Optional but requested to us by Steinberg: the classes need to be reloaded from the module at each host startup and can not be cached (ex: Waves plugins). +}; + +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 moduleHasMultipleClasses(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); + +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/obs-vst3.cpp b/plugins/obs-vst3/obs-vst3.cpp new file mode 100644 index 00000000000000..bacf5cc4c57986 --- /dev/null +++ b/plugins/obs-vst3/obs-vst3.cpp @@ -0,0 +1,1209 @@ +/****************************************************************************** + 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" + +#include "VST3HostApp.h" +#include "VST3Plugin.h" +#include "VST3Scanner.h" +#ifdef __linux__ +#include "RunLoopImpl.h" + +#include +#endif +#include + +#include + +#include +#include +#include +#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 ---------------- +namespace { +std::unique_ptr g_host_app; +#ifdef __linux__ +std::unique_ptr g_run_loop; +#endif +} // namespace + +VST3HostApp *get_host_app() noexcept +{ + return g_host_app.get(); +} + +bool load_host() +{ + VST3Backend backend = VST3Backend::Unknown; + +#ifdef _WIN32 + backend = VST3Backend::Windows; +#elif defined(__APPLE__) + backend = VST3Backend::MacOS; +#elif defined(__linux__) + const auto platform = obs_get_nix_platform(); + + if (platform == OBS_NIX_PLATFORM_X11_EGL) { + backend = VST3Backend::X11; + } else if (platform == OBS_NIX_PLATFORM_WAYLAND) { + backend = VST3Backend::Wayland; + } +#endif + + if (backend == VST3Backend::Unknown) { + blog(LOG_WARNING, "[VST3 Host] Unsupported platform"); + return false; + } + + auto hostApp = std::make_unique(backend); + +#ifdef __linux__ + auto runLoop = std::make_unique(); + hostApp->setRunLoop(runLoop.get()); +#endif + + g_host_app = std::move(hostApp); + +#ifdef __linux__ + g_run_loop = std::move(runLoop); +#endif + + return true; +} + +void unload_host() +{ +#ifdef __linux__ + if (g_host_app) { + g_host_app->setRunLoop(nullptr); + } + + if (g_run_loop) { + g_run_loop->stop(); + } + + g_run_loop.reset(); +#endif + + g_host_app.reset(); +} +// -------------------- initial scanning ------------------ +VST3Scanner *list; +std::atomic vst3_scan_done; + +static void vst3_cache_save() +{ + if (!list) { + return; + } + + char *path = obs_module_config_path(nullptr); + os_mkdirs(path); + bfree(path); + + char *filepath = obs_module_config_path("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); + + list->updateModulesList(modules); + + 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; + } + + 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", + static_cast(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; +} + +static bool is_valid_hex(const std::string &hex) +{ + if (hex.empty()) { + return false; + } + + if ((hex.size() & 1) != 0) { + return false; + } + + for (char c : hex) { + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'))) { + return false; + } + } + return true; +} + +std::string toHex(const std::vector &data) +{ + std::ostringstream oss; + for (auto b : data) { + oss << std::hex << std::setw(2) << std::setfill('0') << static_cast(b); + } + + return oss.str(); +} + +std::vector fromHex(const std::string &hex) +{ + if (!is_valid_hex(hex)) { + blog(LOG_INFO, "Corrupted VST3 settings."); + return {}; + } + + std::vector data; + for (size_t i = 0; i + 1 < hex.size(); i += 2) { + data.push_back(static_cast(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 management -------------------------- + +static inline void clear_deque(struct deque *buf) +{ + deque_pop_front(buf, nullptr, 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) +{ + auto *vd = static_cast(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->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, nullptr); + } + + 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) +{ + 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->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; + constexpr int max_block = FRAME_SIZE; + + Steinberg::Vst::SpeakerArrangement arr = obs_to_vst3_speaker_arrangement(vd->layout); + + auto *raw = new VST3Plugin(); + raw->obsVst3Data = 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()); + } + + 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->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->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 = nullptr; + + { + std::lock_guard lock(vd->sidechain_update_mutex); + if (!valid_sidechain) { + { + if (vd->weak_sidechain) { + old_weak_sidechain = vd->weak_sidechain; + vd->weak_sidechain = nullptr; + } + 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 = nullptr; + } + 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 a 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 that's inherited from the obs-filter compressor... TODO: revisit both filters later to improve that. +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; + + 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); + } + if (!plugin->loadStates(comp, ctrl)) { + infovst3("VST3 failure; failed to load settings."); + } + } + } + // 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(); + + 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); + + constexpr auto frames = static_cast(FRAME_SIZE); + vd->frames = frames; + const 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); + + vd->copy_buffers[0] = static_cast(bmalloc(static_cast(FRAME_SIZE) * channels * sizeof(float))); + vd->sc_copy_buffers[0] = static_cast(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; + } + + 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) +{ + auto *vd = static_cast(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) +{ + const int num_channels = static_cast(vd->channels); + const int sc_num_channels = static_cast(vd->sc_channels); + const int frames = static_cast(vd->frames); + const size_t segment_size = vd->frames * sizeof(float); + const bool has_sc = vd->has_sidechain.load(std::memory_order_relaxed); + const 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); + } + } + } + + 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)); + } + } + + for (int ch = 0; ch < num_channels; ++ch) { + auto *inBuf = vd->copy_buffers[ch]; + float *vstIn = plugin->channelBuffer32(Steinberg::Vst::kInput, ch); + if (inBuf && vstIn) { + memcpy(vstIn, inBuf, frames * sizeof(float)); + } + } + + if (has_sc && sc_enabled) { + const 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, + static_cast(vd->frames))) { + for (int ch = 0; ch < sc_num_channels; ++ch) { + auto *inBuf = reinterpret_cast(resampled[ch]); + float *vstIn = plugin->auxChannelBuffer32(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->auxChannelBuffer32(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) +{ + const int num_channels = static_cast(vd->channels); + const int frames = static_cast(vd->frames); + + preprocess_input(vd, plugin); + plugin->process(frames); + + for (int ch = 0; ch < num_channels; ++ch) { + auto *outBuf = reinterpret_cast(vd->copy_buffers[ch]); + float *vstOut = plugin->channelBuffer32(Steinberg::Vst::kOutput, ch); + if (outBuf && vstOut) { + memcpy(outBuf, vstOut, frames * sizeof(float)); + } + } + + 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) +{ + auto *vd = static_cast(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->numEnabledOutputAudioBuses) { + 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(static_cast(vd->last_timestamp) - static_cast(audio->timestamp)); + + if (diff > 1000000000LL) { + reset_data(vd); + } + } + + vd->last_timestamp = audio->timestamp; + + info.frames = audio->frames; + info.timestamp = audio->timestamp; + deque_push_back(&vd->info_buffer, &info, sizeof(info)); + + for (size_t i = 0; i < vd->channels; i++) { + deque_push_back(&vd->input_buffers[i], audio->data[i], audio->frames * sizeof(float)); + } + + while (vd->input_buffers[0].size >= segment_size) { + process(vd, p); + } + + 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 nullptr; + } + + deque_pop_front(&vd->info_buffer, nullptr, 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] = reinterpret_cast(&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); + auto *vd = static_cast(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 (vd->sc_last_timestamp) { + int64_t diff = + llabs(static_cast(vd->sc_last_timestamp) - static_cast(audio->timestamp)); + + if (diff > 1000000000LL) { + reset_sidechain_data(vd); + } + } + + vd->sc_last_timestamp = audio->timestamp; + + { + 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) +{ + auto *vd = static_cast(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) : nullptr; + { + 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 = nullptr; + } + } + 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 = {}; + struct resample_info dst = {}; + src.samples_per_sec = vd->sample_rate; + src.format = AUDIO_FORMAT_FLOAT_PLANAR; + src.speakers = convert_speaker_layout(static_cast(vd->channels)); + + dst.samples_per_sec = vd->sample_rate; + dst.format = AUDIO_FORMAT_FLOAT_PLANAR; + dst.speakers = convert_speaker_layout(static_cast(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); + auto *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) +{ + const auto *info = static_cast(data); + const 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 = static_cast(priv); + if (!vd) { + return false; + } + + const 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 = static_cast(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"), ""); + + const bool scanDone = vst3_scan_done.load(std::memory_order_acquire); + + if (scanDone) { + for (const auto &plugin : list->pluginList) { + const bool multi = list->moduleHasMultipleClasses(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()); + } + } else { + obs_property_set_enabled(vst3list, false); + 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); + } + + obs_property_t *gui = obs_properties_add_button2(props, S_EDITOR, obs_module_text(TEXT_EDITOR), + vst3_show_gui_callback, nullptr); + 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); + } + + 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..9396de912c18c8 --- /dev/null +++ b/plugins/obs-vst3/obs-vst3.h @@ -0,0 +1,83 @@ +/****************************************************************************** + 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 + +#define MAX_PREPROC_CHANNELS 8 +#define MAX_SC_CHANNELS 2 +#define BUFFER_SIZE_MSEC 10 +#define FRAME_SIZE 480 + +class VST3HostApp; +class VST3Plugin; + +VST3HostApp *get_host_app() noexcept; + +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; + + int 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]; + + float *copy_buffers[MAX_PREPROC_CHANNELS]; + float *sc_copy_buffers[MAX_PREPROC_CHANNELS]; + + struct obs_audio_data output_audio; + DARRAY(float) output_data; + + std::atomic bypass; + std::atomic sidechain_enabled; + std::atomic noview; + std::atomic_flag init_in_progress = ATOMIC_FLAG_INIT; + + 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; + std::mutex sidechain_mutex; + + 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..b732b033ebbb88 --- /dev/null +++ b/plugins/obs-vst3/plugin-main.cpp @@ -0,0 +1,64 @@ +/****************************************************************************** + 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 + +extern bool 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 (!load_host()) { + return false; + } + + if (!retrieve_vst3_list()) { + blog(LOG_INFO, "OBS-VST3: you'll have to install VST3s in order 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(); +}