diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..a53ddfb --- /dev/null +++ b/.gitmodules @@ -0,0 +1,9 @@ +[submodule "RVCRealtimeVST/third_party/iPlug2"] + path = RVCRealtimeVST/third_party/iPlug2 + url = https://github.com/iPlug2/iPlug2.git +[submodule "RVCRealtimeVST/third_party/vst3sdk"] + path = RVCRealtimeVST/third_party/vst3sdk + url = https://github.com/steinbergmedia/vst3sdk.git +[submodule "RVCRealtimeVST/third_party/vst2sdk"] + path = RVCRealtimeVST/third_party/vst2sdk + url = https://github.com/Xaymar/vst2sdk.git diff --git a/RVCRealtimeVST/.gitignore b/RVCRealtimeVST/.gitignore new file mode 100644 index 0000000..e3719b0 --- /dev/null +++ b/RVCRealtimeVST/.gitignore @@ -0,0 +1,17 @@ +/build/ +/build-*/ +/dist/ +/logs/ +/tools/vst3-validator-build/ +*.aps +*.db +*.exp +*.ilk +*.lib +*.obj +*.pdb +*.sln +*.suo +*.user +*.vcxproj +*.vcxproj.filters diff --git a/RVCRealtimeVST/CMakeLists.txt b/RVCRealtimeVST/CMakeLists.txt new file mode 100644 index 0000000..58fe301 --- /dev/null +++ b/RVCRealtimeVST/CMakeLists.txt @@ -0,0 +1,95 @@ +cmake_minimum_required(VERSION 3.14) + +project(RVCRealtime VERSION 0.1.0 LANGUAGES C CXX RC) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) +option(RVC_BUILD_SMOKE_TEST "Build the standalone worker smoke test" ON) + +set(IPLUG_DEPLOY_PLUGINS OFF CACHE BOOL "Keep plugin artifacts inside this project" FORCE) +set(IPLUG2_DIR "${CMAKE_CURRENT_SOURCE_DIR}/third_party/iPlug2" CACHE PATH "iPlug2 root directory") + +if(NOT EXISTS "${IPLUG2_DIR}/Dependencies/IPlug/VST3_SDK/base/source/baseiids.cpp" + OR NOT EXISTS "${IPLUG2_DIR}/Dependencies/IPlug/VST2_SDK/aeffectx.h") + message(FATAL_ERROR + "Prepared VST SDK files are missing. Run scripts/prepare-dependencies.ps1 " + "or use scripts/build.ps1, which runs it automatically.") +endif() + +include(${IPLUG2_DIR}/iPlug2.cmake) +find_package(iPlug2 REQUIRED) + +iplug_add_plugin(${PROJECT_NAME} + SOURCES + src/RVCRealtime.cpp + src/RVCRealtime.h + src/WorkerClient.cpp + src/WorkerClient.hpp + src/SpscRing.hpp + src/RvcParameters.hpp + config.h + resources/resource.h + resources/main.rc + RESOURCES + resources/fonts/Roboto-Regular.ttf + FORMATS + VST2 + VST3 + UI IGRAPHICS + DEFINES + UNICODE + _UNICODE + NOMINMAX) + +if(TARGET RVCRealtime-vst2) + target_include_directories(RVCRealtime-vst2 BEFORE PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/third_party/vst2-compat" + "${CMAKE_CURRENT_SOURCE_DIR}/third_party/vst2sdk/include") +endif() + +if(MSVC) + foreach(target RVCRealtime-vst2 RVCRealtime-vst3) + if(TARGET ${target}) + target_compile_options(${target} PRIVATE /W4 /permissive- /utf-8) + endif() + endforeach() +endif() + +if(RVC_BUILD_SMOKE_TEST) + add_executable(rvc-worker-smoke + tools/worker_smoke.cpp + src/WorkerClient.cpp + src/WorkerClient.hpp + src/SpscRing.hpp + src/RvcParameters.hpp + config.h) + target_include_directories(rvc-worker-smoke PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}" + "${CMAKE_CURRENT_SOURCE_DIR}/src") + target_compile_definitions(rvc-worker-smoke PRIVATE + UNICODE + _UNICODE + NOMINMAX) + if(MSVC) + target_compile_options(rvc-worker-smoke PRIVATE /W4 /permissive- /utf-8) + endif() + add_custom_command(TARGET rvc-worker-smoke POST_BUILD + COMMAND ${CMAKE_COMMAND} -E make_directory + "$/RVCRealtime.resources/worker" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${CMAKE_CURRENT_SOURCE_DIR}/worker/rvc_worker.py" + "$/RVCRealtime.resources/worker/rvc_worker.py") + + add_executable(rvc-vst2-smoke tools/vst2_smoke.cpp) + target_include_directories(rvc-vst2-smoke PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/third_party/vst2-compat" + "${CMAKE_CURRENT_SOURCE_DIR}/third_party/vst2sdk/include") + target_compile_definitions(rvc-vst2-smoke PRIVATE + UNICODE + _UNICODE + NOMINMAX) + if(MSVC) + target_compile_options(rvc-vst2-smoke PRIVATE /W4 /permissive- /utf-8) + endif() +endif() diff --git a/RVCRealtimeVST/LICENSE.txt b/RVCRealtimeVST/LICENSE.txt new file mode 100644 index 0000000..16e2eed --- /dev/null +++ b/RVCRealtimeVST/LICENSE.txt @@ -0,0 +1,13 @@ +RVC Realtime Plugin + +Copyright (c) 2026 RVC Realtime contributors + +This software is provided 'as-is', without any express or implied warranty. In no event will the authors be held liable for any damages arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, including commercial applications, and to alter it and redistribute it freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not claim that you wrote the original software. +2. Altered source versions must be plainly marked as such, and must not be misrepresented as being the original software. +3. This notice may not be removed or altered from any source distribution. + +Third-party components retain their respective notices and terms under third_party. diff --git a/RVCRealtimeVST/THIRD_PARTY_NOTICES.md b/RVCRealtimeVST/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..77a6615 --- /dev/null +++ b/RVCRealtimeVST/THIRD_PARTY_NOTICES.md @@ -0,0 +1,40 @@ +# Third-Party Notices + +The RVC Realtime VST source tree uses the following third-party components. +Each dependency remains under its own license. + +## iPlug2 + +- Source: https://github.com/iPlug2/iPlug2 +- Locked commit: `5c2df9dce3f5258acfeff3846a6a9563f382212c` +- License: zlib-style license +- License file: `third_party/iPlug2/LICENSE.txt` + +## Steinberg VST 3 SDK + +- Source: https://github.com/steinbergmedia/vst3sdk +- Locked commit: `58f8da7936800732561402d7936584ca4505de07` +- License: MIT +- License file: `third_party/vst3sdk/LICENSE.txt` + +The required nested SDK repositories are locked by the VST 3 SDK gitlinks. +The build initializes `base`, `cmake`, `pluginterfaces`, and `public.sdk`. + +## Xaymar VST2 SDK + +- Source: https://github.com/Xaymar/vst2sdk +- Locked commit: `339d4f31590bf77c0d0d248e09a380ac6285e069` +- License: BSD-3-Clause +- License file: `third_party/vst2sdk/LICENSE` + +`third_party/vst2-compat/aeffectx.h` supplies the compatibility names used by +iPlug2 and is backed by the BSD-3-Clause ABI declarations above. + +## Roboto + +- Font file: `resources/fonts/Roboto-Regular.ttf` +- License: Apache License 2.0 +- License copy: `third_party/licenses/Roboto-Apache-2.0.txt` + +The font file is byte-identical to the Roboto resource in the locked iPlug2 +source tree. diff --git a/RVCRealtimeVST/config.h b/RVCRealtimeVST/config.h new file mode 100644 index 0000000..2146fff --- /dev/null +++ b/RVCRealtimeVST/config.h @@ -0,0 +1,41 @@ +#pragma once + +#define PLUG_NAME "RVC Realtime" +#define PLUG_MFR "RVC Project" +#define PLUG_VERSION_HEX 0x00010000 +#define PLUG_VERSION_STR "0.1.0" +#define PLUG_UNIQUE_ID 'Rvcr' +#define PLUG_MFR_ID 'Rvcp' +#define PLUG_URL_STR "https://github.com/iPlug2/iPlug2" +#define PLUG_EMAIL_STR "" +#define PLUG_COPYRIGHT_STR "Copyright 2026 RVC Realtime contributors" +#define PLUG_CLASS_NAME RVCRealtime + +#define BUNDLE_NAME "RVCRealtime" +#define BUNDLE_MFR "RVCProject" +#define BUNDLE_DOMAIN "org" +#define SHARED_RESOURCES_SUBPATH "RVCRealtime" + +#define PLUG_CHANNEL_IO "1-1 1-2 2-2" +#define PLUG_LATENCY 12480 +#define PLUG_TYPE 0 +#define PLUG_DOES_MIDI_IN 0 +#define PLUG_DOES_MIDI_OUT 0 +#define PLUG_DOES_MPE 0 +#define PLUG_DOES_STATE_CHUNKS 1 +#define PLUG_HAS_UI 1 +#define PLUG_WIDTH 780 +#define PLUG_HEIGHT 630 +#define PLUG_FPS 30 +#define PLUG_SHARED_RESOURCES 0 +#define PLUG_HOST_RESIZE 1 + +#define VST3_SUBCATEGORY "Fx" +#define ROBOTO_FN "Roboto-Regular.ttf" + +#define RVC_WORKER_RELATIVE_PATH "worker\\rvc_worker.py" +#define RVC_VST2_RESOURCES_DIR "RVCRealtime.resources" +#define RVC_DEFAULT_ROOT "" +#define RVC_DEFAULT_PYTHON "" +#define RVC_DEFAULT_MODEL "" +#define RVC_DEFAULT_INDEX "" diff --git a/RVCRealtimeVST/resources/README.txt b/RVCRealtimeVST/resources/README.txt new file mode 100644 index 0000000..d83d717 --- /dev/null +++ b/RVCRealtimeVST/resources/README.txt @@ -0,0 +1,87 @@ +RVC Realtime - Studio One 安装与使用说明 +======================================== + +适用系统 +-------- +- Windows 10/11 64 位 +- Studio One 64 位 +- 用户已经拥有可正常运行的 RVC 源码和 Python/CUDA 环境整合包 + +压缩包内容 +---------- +VST2\ + RVC Realtime.dll + RVCRealtime.resources\worker\rvc_worker.py + +VST3\ + RVCRealtime.vst3\ + +VST2 安装方法 +------------- +1. 把 VST2 文件夹中的以下两个项目一起复制到 Studio One 已扫描的 VST2 插件目录: + - RVC Realtime.dll + - RVCRealtime.resources 文件夹 +2. DLL 和 RVCRealtime.resources 必须保持同级,不能只复制 DLL。 +3. 常见的自定义 VST2 目录示例:C:\VSTPlugins +4. 在 Studio One 的“选项/位置/VST 插件”中确认该目录已加入扫描列表,然后重新扫描插件。 + +正确示例: +C:\VSTPlugins\RVC Realtime.dll +C:\VSTPlugins\RVCRealtime.resources\worker\rvc_worker.py + +VST3 安装方法 +------------- +1. 把 VST3 文件夹中的整个 RVCRealtime.vst3 文件夹复制到: + C:\Program Files\Common Files\VST3\ +2. 不要只复制 Contents 里面的单个文件。 +3. 复制后在 Studio One 中重新扫描插件。 + +正确示例: +C:\Program Files\Common Files\VST3\RVCRealtime.vst3\Contents\x86_64-win\RVCRealtime.vst3 + +首次使用 +-------- +1. 在 Studio One 的音轨上加载“RVC Realtime”。 +2. 点击 RVC ROOT,选择已有 RVC 源码与环境整合包的根目录。 +3. 插件会自动检测该目录下的 runtime\python.exe。 +4. PYTHON 保持空白时,手动选择整合包中可用的 64 位 python.exe。 +5. 选择 .pth 模型;需要索引检索时再选择 .index 文件。 +6. 点击右下角 ENGINE。状态变成 READY 后开始输出变声结果。 + +RVC 整合包要求 +------------- +所选根目录至少需要包含: +- infer\rtrvc.py +- configs\config.py +- Python 环境及其依赖 +- HuBERT、RMVPE 等原 RVC 实时推理所需文件 +- 用户选择的 .pth 模型 + +配置与日志 +---------- +- 最后一次成功启动的路径配置保存在: + %LOCALAPPDATA%\RVCRealtime\settings.ini +- 运行日志保存在: + %TEMP%\RVCRealtime\logs\ +- Worker 的临时 JSON 配置也保存在上述临时目录,系统清理临时文件时可自动删除。 +- VST2 与 VST3 共用最后一次成功配置。 +- 删除 settings.ini 可以清除已保存的路径。 + +常见问题 +-------- +- Studio One 找不到 VST2:确认已添加 DLL 所在目录并重新扫描。 +- Studio One 找不到 VST3:确认复制的是整个 .vst3 文件夹。 +- VST2 提示 worker resource is missing:RVCRealtime.resources 没有与 DLL 放在同一目录。 +- 启动提示 Python 错误:重新选择 RVC ROOT,并检查 runtime\python.exe 或手动选择 Python。 +- 启动提示模型或源码缺失:检查界面选择路径以及 RVC 整合包内容。 +- 系统提示缺少 MSVC DLL:安装 Microsoft Visual C++ 2015-2022 Redistributable x64。 + +操作说明 +-------- +- 单击并拖动滑块:调整参数。 +- 双击滑块数值:直接输入数字。 +- BLOCK 可选范围为 20~1000 ms。 +- CROSSFADE 可选范围为 10~100 ms。 +- 与原版实时 GUI 一致,实际 SOLA Crossfade 为 CROSSFADE 和 40 ms 中的较小值,与 BLOCK 独立。 +- 例如 BLOCK 20 ms、CROSSFADE 100 ms 时,实际 SOLA Crossfade 为 40 ms。 +- 状态栏的 actual CF 会显示当前实际使用的 Crossfade。 diff --git a/RVCRealtimeVST/resources/fonts/Roboto-Regular.ttf b/RVCRealtimeVST/resources/fonts/Roboto-Regular.ttf new file mode 100644 index 0000000..3e6e2e7 Binary files /dev/null and b/RVCRealtimeVST/resources/fonts/Roboto-Regular.ttf differ diff --git a/RVCRealtimeVST/resources/main.rc b/RVCRealtimeVST/resources/main.rc new file mode 100644 index 0000000..64b8684 --- /dev/null +++ b/RVCRealtimeVST/resources/main.rc @@ -0,0 +1,37 @@ +#include "resource.h" +#include "../config.h" +#include + +ROBOTO_FN TTF "fonts/Roboto-Regular.ttf" + +VS_VERSION_INFO VERSIONINFO + FILEVERSION 0,1,0,0 + PRODUCTVERSION 0,1,0,0 + FILEFLAGSMASK 0x3fL +#ifdef _DEBUG + FILEFLAGS 0x1L +#else + FILEFLAGS 0x0L +#endif + FILEOS 0x40004L + FILETYPE 0x2L + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "FileVersion", PLUG_VERSION_STR + VALUE "ProductVersion", PLUG_VERSION_STR + VALUE "FileDescription", PLUG_NAME + VALUE "InternalName", BUNDLE_NAME + VALUE "ProductName", PLUG_NAME + VALUE "CompanyName", PLUG_MFR + VALUE "LegalCopyright", PLUG_COPYRIGHT_STR + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END diff --git a/RVCRealtimeVST/resources/resource.h b/RVCRealtimeVST/resources/resource.h new file mode 100644 index 0000000..654de80 --- /dev/null +++ b/RVCRealtimeVST/resources/resource.h @@ -0,0 +1,3 @@ +#pragma once + +#define IDR_RVC_FONT 101 diff --git a/RVCRealtimeVST/scripts/build.ps1 b/RVCRealtimeVST/scripts/build.ps1 new file mode 100644 index 0000000..0ac437d --- /dev/null +++ b/RVCRealtimeVST/scripts/build.ps1 @@ -0,0 +1,76 @@ +$ErrorActionPreference = "Stop" + +$Root = Split-Path -Parent $PSScriptRoot +$Build = Join-Path $Root "build" +$Dist = Join-Path $Root "dist" +$PackageName = "RVCRealtime-Win64" +$Package = Join-Path $Dist $PackageName +$Zip = Join-Path $Dist "$PackageName.zip" + +& (Join-Path $PSScriptRoot "prepare-dependencies.ps1") + +cmake -S $Root -B $Build -G "Visual Studio 17 2022" -A x64 +if ($LASTEXITCODE -ne 0) { + throw "CMake configuration failed with exit code $LASTEXITCODE" +} +cmake --build $Build --config Release --target RVCRealtime-vst2 RVCRealtime-vst3 --parallel +if ($LASTEXITCODE -ne 0) { + throw "Plugin build failed with exit code $LASTEXITCODE" +} + +New-Item -ItemType Directory -Force -Path $Dist | Out-Null +$Vst2Source = Join-Path $Build "out\RVCRealtime.dll" +$Vst3Source = Join-Path $Build "out\RVCRealtime.vst3" +$LegacyVst3 = Join-Path $Dist "RVC Realtime.vst3" + +Copy-Item -Force $Vst2Source (Join-Path $Dist "RVC Realtime.dll") +if (Test-Path $LegacyVst3) { + Remove-Item -Recurse -Force $LegacyVst3 +} +if (Test-Path (Join-Path $Dist "RVCRealtime.vst3")) { + Remove-Item -Recurse -Force (Join-Path $Dist "RVCRealtime.vst3") +} +Copy-Item -Recurse -Force $Vst3Source (Join-Path $Dist "RVCRealtime.vst3") +$LooseResources = Join-Path $Dist "RVCRealtime.resources" +if (Test-Path $LooseResources) { + Remove-Item -Recurse -Force $LooseResources +} +New-Item -ItemType Directory -Force -Path (Join-Path $LooseResources "worker") | Out-Null +Copy-Item -Force (Join-Path $Root "worker\rvc_worker.py") (Join-Path $LooseResources "worker\rvc_worker.py") + +$LooseVst3Worker = Join-Path $Dist "RVCRealtime.vst3\Contents\Resources\worker" +New-Item -ItemType Directory -Force -Path $LooseVst3Worker | Out-Null +Copy-Item -Force (Join-Path $Root "worker\rvc_worker.py") (Join-Path $LooseVst3Worker "rvc_worker.py") + +if (Test-Path $Package) { + Remove-Item -Recurse -Force $Package +} +if (Test-Path $Zip) { + Remove-Item -Force $Zip +} +$PackageVst2 = Join-Path $Package "VST2" +$PackageVst3 = Join-Path $Package "VST3" +New-Item -ItemType Directory -Force -Path $PackageVst2, $PackageVst3 | Out-Null +Copy-Item -Force (Join-Path $Dist "RVC Realtime.dll") (Join-Path $PackageVst2 "RVC Realtime.dll") +Copy-Item -Recurse -Force $LooseResources (Join-Path $PackageVst2 "RVCRealtime.resources") +Copy-Item -Recurse -Force (Join-Path $Dist "RVCRealtime.vst3") (Join-Path $PackageVst3 "RVCRealtime.vst3") +$ReadmeSource = Join-Path $Root "resources\README.txt" +$ReadmeDestination = Join-Path $Package "README.txt" +$Utf8WithBom = New-Object System.Text.UTF8Encoding -ArgumentList $true +[System.IO.File]::WriteAllText($ReadmeDestination, [System.IO.File]::ReadAllText($ReadmeSource), $Utf8WithBom) + +$RequiredReleaseFiles = @( + (Join-Path $Package "README.txt"), + (Join-Path $Package "VST2\RVC Realtime.dll"), + (Join-Path $Package "VST2\RVCRealtime.resources\worker\rvc_worker.py"), + (Join-Path $Package "VST3\RVCRealtime.vst3\Contents\x86_64-win\RVCRealtime.vst3"), + (Join-Path $Package "VST3\RVCRealtime.vst3\Contents\Resources\worker\rvc_worker.py") +) +foreach ($RequiredFile in $RequiredReleaseFiles) { + if (-not (Test-Path -LiteralPath $RequiredFile -PathType Leaf)) { + throw "Release package is missing: $RequiredFile" + } +} +Compress-Archive -Path $Package -DestinationPath $Zip -CompressionLevel Optimal + +Write-Host "Built artifacts and release ZIP: $Zip" diff --git a/RVCRealtimeVST/scripts/prepare-dependencies.ps1 b/RVCRealtimeVST/scripts/prepare-dependencies.ps1 new file mode 100644 index 0000000..d693ab0 --- /dev/null +++ b/RVCRealtimeVST/scripts/prepare-dependencies.ps1 @@ -0,0 +1,76 @@ +$ErrorActionPreference = "Stop" + +$Root = Split-Path -Parent $PSScriptRoot +$ThirdParty = Join-Path $Root "third_party" +$IPlug2 = Join-Path $ThirdParty "iPlug2" +$Vst3Source = Join-Path $ThirdParty "vst3sdk" +$Vst2Source = Join-Path $ThirdParty "vst2sdk" +$Vst2Compat = Join-Path $ThirdParty "vst2-compat\aeffectx.h" + +$ExpectedCommits = [ordered]@{ + $IPlug2 = "5c2df9dce3f5258acfeff3846a6a9563f382212c" + $Vst3Source = "58f8da7936800732561402d7936584ca4505de07" + $Vst2Source = "339d4f31590bf77c0d0d248e09a380ac6285e069" +} + +foreach ($Entry in $ExpectedCommits.GetEnumerator()) { + if (-not (Test-Path -LiteralPath (Join-Path $Entry.Key ".git"))) { + throw "Submodule is missing: $($Entry.Key). Run: git submodule update --init --recursive" + } + $ActualCommit = (& git -C $Entry.Key rev-parse HEAD).Trim() + if ($LASTEXITCODE -ne 0 -or $ActualCommit -ne $Entry.Value) { + throw "Unexpected submodule revision at $($Entry.Key): $ActualCommit (expected $($Entry.Value))" + } +} + +$RequiredVst3Modules = @("base", "cmake", "pluginterfaces", "public.sdk") +$RequiredVst3ModuleFiles = @( + "base\source\baseiids.cpp", + "cmake\modules\SMTG_AddVST3Library.cmake", + "pluginterfaces\base\funknown.cpp", + "public.sdk\source\main\dllmain.cpp" +) +$MissingVst3Modules = $RequiredVst3ModuleFiles | Where-Object { + -not (Test-Path -LiteralPath (Join-Path $Vst3Source $_) -PathType Leaf) +} +if ($MissingVst3Modules.Count -gt 0) { + & git -C $Vst3Source submodule update --init @RequiredVst3Modules + if ($LASTEXITCODE -ne 0) { + throw "Failed to initialize the required nested VST3 SDK submodules." + } +} + +$Vst3Destination = Join-Path $IPlug2 "Dependencies\IPlug\VST3_SDK" +New-Item -ItemType Directory -Force -Path $Vst3Destination | Out-Null +foreach ($Directory in @("base", "cmake", "pluginterfaces", "public.sdk")) { + $Source = Join-Path $Vst3Source $Directory + if (-not (Test-Path -LiteralPath $Source -PathType Container)) { + throw "VST3 SDK directory is missing: $Source" + } + Copy-Item -LiteralPath $Source -Destination $Vst3Destination -Recurse -Force +} +foreach ($File in @("CMakeLists.txt", "LICENSE.txt")) { + Copy-Item -LiteralPath (Join-Path $Vst3Source $File) -Destination (Join-Path $Vst3Destination $File) -Force +} + +$Vst2Destination = Join-Path $IPlug2 "Dependencies\IPlug\VST2_SDK" +New-Item -ItemType Directory -Force -Path $Vst2Destination | Out-Null +Get-ChildItem -LiteralPath (Join-Path $Vst2Source "include") -File | ForEach-Object { + Copy-Item -LiteralPath $_.FullName -Destination (Join-Path $Vst2Destination $_.Name) -Force +} +Copy-Item -LiteralPath $Vst2Compat -Destination (Join-Path $Vst2Destination "aeffectx.h") -Force +Copy-Item -LiteralPath (Join-Path $Vst2Source "LICENSE") -Destination (Join-Path $Vst2Destination "LICENSE.BSD-3-Clause.txt") -Force + +$RequiredFiles = @( + (Join-Path $Vst3Destination "base\source\baseiids.cpp"), + (Join-Path $Vst3Destination "public.sdk\source\main\dllmain.cpp"), + (Join-Path $Vst2Destination "aeffectx.h"), + (Join-Path $Vst2Destination "vst.h") +) +foreach ($RequiredFile in $RequiredFiles) { + if (-not (Test-Path -LiteralPath $RequiredFile -PathType Leaf)) { + throw "Prepared dependency file is missing: $RequiredFile" + } +} + +Write-Host "Prepared locked iPlug2, VST3, and VST2 dependencies." diff --git a/RVCRealtimeVST/scripts/test-all.ps1 b/RVCRealtimeVST/scripts/test-all.ps1 new file mode 100644 index 0000000..d8a56c6 --- /dev/null +++ b/RVCRealtimeVST/scripts/test-all.ps1 @@ -0,0 +1,91 @@ +param( + [switch]$SkipWorker, + [string]$RvcRoot = "", + [string]$Python = "", + [string]$Model = "", + [string]$Index = "" +) + +$ErrorActionPreference = "Stop" + +$Root = Split-Path -Parent $PSScriptRoot +$Build = Join-Path $Root "build" +$Dist = Join-Path $Root "dist" +$ValidatorBuild = Join-Path $Build "v3vs" +$Validator = Join-Path $ValidatorBuild "bin\Release\validator.exe" + +& (Join-Path $PSScriptRoot "build.ps1") + +cmake -S $Root -B $Build -G "Visual Studio 17 2022" -A x64 -DRVC_BUILD_SMOKE_TEST=ON +if ($LASTEXITCODE -ne 0) { + throw "CMake configuration failed with exit code $LASTEXITCODE" +} +cmake --build $Build --config Release --target rvc-vst2-smoke rvc-worker-smoke --parallel +if ($LASTEXITCODE -ne 0) { + throw "Smoke-test build failed with exit code $LASTEXITCODE" +} + +& (Join-Path $Build "Release\rvc-vst2-smoke.exe") (Join-Path $Dist "RVC Realtime.dll") +if ($LASTEXITCODE -ne 0) { + throw "VST2 smoke test failed with exit code $LASTEXITCODE" +} + +if (-not (Test-Path $Validator)) { + $Sdk = Join-Path $Root "third_party\iPlug2\Dependencies\IPlug\VST3_SDK" + # Several validator source names exceed legacy MSBuild path limits when the + # repository is cloned deeply. A stable TEMP junction keeps compiler paths short. + $ValidatorSdk = Join-Path ([System.IO.Path]::GetTempPath()) "RVCRealtimeVST-vst3sdk-58f8da7" + if (-not (Test-Path -LiteralPath $ValidatorSdk)) { + New-Item -ItemType Junction -Path $ValidatorSdk -Target $Sdk | Out-Null + } + if (-not (Test-Path -LiteralPath (Join-Path $ValidatorSdk "CMakeLists.txt") -PathType Leaf)) { + throw "Short VST3 SDK path is invalid: $ValidatorSdk" + } + cmake -S $ValidatorSdk -B $ValidatorBuild -G "Visual Studio 17 2022" -A x64 ` + -DSMTG_ENABLE_VST3_PLUGIN_EXAMPLES=OFF ` + -DSMTG_ENABLE_VST3_HOSTING_EXAMPLES=ON ` + -DSMTG_ENABLE_VSTGUI_SUPPORT=OFF + if ($LASTEXITCODE -ne 0) { + throw "VST3 validator configuration failed with exit code $LASTEXITCODE" + } + cmake --build $ValidatorBuild --config Release --target validator --parallel + if ($LASTEXITCODE -ne 0) { + throw "VST3 validator build failed with exit code $LASTEXITCODE" + } +} + +& $Validator (Join-Path $Dist "RVCRealtime.vst3") +if ($LASTEXITCODE -ne 0) { + throw "VST3 validator failed with exit code $LASTEXITCODE" +} + +if (-not $SkipWorker) { + if ([string]::IsNullOrWhiteSpace($RvcRoot) -or [string]::IsNullOrWhiteSpace($Model)) { + throw "Specify -RvcRoot and -Model for the CUDA worker test, or use -SkipWorker." + } + if ([string]::IsNullOrWhiteSpace($Python)) { + $Python = Join-Path $RvcRoot "runtime\python.exe" + } + foreach ($RequiredPath in @($RvcRoot, $Python, $Model)) { + if (-not (Test-Path -LiteralPath $RequiredPath)) { + throw "Required worker test path does not exist: $RequiredPath" + } + } + if (-not [string]::IsNullOrWhiteSpace($Index) -and -not (Test-Path -LiteralPath $Index -PathType Leaf)) { + throw "Index path does not exist: $Index" + } + $WorkerArgs = @($RvcRoot, $Python, $Model) + if (-not [string]::IsNullOrWhiteSpace($Index)) { + $WorkerArgs += $Index + } + & (Join-Path $Build "Release\rvc-worker-smoke.exe") @WorkerArgs + if ($LASTEXITCODE -ne 0) { + throw "Worker smoke test failed with exit code $LASTEXITCODE" + } +} + +if ($SkipWorker) { + Write-Host "VST2 and VST3 format tests passed; CUDA worker test skipped." +} else { + Write-Host "All VST2, VST3, and CUDA worker tests passed." +} diff --git a/RVCRealtimeVST/scripts/test-worker.ps1 b/RVCRealtimeVST/scripts/test-worker.ps1 new file mode 100644 index 0000000..280bf79 --- /dev/null +++ b/RVCRealtimeVST/scripts/test-worker.ps1 @@ -0,0 +1,44 @@ +param( + [Parameter(Mandatory = $true)] + [string]$RvcRoot, + [string]$Python = "", + [Parameter(Mandatory = $true)] + [string]$Model, + [string]$Index = "" +) + +$ErrorActionPreference = "Stop" + +$Root = Split-Path -Parent $PSScriptRoot +$Build = Join-Path $Root "build" + +& (Join-Path $PSScriptRoot "prepare-dependencies.ps1") + +if ([string]::IsNullOrWhiteSpace($Python)) { + $Python = Join-Path $RvcRoot "runtime\python.exe" +} +foreach ($RequiredPath in @($RvcRoot, $Python, $Model)) { + if (-not (Test-Path -LiteralPath $RequiredPath)) { + throw "Required worker test path does not exist: $RequiredPath" + } +} +if (-not [string]::IsNullOrWhiteSpace($Index) -and -not (Test-Path -LiteralPath $Index -PathType Leaf)) { + throw "Index path does not exist: $Index" +} + +cmake -S $Root -B $Build -G "Visual Studio 17 2022" -A x64 -DRVC_BUILD_SMOKE_TEST=ON +if ($LASTEXITCODE -ne 0) { + throw "CMake configuration failed with exit code $LASTEXITCODE" +} +cmake --build $Build --config Release --target rvc-worker-smoke --parallel +if ($LASTEXITCODE -ne 0) { + throw "Worker smoke-test build failed with exit code $LASTEXITCODE" +} +$WorkerArgs = @($RvcRoot, $Python, $Model) +if (-not [string]::IsNullOrWhiteSpace($Index)) { + $WorkerArgs += $Index +} +& (Join-Path $Build "Release\rvc-worker-smoke.exe") @WorkerArgs +if ($LASTEXITCODE -ne 0) { + throw "Worker smoke test failed with exit code $LASTEXITCODE" +} diff --git a/RVCRealtimeVST/src/RVCRealtime.cpp b/RVCRealtimeVST/src/RVCRealtime.cpp new file mode 100644 index 0000000..8e0c1ef --- /dev/null +++ b/RVCRealtimeVST/src/RVCRealtime.cpp @@ -0,0 +1,663 @@ +#include "RVCRealtime.h" +#include "IPlug_include_in_plug_src.h" +#include "IPlugPaths.h" +#include "IControls.h" + +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#include +#endif + +namespace { + +const IColor kBackground = IColor(255, 18, 20, 23); +const IColor kPanel = IColor(255, 33, 36, 39); +const IColor kHeaderColor = IColor(255, 235, 234, 228); +const IColor kText = IColor(255, 233, 232, 226); +const IColor kMuted = IColor(255, 152, 155, 155); +const IColor kAccent = IColor(255, 41, 151, 126); +const IColor kAmber = IColor(255, 230, 160, 48); + +enum class PathRow { RvcRoot, Python, Model, Index }; + +std::wstring SettingsFilePath(const bool createDirectory) +{ + WDL_String directory; + iplug::INIPath(directory, BUNDLE_NAME); + const UTF8AsUTF16 directoryWide(directory.Get()); + if (createDirectory) + CreateDirectoryW(directoryWide.Get(), nullptr); + std::wstring result(directoryWide.Get()); + result += L"\\settings.ini"; + return result; +} + +std::string ReadSetting(const wchar_t* key) +{ + const std::wstring settingsPath = SettingsFilePath(false); + std::vector value(32768, L'\0'); + GetPrivateProfileStringW(L"Paths", key, L"", value.data(), static_cast(value.size()), settingsPath.c_str()); + return UTF16AsUTF8(value.data()).Get(); +} + +void WriteSetting(const std::wstring& settingsPath, const wchar_t* key, const std::string& value) +{ + WritePrivateProfileStringW(L"Paths", key, UTF8AsUTF16(value.c_str()).Get(), settingsPath.c_str()); +} + +bool PathIsFile(const std::string& path) +{ + if (path.empty()) + return false; + const DWORD attributes = GetFileAttributesW(UTF8AsUTF16(path.c_str()).Get()); + return attributes != INVALID_FILE_ATTRIBUTES && (attributes & FILE_ATTRIBUTE_DIRECTORY) == 0; +} + +bool PathIsDirectory(const std::string& path) +{ + if (path.empty()) + return false; + const DWORD attributes = GetFileAttributesW(UTF8AsUTF16(path.c_str()).Get()); + return attributes != INVALID_FILE_ATTRIBUTES && (attributes & FILE_ATTRIBUTE_DIRECTORY) != 0; +} + +std::string TrimTrailingSeparators(std::string path) +{ + while (path.size() > 3 && (path.back() == '\\' || path.back() == '/')) + path.pop_back(); + return path; +} + +std::string JoinPath(const std::string& root, const char* relative) +{ + if (root.empty()) + return {}; + std::string result = TrimTrailingSeparators(root); + result += "\\"; + result += relative; + return result; +} + +std::string ParentDirectory(const std::string& filePath) +{ + const std::size_t separator = filePath.find_last_of("\\/"); + return separator == std::string::npos ? std::string() : filePath.substr(0, separator); +} + +bool Is64BitExecutable(const std::string& path) +{ + DWORD binaryType = 0; + return GetBinaryTypeW(UTF8AsUTF16(path.c_str()).Get(), &binaryType) != FALSE + && binaryType == SCS_64BIT_BINARY; +} + +IVStyle MakeStyle() +{ + return DEFAULT_STYLE + .WithColor(kBG, kPanel) + .WithColor(kFG, kAccent) + .WithColor(kPR, kText) + .WithColor(kFR, IColor(255, 64, 68, 72)) + .WithLabelText(IText(13.f, kMuted, "Roboto-Regular", EAlign::Center)) + .WithValueText(IText(14.f, kText, "Roboto-Regular", EAlign::Center)) + .WithRoundness(0.15f) + .WithWidgetFrac(0.55f) + .WithDrawShadows(false) + .WithDrawFrame(false); +} + +class RVCSliderControl final : public IVSliderControl +{ +public: + using IVSliderControl::IVSliderControl; + + void OnMouseDown(float x, float y, const IMouseMod& mod) override + { + ISliderControlBase::OnMouseDown(x, y, mod); + } + + void OnMouseDblClick(float x, float y, const IMouseMod&) override + { + if (mStyle.showValue && mValueBounds.Contains(x, y)) + PromptUserInput(mValueBounds); + } +}; + +const char* StatusName(const int status) +{ + switch (status) { + case rvc::kStatusStarting: return "STARTING"; + case rvc::kStatusLoading: return "LOADING MODEL"; + case rvc::kStatusReady: return "READY"; + case rvc::kStatusError: return "ERROR"; + default: return "ENGINE OFF"; + } +} + +} // namespace + +RVCRealtime::RVCRealtime(const InstanceInfo& info) +: Plugin(info, MakeConfig(kNumParams, kNumPresets)) +{ + GetParam(kEngine)->InitBool("Engine", false); + GetParam(kPitch)->InitInt("Pitch", 12, -24, 24, "st"); + GetParam(kFormant)->InitDouble("Formant", 0.0, -12.0, 12.0, 0.01, "st"); + GetParam(kIndexRate)->InitDouble("Index", 0.0, 0.0, 1.0, 0.001); + GetParam(kRmsMix)->InitDouble("RMS Mix", 0.5, 0.0, 1.0, 0.001); + GetParam(kThreshold)->InitDouble("Gate", -60.0, -60.0, 0.0, 0.1, "dB"); + GetParam(kBlockMs)->InitInt("Block", 130, 20, 1000, "ms"); + GetParam(kCrossfadeMs)->InitInt("Crossfade", 80, 10, 100, "ms"); + GetParam(kExtraMs)->InitInt("Context", 2000, 500, 3000, "ms"); + GetParam(kF0Method)->InitEnum("F0 Method", 0, {"RMVPE", "FCPE", "PM"}); + GetParam(kDryWet)->InitDouble("Mix", 100.0, 0.0, 100.0, 0.1, "%"); + GetParam(kOutputGain)->InitDouble("Output", 0.0, -18.0, 12.0, 0.1, "dB"); + + LoadUserConfiguration(); + if (mPythonPath.GetLength() == 0 && mRvcRoot.GetLength() > 0) { + const std::string detected = JoinPath(mRvcRoot.Get(), "runtime\\python.exe"); + if (PathIsFile(detected)) + mPythonPath.Set(detected.c_str()); + } + const std::string modelDirectory = ParentDirectory(mModelPath.Get()); + const std::string indexDirectory = ParentDirectory(mIndexPath.Get()); + mModelBrowseDirectory.Set(modelDirectory.empty() ? mRvcRoot.Get() : modelDirectory.c_str()); + mIndexBrowseDirectory.Set(indexDirectory.empty() ? mRvcRoot.Get() : indexDirectory.c_str()); + + mWorker.setPath(rvc::kStateModelPath, mModelPath.Get()); + mWorker.setPath(rvc::kStateIndexPath, mIndexPath.Get()); + mWorker.setPath(rvc::kStateRvcRoot, mRvcRoot.Get()); + mWorker.setPath(rvc::kStatePythonPath, mPythonPath.Get()); + SyncParametersToWorker(); + +#if IPLUG_EDITOR + mMakeGraphicsFunc = [&]() { + return MakeGraphics(*this, PLUG_WIDTH, PLUG_HEIGHT, PLUG_FPS, + GetScaleForScreen(PLUG_WIDTH, PLUG_HEIGHT)); + }; + + mLayoutFunc = [&](IGraphics* graphics) { + graphics->EnableMouseOver(true); + graphics->AttachCornerResizer(EUIResizerMode::Scale, false); // drag corner to scale whole UI + graphics->AttachPanelBackground(kBackground); + graphics->LoadFont("Roboto-Regular", ROBOTO_FN); + const IVStyle style = MakeStyle(); + const IRECT bounds = graphics->GetBounds(); + + // Header + graphics->AttachControl(new IPanelControl(bounds.GetFromTop(86.f), kHeaderColor)); + graphics->AttachControl(new ITextControl(IRECT(28, 10, 420, 52), "RVC REALTIME", + IText(28.f, IColor(255, 23, 25, 27), "Roboto-Regular", EAlign::Near))); + graphics->AttachControl(new ITextControl(IRECT(30, 50, 420, 76), "VOICE CONVERSION / CUDA BRIDGE", + IText(12.f, IColor(255, 85, 89, 90), "Roboto-Regular", EAlign::Near))); + graphics->AttachControl(new ITextControl(IRECT(560, 16, 750, 42), "ENGINE OFF", + IText(14.f, IColor(255, 80, 84, 86), "Roboto-Regular", EAlign::Far)), kCtrlStatus); + graphics->AttachControl(new ITextControl(IRECT(560, 42, 750, 66), "0 ms / 0 drop", + IText(12.f, IColor(255, 100, 104, 105), "Roboto-Regular", EAlign::Far)), kCtrlPerformance); + + // Runtime and model paths + auto attachFileRow = [&](const float y, const char* label, const int textTag, const PathRow pathRow) { + const float rowHeight = 36.f; + graphics->AttachControl(new ITextControl(IRECT(30, y, 92, y + rowHeight), label, + IText(13.f, kMuted, "Roboto-Regular", EAlign::Near))); + graphics->AttachControl(new IPanelControl(IRECT(96, y, 692, y + rowHeight), kPanel)); + const char* initial = ""; + switch (pathRow) { + case PathRow::RvcRoot: initial = mRvcRoot.Get(); break; + case PathRow::Python: initial = mPythonPath.Get(); break; + case PathRow::Model: initial = mModelPath.get_filepart(); break; + case PathRow::Index: initial = mIndexPath.get_filepart(); break; + } + graphics->AttachControl(new ITextControl(IRECT(110, y, 680, y + rowHeight), initial, + IText(12.f, kText, "Roboto-Regular", EAlign::Near)), textTag); + auto action = [this, graphics, pathRow](IControl*) { + switch (pathRow) { + case PathRow::RvcRoot: ChooseRvcRoot(graphics); break; + case PathRow::Python: ChoosePython(graphics); break; + case PathRow::Model: ChooseModel(graphics); break; + case PathRow::Index: ChooseIndex(graphics); break; + } + }; + graphics->AttachControl(new IVButtonControl(IRECT(700, y, 750, y + rowHeight), action, "...", + style.WithLabelText(IText(18.f, kText, "Roboto-Regular", EAlign::Center)), true)); + }; + attachFileRow(100.f, "RVC ROOT", kCtrlRvcRoot, PathRow::RvcRoot); + attachFileRow(142.f, "PYTHON", kCtrlPythonPath, PathRow::Python); + attachFileRow(184.f, "MODEL", kCtrlModelName, PathRow::Model); + attachFileRow(226.f, "INDEX", kCtrlIndexName, PathRow::Index); + graphics->AttachControl(new ITextControl(IRECT(96, 266, 750, 290), "Select RVC root and Python runtime", + IText(12.f, kAmber, "Roboto-Regular", EAlign::Near)), kCtrlStatusDetail); + + // 3x3 slider grid + struct SliderLayout { int param; const char* label; }; + const SliderLayout sliders[] = { + {kPitch, "PITCH"}, {kFormant, "FORMANT"}, {kIndexRate, "INDEX"}, + {kRmsMix, "RMS MIX"}, {kThreshold, "GATE"}, {kDryWet, "MIX"}, + {kBlockMs, "BLOCK"}, {kCrossfadeMs, "CROSSFADE"}, {kExtraMs, "CONTEXT"} + }; + const float gridLeft = 30.f, gridTop = 300.f; + const float cellWidth = 226.f, cellHeight = 62.f; + const float gapX = 21.f, gapY = 10.f; + for (int i = 0; i < 9; ++i) { + const int column = i % 3; + const int row = i / 3; + const float x = gridLeft + column * (cellWidth + gapX); + const float y = gridTop + row * (cellHeight + gapY); + const IRECT cell(x, y, x + cellWidth, y + cellHeight); + graphics->AttachControl(new IPanelControl(cell, kPanel)); + graphics->AttachControl(new RVCSliderControl(cell.GetPadded(-8.f), sliders[i].param, + sliders[i].label, style, true, EDirection::Horizontal)); + } + + // Bottom row: F0 method, output gain, engine toggle — one shared baseline + const float bottomY = 566.f, bottomHeight = 44.f; + graphics->AttachControl(new ITextControl(IRECT(30, bottomY, 100, bottomY + bottomHeight), "F0 METHOD", + IText(12.f, kMuted, "Roboto-Regular", EAlign::Near))); + graphics->AttachControl(new IVMenuButtonControl(IRECT(104, bottomY, 236, bottomY + bottomHeight), kF0Method, + "", style)); + graphics->AttachControl(new IPanelControl(IRECT(258, bottomY, 560, bottomY + bottomHeight), kPanel)); + graphics->AttachControl(new RVCSliderControl(IRECT(266, bottomY + 2, 552, bottomY + bottomHeight - 2), kOutputGain, + "OUTPUT", style, true, EDirection::Horizontal)); + graphics->AttachControl(new IVToggleControl(IRECT(600, bottomY, 750, bottomY + bottomHeight), kEngine, + "ENGINE", style)); + }; +#endif +} + +#if IPLUG_DSP +void RVCRealtime::OnReset() +{ + const double sampleRate = GetSampleRate() > 0.0 ? GetSampleRate() : 48000.0; + const int blockSize = std::max(1, GetBlockSize()); + mWorker.setSampleRate(sampleRate); + ResizeBuffers(blockSize, sampleRate); + SyncParametersToWorker(); + const int latency = CalculateLatencyFrames(GetParam(kBlockMs)->Value()); + mTargetDelayFrames.store(latency, std::memory_order_relaxed); + SetLatency(latency); +} + +void RVCRealtime::OnParamChange(const int paramIdx) +{ + if (paramIdx < 0 || paramIdx >= kNumParams) + return; + double value = GetParam(paramIdx)->Value(); + if (paramIdx == kDryWet) + value /= 100.0; + mWorker.setParameter(static_cast(paramIdx), static_cast(value)); + if (paramIdx == kEngine) { + if (value >= 0.5) { + std::string error; + if (!ValidateConfiguration(error)) { + { + std::lock_guard lock(mStateMutex); + mValidationMessage.Set(error.c_str()); + } + GetParam(kEngine)->Set(0.0); + SendParameterValueFromAPI(kEngine, 0.0, false); + mWorker.setEnabled(false); + return; + } + { + std::lock_guard lock(mStateMutex); + mValidationMessage.Set(""); + } + SaveUserConfiguration(); + mWorker.setEnabled(true); + } else { + mWorker.setEnabled(false); + } + } + if (paramIdx == kBlockMs) { + const int latency = CalculateLatencyFrames(value); + mTargetDelayFrames.store(latency, std::memory_order_relaxed); + SetLatency(latency); + } +} + +void RVCRealtime::ProcessBlock(sample** inputs, sample** outputs, const int nFrames) +{ + const int nIn = NInChansConnected(); + const int nOut = NOutChansConnected(); + if (nFrames <= 0 || nOut <= 0) + return; + + if (nIn <= 0) { + for (int channel = 0; channel < nOut; ++channel) + std::memset(outputs[channel], 0, static_cast(nFrames) * sizeof(sample)); + return; + } + + // Mono-safe passthrough: never leave output buffers unwritten. + auto passthrough = [&]() { + for (int channel = 0; channel < nOut; ++channel) { + const sample* source = inputs[std::min(channel, nIn - 1)]; + if (outputs[channel] != source) + std::memcpy(outputs[channel], source, static_cast(nFrames) * sizeof(sample)); + } + }; + + if (nFrames > static_cast(mMonoInput.size())) { + passthrough(); + return; + } + + const bool requested = GetParam(kEngine)->Bool(); + if (!requested || !mWorker.isReady()) { + mActiveBlend = std::max(0.0f, mActiveBlend - static_cast(nFrames) / 1024.0f); + passthrough(); + return; + } + + const sample* inL = inputs[0]; + const sample* inR = inputs[nIn > 1 ? 1 : 0]; + sample* outL = outputs[0]; + sample* outR = nOut > 1 ? outputs[1] : nullptr; + + for (int frame = 0; frame < nFrames; ++frame) + mMonoInput[static_cast(frame)] = static_cast(0.5 * (inL[frame] + inR[frame])); + mWorker.pushInput(mMonoInput.data(), static_cast(nFrames)); + const size_t wetRead = mWorker.popOutput(mWetOutput.data(), static_cast(nFrames)); + std::fill(mWetOutput.begin() + static_cast(wetRead), + mWetOutput.begin() + nFrames, 0.0f); + + const float mix = static_cast(GetParam(kDryWet)->Value() / 100.0); + const float gain = static_cast(std::pow(10.0, GetParam(kOutputGain)->Value() / 20.0)); + const int maxDelay = static_cast(mDryDelay.size() / 2); + const int delay = std::clamp(mTargetDelayFrames.load(std::memory_order_relaxed), 0, maxDelay - 1); + const int readPosition = (mDryWritePosition + maxDelay - delay) % maxDelay; + mActiveBlend = std::min(1.0f, mActiveBlend + static_cast(nFrames) / 2048.0f); + + for (int frame = 0; frame < nFrames; ++frame) { + const int write = (mDryWritePosition + frame) % maxDelay; + const int read = (readPosition + frame) % maxDelay; + mDryDelay[static_cast(write) * 2] = static_cast(inL[frame]); + mDryDelay[static_cast(write) * 2 + 1] = static_cast(inR[frame]); + const float wet = mWetOutput[static_cast(frame)] * gain; + const float processedL = mDryDelay[static_cast(read) * 2] * (1.0f - mix) + wet * mix; + const float processedR = mDryDelay[static_cast(read) * 2 + 1] * (1.0f - mix) + wet * mix; + outL[frame] = static_cast(inL[frame] * (1.0f - mActiveBlend) + processedL * mActiveBlend); + if (outR) + outR[frame] = static_cast(inR[frame] * (1.0f - mActiveBlend) + processedR * mActiveBlend); + } + mDryWritePosition = (mDryWritePosition + nFrames) % maxDelay; + + // Copy to any extra connected outputs beyond stereo. + for (int channel = 2; channel < nOut; ++channel) + std::memcpy(outputs[channel], outputs[channel & 1], static_cast(nFrames) * sizeof(sample)); +} +#endif + +void RVCRealtime::OnIdle() +{ +#if IPLUG_EDITOR + if (GetUI() == nullptr) + return; + std::string validationMessage; + { + std::lock_guard lock(mStateMutex); + validationMessage = mValidationMessage.Get(); + } + if (auto* status = GetUI()->GetControlWithTag(kCtrlStatus)) + status->As()->SetStr(validationMessage.empty() ? StatusName(mWorker.status()) : "CONFIG ERROR"); + if (auto* detail = GetUI()->GetControlWithTag(kCtrlStatusDetail)) + detail->As()->SetStr(validationMessage.empty() ? mWorker.statusText().c_str() : validationMessage.c_str()); + if (auto* performance = GetUI()->GetControlWithTag(kCtrlPerformance)) + performance->As()->SetStrFmt(80, "%.0f ms / %.0f drop", mWorker.inferMs(), mWorker.droppedBlocks()); +#endif +} + +void RVCRealtime::OnUIOpen() +{ + Plugin::OnUIOpen(); // pushes current param values to the UI controls + UpdateFileLabels(); +} + +bool RVCRealtime::SerializeState(IByteChunk& chunk) const +{ + std::lock_guard lock(mStateMutex); + chunk.PutStr(mModelPath.Get()); + chunk.PutStr(mIndexPath.Get()); + chunk.PutStr(mRvcRoot.Get()); + chunk.PutStr(mPythonPath.Get()); + return SerializeParams(chunk); +} + +int RVCRealtime::UnserializeState(const IByteChunk& chunk, int startPos) +{ + WDL_String model, index, root, python; + startPos = chunk.GetStr(model, startPos); + startPos = chunk.GetStr(index, startPos); + startPos = chunk.GetStr(root, startPos); + startPos = chunk.GetStr(python, startPos); + if (startPos < 0) + return startPos; + { + std::lock_guard lock(mStateMutex); + mModelPath.Set(model.Get()); + mIndexPath.Set(index.Get()); + mRvcRoot.Set(root.Get()); + mPythonPath.Set(python.Get()); + } + mWorker.setPath(rvc::kStateModelPath, model.Get()); + mWorker.setPath(rvc::kStateIndexPath, index.Get()); + mWorker.setPath(rvc::kStateRvcRoot, root.Get()); + mWorker.setPath(rvc::kStatePythonPath, python.Get()); + startPos = UnserializeParams(chunk, startPos); + SyncParametersToWorker(); + UpdateFileLabels(); + return startPos; +} + +void RVCRealtime::SyncParametersToWorker() +{ + for (int i = 0; i < kNumParams; ++i) + OnParamChange(i); +} + +int RVCRealtime::CalculateLatencyFrames(const double blockMs) const +{ + const double sampleRate = GetSampleRate() > 0.0 ? GetSampleRate() : 48000.0; + const double zc = std::max(1.0, std::floor(sampleRate / 100.0)); + const int block = static_cast(std::round(blockMs / 1000.0 * sampleRate / zc) * zc); + return block * 2; +} + +void RVCRealtime::ResizeBuffers(const int blockSize, const double sampleRate) +{ + const size_t scratch = static_cast(std::max(blockSize, 131072)); + mMonoInput.assign(scratch, 0.0f); + mWetOutput.assign(scratch, 0.0f); + mDryDelay.assign(static_cast(std::max(1.0, sampleRate * 4.0)) * 2, 0.0f); + mDryWritePosition = 0; + mActiveBlend = 0.0f; +} + +void RVCRealtime::ChooseRvcRoot(IGraphics* graphics) +{ + WDL_String directory(mRvcRoot.Get()); + graphics->PromptForDirectory(directory, [this](const WDL_String&, const WDL_String& selectedDirectory) { + if (selectedDirectory.GetLength() > 0) + SetRvcRoot(selectedDirectory.Get()); + }); +} + +void RVCRealtime::ChoosePython(IGraphics* graphics) +{ + WDL_String fileName, path; + const std::string currentDirectory = ParentDirectory(mPythonPath.Get()); + if (!currentDirectory.empty()) + path.Set(currentDirectory.c_str()); + else if (mRvcRoot.GetLength() > 0) + path.Set(JoinPath(mRvcRoot.Get(), "runtime").c_str()); + graphics->PromptForFile(fileName, path, EFileAction::Open, "exe", + [this](const WDL_String& selected, const WDL_String&) { + if (selected.GetLength() > 0) + SetPythonPath(selected.Get()); + }); +} + +void RVCRealtime::ChooseModel(IGraphics* graphics) +{ + WDL_String fileName, path(mModelBrowseDirectory.Get()); + if (path.GetLength() == 0) + path.Set(mRvcRoot.Get()); + graphics->PromptForFile(fileName, path, EFileAction::Open, "pth", + [this](const WDL_String& selected, const WDL_String& selectedDirectory) { + if (selected.GetLength() > 0) { + mModelBrowseDirectory.Set(selectedDirectory.Get()); + SetModelPath(selected.Get()); + } + }); +} + +void RVCRealtime::ChooseIndex(IGraphics* graphics) +{ + WDL_String fileName, path(mIndexBrowseDirectory.Get()); + if (path.GetLength() == 0) + path.Set(mRvcRoot.Get()); + graphics->PromptForFile(fileName, path, EFileAction::Open, "index", + [this](const WDL_String& selected, const WDL_String& selectedDirectory) { + if (selected.GetLength() > 0) { + mIndexBrowseDirectory.Set(selectedDirectory.Get()); + SetIndexPath(selected.Get()); + } + }); +} + +void RVCRealtime::SetRvcRoot(const char* path) +{ + const std::string root = TrimTrailingSeparators(path != nullptr ? path : ""); + const std::string detectedPython = JoinPath(root, "runtime\\python.exe"); + const bool pythonDetected = PathIsFile(detectedPython); + { + std::lock_guard lock(mStateMutex); + mRvcRoot.Set(root.c_str()); + mPythonPath.Set(pythonDetected ? detectedPython.c_str() : ""); + mModelBrowseDirectory.Set(root.c_str()); + mIndexBrowseDirectory.Set(root.c_str()); + mValidationMessage.Set(pythonDetected ? "" : "runtime\\python.exe not found; select Python manually."); + } + mWorker.setPath(rvc::kStateRvcRoot, root.c_str()); + mWorker.setPath(rvc::kStatePythonPath, pythonDetected ? detectedPython.c_str() : ""); + StopEngineForPathChange(); + UpdateFileLabels(); +} + +void RVCRealtime::SetPythonPath(const char* path) +{ + const char* value = path != nullptr ? path : ""; + { + std::lock_guard lock(mStateMutex); + mPythonPath.Set(value); + mValidationMessage.Set(""); + } + mWorker.setPath(rvc::kStatePythonPath, value); + StopEngineForPathChange(); + UpdateFileLabels(); +} + +void RVCRealtime::SetModelPath(const char* path) +{ + { + std::lock_guard lock(mStateMutex); + mModelPath.Set(path); + } + mWorker.setPath(rvc::kStateModelPath, path); + StopEngineForPathChange(); + UpdateFileLabels(); +} + +void RVCRealtime::SetIndexPath(const char* path) +{ + { + std::lock_guard lock(mStateMutex); + mIndexPath.Set(path); + } + mWorker.setPath(rvc::kStateIndexPath, path); + StopEngineForPathChange(); + UpdateFileLabels(); +} + +void RVCRealtime::StopEngineForPathChange() +{ + mWorker.setEnabled(false); + if (GetParam(kEngine)->Bool()) { + GetParam(kEngine)->Set(0.0); + SendParameterValueFromAPI(kEngine, 0.0, false); + } +} + +bool RVCRealtime::ValidateConfiguration(std::string& error) const +{ + std::string root, python, model, index; + { + std::lock_guard lock(mStateMutex); + root = mRvcRoot.Get(); + python = mPythonPath.Get(); + model = mModelPath.Get(); + index = mIndexPath.Get(); + } + if (root.empty()) { error = "Select the RVC package root."; return false; } + if (!PathIsDirectory(root)) { error = "RVC root folder does not exist."; return false; } + if (!PathIsFile(JoinPath(root, "infer\\rtrvc.py"))) { error = "RVC source not found: infer\\rtrvc.py."; return false; } + if (!PathIsFile(JoinPath(root, "configs\\config.py"))) { error = "RVC source not found: configs\\config.py."; return false; } + if (python.empty()) { error = "runtime\\python.exe not found; select Python manually."; return false; } + if (!PathIsFile(python)) { error = "Selected Python executable does not exist."; return false; } + if (!Is64BitExecutable(python)) { error = "Selected Python must be a 64-bit executable."; return false; } + if (model.empty()) { error = "Select an RVC .pth model."; return false; } + if (!PathIsFile(model)) { error = "Selected model file does not exist."; return false; } + if (!index.empty() && !PathIsFile(index)) { error = "Selected index file does not exist."; return false; } + error.clear(); + return true; +} + +void RVCRealtime::LoadUserConfiguration() +{ + const std::string root = ReadSetting(L"RvcRoot"); + const std::string python = ReadSetting(L"PythonPath"); + const std::string model = ReadSetting(L"ModelPath"); + const std::string index = ReadSetting(L"IndexPath"); + if (!root.empty()) mRvcRoot.Set(root.c_str()); + if (!python.empty()) mPythonPath.Set(python.c_str()); + if (!model.empty()) mModelPath.Set(model.c_str()); + if (!index.empty()) mIndexPath.Set(index.c_str()); +} + +void RVCRealtime::SaveUserConfiguration() const +{ + std::string root, python, model, index; + { + std::lock_guard lock(mStateMutex); + root = mRvcRoot.Get(); + python = mPythonPath.Get(); + model = mModelPath.Get(); + index = mIndexPath.Get(); + } + const std::wstring settingsPath = SettingsFilePath(true); + WriteSetting(settingsPath, L"RvcRoot", root); + WriteSetting(settingsPath, L"PythonPath", python); + WriteSetting(settingsPath, L"ModelPath", model); + WriteSetting(settingsPath, L"IndexPath", index); +} + +void RVCRealtime::UpdateFileLabels() +{ +#if IPLUG_EDITOR + if (GetUI() == nullptr) + return; + std::lock_guard lock(mStateMutex); + if (auto* root = GetUI()->GetControlWithTag(kCtrlRvcRoot)) + root->As()->SetStr(mRvcRoot.Get()); + if (auto* python = GetUI()->GetControlWithTag(kCtrlPythonPath)) + python->As()->SetStr(mPythonPath.Get()); + if (auto* model = GetUI()->GetControlWithTag(kCtrlModelName)) + model->As()->SetStr(mModelPath.get_filepart()); + if (auto* index = GetUI()->GetControlWithTag(kCtrlIndexName)) + index->As()->SetStr(mIndexPath.get_filepart()); +#endif +} diff --git a/RVCRealtimeVST/src/RVCRealtime.h b/RVCRealtimeVST/src/RVCRealtime.h new file mode 100644 index 0000000..8410c49 --- /dev/null +++ b/RVCRealtimeVST/src/RVCRealtime.h @@ -0,0 +1,96 @@ +#pragma once + +#include "IPlug_include_in_plug_hdr.h" + +#include "RvcParameters.hpp" +#include "WorkerClient.hpp" + +#include +#include +#include +#include + +const int kNumPresets = 1; + +enum EParams { + kEngine = 0, + kPitch, + kFormant, + kIndexRate, + kRmsMix, + kThreshold, + kBlockMs, + kCrossfadeMs, + kExtraMs, + kF0Method, + kDryWet, + kOutputGain, + kNumParams +}; + +enum EControlTags { + kCtrlStatus = 100, + kCtrlPerformance, + kCtrlStatusDetail, + kCtrlRvcRoot, + kCtrlPythonPath, + kCtrlModelName, + kCtrlIndexName +}; + +using namespace iplug; +using namespace igraphics; + +class RVCRealtime final : public Plugin { +public: + RVCRealtime(const InstanceInfo& info); + +#if IPLUG_DSP + void ProcessBlock(sample** inputs, sample** outputs, int nFrames) override; + void OnReset() override; + void OnParamChange(int paramIdx) override; +#endif + + void OnIdle() override; + void OnUIOpen() override; + bool SerializeState(IByteChunk& chunk) const override; + int UnserializeState(const IByteChunk& chunk, int startPos) override; + +private: + void SyncParametersToWorker(); + int CalculateLatencyFrames(double blockMs) const; + void ResizeBuffers(int blockSize, double sampleRate); + void ChooseRvcRoot(IGraphics* graphics); + void ChoosePython(IGraphics* graphics); + void ChooseModel(IGraphics* graphics); + void ChooseIndex(IGraphics* graphics); + void SetRvcRoot(const char* path); + void SetPythonPath(const char* path); + void SetModelPath(const char* path); + void SetIndexPath(const char* path); + void UpdateFileLabels(); + void StopEngineForPathChange(); + bool ValidateConfiguration(std::string& error) const; + void LoadUserConfiguration(); + void SaveUserConfiguration() const; + + rvc::WorkerClient mWorker; + std::vector mMonoInput; + std::vector mWetOutput; + std::vector mDryDelay; + int mDryWritePosition = 0; + std::atomic mTargetDelayFrames {12480}; + float mActiveBlend = 0.0f; + + mutable std::mutex mStateMutex; + WDL_String mModelPath {RVC_DEFAULT_MODEL}; + WDL_String mIndexPath {RVC_DEFAULT_INDEX}; + WDL_String mRvcRoot {RVC_DEFAULT_ROOT}; + WDL_String mPythonPath {RVC_DEFAULT_PYTHON}; + WDL_String mModelBrowseDirectory; + WDL_String mIndexBrowseDirectory; + WDL_String mValidationMessage; + + RVCRealtime(const RVCRealtime&) = delete; + RVCRealtime& operator=(const RVCRealtime&) = delete; +}; diff --git a/RVCRealtimeVST/src/RvcParameters.hpp b/RVCRealtimeVST/src/RvcParameters.hpp new file mode 100644 index 0000000..e9189af --- /dev/null +++ b/RVCRealtimeVST/src/RvcParameters.hpp @@ -0,0 +1,68 @@ +#pragma once + +#include + +namespace rvc { + +enum ParameterId : uint32_t { + kParamEngine = 0, + kParamPitch, + kParamFormant, + kParamIndexRate, + kParamRmsMix, + kParamThreshold, + kParamBlockMs, + kParamCrossfadeMs, + kParamExtraMs, + kParamF0Method, + kParamDryWet, + kParamOutputGain, + kParamStatus, + kParamInferMs, + kParamDroppedBlocks, + kParameterCount +}; + +enum StateId : uint32_t { + kStateModelPath = 0, + kStateIndexPath, + kStateRvcRoot, + kStatePythonPath, + kStateCount +}; + +enum WorkerStatus : int { + kStatusOff = 0, + kStatusStarting = 1, + kStatusLoading = 2, + kStatusReady = 3, + kStatusError = 4 +}; + +namespace ids { +inline constexpr const char* engine = "engine"; +inline constexpr const char* pitch = "pitch"; +inline constexpr const char* formant = "formant"; +inline constexpr const char* indexRate = "index_rate"; +inline constexpr const char* rmsMix = "rms_mix"; +inline constexpr const char* threshold = "threshold"; +inline constexpr const char* blockMs = "block_ms"; +inline constexpr const char* crossfadeMs = "crossfade_ms"; +inline constexpr const char* extraMs = "extra_ms"; +inline constexpr const char* f0Method = "f0_method"; +inline constexpr const char* dryWet = "mix"; +inline constexpr const char* outputGain = "output_gain"; +} // namespace ids + +inline const char* stateKey(const StateId id) +{ + switch (id) { + case kStateModelPath: return "modelPath"; + case kStateIndexPath: return "indexPath"; + case kStateRvcRoot: return "rvcRoot"; + case kStatePythonPath: return "pythonPath"; + default: return ""; + } +} + +} // namespace rvc diff --git a/RVCRealtimeVST/src/SpscRing.hpp b/RVCRealtimeVST/src/SpscRing.hpp new file mode 100644 index 0000000..ed08869 --- /dev/null +++ b/RVCRealtimeVST/src/SpscRing.hpp @@ -0,0 +1,98 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace rvc { + +class SpscFloatRing { +public: + explicit SpscFloatRing(const std::size_t requestedCapacity) + { + std::size_t capacity = 1; + while (capacity < requestedCapacity) + capacity <<= 1; + capacity_ = capacity; + mask_ = capacity - 1; + data_.reset(new float[capacity]); + std::memset(data_.get(), 0, capacity * sizeof(float)); + } + + std::size_t readable() const noexcept + { + const auto write = write_.load(std::memory_order_acquire); + const auto read = read_.load(std::memory_order_acquire); + return write - read; + } + + std::size_t writable() const noexcept + { + return capacity_ - readable(); + } + + std::size_t push(const float* src, std::size_t count) noexcept + { + const auto write = write_.load(std::memory_order_relaxed); + const auto read = read_.load(std::memory_order_acquire); + count = std::min(count, capacity_ - (write - read)); + if (count == 0) + return 0; + + const std::size_t pos = write & mask_; + const std::size_t first = std::min(count, capacity_ - pos); + std::memcpy(data_.get() + pos, src, first * sizeof(float)); + if (count > first) + std::memcpy(data_.get(), src + first, (count - first) * sizeof(float)); + write_.store(write + count, std::memory_order_release); + return count; + } + + std::size_t pushZeros(std::size_t count) noexcept + { + static constexpr float zeros[1024] = {}; + std::size_t total = 0; + while (total < count) { + const std::size_t chunk = std::min(1024, count - total); + const std::size_t written = push(zeros, chunk); + total += written; + if (written != chunk) + break; + } + return total; + } + + std::size_t pop(float* dst, std::size_t count) noexcept + { + const auto read = read_.load(std::memory_order_relaxed); + const auto write = write_.load(std::memory_order_acquire); + count = std::min(count, write - read); + if (count == 0) + return 0; + + const std::size_t pos = read & mask_; + const std::size_t first = std::min(count, capacity_ - pos); + std::memcpy(dst, data_.get() + pos, first * sizeof(float)); + if (count > first) + std::memcpy(dst + first, data_.get(), (count - first) * sizeof(float)); + read_.store(read + count, std::memory_order_release); + return count; + } + + void resetUnsafe() noexcept + { + read_.store(0, std::memory_order_relaxed); + write_.store(0, std::memory_order_relaxed); + } + +private: + std::unique_ptr data_; + std::size_t capacity_ = 0; + std::size_t mask_ = 0; + alignas(64) std::atomic write_ {0}; + alignas(64) std::atomic read_ {0}; +}; + +} // namespace rvc diff --git a/RVCRealtimeVST/src/WorkerClient.cpp b/RVCRealtimeVST/src/WorkerClient.cpp new file mode 100644 index 0000000..08d22e0 --- /dev/null +++ b/RVCRealtimeVST/src/WorkerClient.cpp @@ -0,0 +1,587 @@ +#include "WorkerClient.hpp" +#include "config.h" + +#if !defined(_WIN32) +#error RVC Realtime worker bridge currently targets Windows. +#endif + +#include + +#include +#include +#include +#include +#include +#include + +namespace rvc { +namespace { + +constexpr uint32_t kMagic = 0x50564352; // RVCP +constexpr uint32_t kProtocolVersion = 1; +constexpr uint32_t kHeaderBytes = 4096; +constexpr uint32_t kMaxFrames = 131072; +constexpr uint32_t kMapBytes = kHeaderBytes + kMaxFrames * sizeof(float) * 2; +constexpr uint32_t kInputOffset = kHeaderBytes; +constexpr uint32_t kOutputOffset = kHeaderBytes + kMaxFrames * sizeof(float); +constexpr uint32_t kStatusTextOffset = 128; +constexpr uint32_t kStatusTextBytes = 512; + +template +void writeAt(void* const base, const std::size_t offset, const T value) +{ + std::memcpy(static_cast(base) + offset, &value, sizeof(T)); +} + +template +T readAt(const void* const base, const std::size_t offset) +{ + T value {}; + std::memcpy(&value, static_cast(base) + offset, sizeof(T)); + return value; +} + +std::wstring utf8ToWide(const std::string& text) +{ + if (text.empty()) + return {}; + const int length = MultiByteToWideChar(CP_UTF8, 0, text.c_str(), -1, nullptr, 0); + std::wstring result(static_cast(length), L'\0'); + MultiByteToWideChar(CP_UTF8, 0, text.c_str(), -1, result.data(), length); + if (!result.empty() && result.back() == L'\0') + result.pop_back(); + return result; +} + +std::string wideToUtf8(const std::wstring& text) +{ + if (text.empty()) + return {}; + const int length = WideCharToMultiByte(CP_UTF8, 0, text.c_str(), -1, nullptr, 0, nullptr, nullptr); + std::string result(static_cast(length), '\0'); + WideCharToMultiByte(CP_UTF8, 0, text.c_str(), -1, result.data(), length, nullptr, nullptr); + if (!result.empty() && result.back() == '\0') + result.pop_back(); + return result; +} + +std::wstring moduleDirectory() +{ + static int moduleAnchor = 0; + HMODULE module = nullptr; + if (!GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + reinterpret_cast(&moduleAnchor), &module)) + return {}; + std::vector path(32768, L'\0'); + const DWORD length = GetModuleFileNameW(module, path.data(), static_cast(path.size())); + if (length == 0 || length >= path.size()) + return {}; + std::wstring result(path.data(), length); + const std::size_t separator = result.find_last_of(L"\\/"); + return separator == std::wstring::npos ? std::wstring() : result.substr(0, separator); +} + +bool isFile(const std::wstring& path) +{ + const DWORD attributes = GetFileAttributesW(path.c_str()); + return attributes != INVALID_FILE_ATTRIBUTES && (attributes & FILE_ATTRIBUTE_DIRECTORY) == 0; +} + +std::wstring workerScriptPath() +{ + const std::wstring directory = moduleDirectory(); + if (directory.empty()) + return {}; + const std::wstring relativeWorker = utf8ToWide(RVC_WORKER_RELATIVE_PATH); + const std::wstring vst2Resources = utf8ToWide(RVC_VST2_RESOURCES_DIR); + + const std::wstring vst3Path = directory + L"\\..\\Resources\\" + relativeWorker; + if (isFile(vst3Path)) + return vst3Path; + + const std::wstring vst2Path = directory + L"\\" + vst2Resources + L"\\" + relativeWorker; + if (isFile(vst2Path)) + return vst2Path; + + return {}; +} + +bool ensureDirectory(const std::wstring& path, std::string& error) +{ + if (CreateDirectoryW(path.c_str(), nullptr) != FALSE) + return true; + const DWORD code = GetLastError(); + if (code == ERROR_ALREADY_EXISTS) { + const DWORD attributes = GetFileAttributesW(path.c_str()); + if (attributes != INVALID_FILE_ATTRIBUTES && (attributes & FILE_ATTRIBUTE_DIRECTORY) != 0) + return true; + } + error = "Cannot create " + wideToUtf8(path) + " (Windows error " + std::to_string(code) + ")"; + return false; +} + +std::wstring temporaryLogDirectory(std::string& error) +{ + std::vector temporaryPath(32768, L'\0'); + const DWORD length = GetTempPathW(static_cast(temporaryPath.size()), temporaryPath.data()); + if (length == 0 || length >= temporaryPath.size()) { + error = "Windows temporary directory is unavailable (Windows error " + std::to_string(GetLastError()) + ")"; + return {}; + } + + std::wstring productDirectory(temporaryPath.data(), length); + if (!productDirectory.empty() && productDirectory.back() != L'\\') + productDirectory += L'\\'; + productDirectory += L"RVCRealtime"; + if (!ensureDirectory(productDirectory, error)) + return {}; + + const std::wstring logDirectory = productDirectory + L"\\logs"; + if (!ensureDirectory(logDirectory, error)) + return {}; + return logDirectory; +} + +std::string jsonEscape(const std::string& text) +{ + std::ostringstream out; + for (const unsigned char ch : text) { + switch (ch) { + case '\\': out << "\\\\"; break; + case '"': out << "\\\""; break; + case '\n': out << "\\n"; break; + case '\r': out << "\\r"; break; + case '\t': out << "\\t"; break; + default: + if (ch < 0x20) + out << "\\u" << std::hex << std::setw(4) << std::setfill('0') << static_cast(ch); + else + out << static_cast(ch); + } + } + return out.str(); +} + +std::string quoteArg(const std::string& text) +{ + return "\"" + text + "\""; +} + +} // namespace + +struct WorkerClient::Ipc { + HANDLE mapping = nullptr; + HANDLE requestEvent = nullptr; + HANDLE responseEvent = nullptr; + HANDLE process = nullptr; + HANDLE processThread = nullptr; + void* view = nullptr; + std::string mapName; + std::string requestName; + std::string responseName; + std::string configPath; + uint32_t sequence = 0; + + ~Ipc() + { + if (view != nullptr) + UnmapViewOfFile(view); + if (mapping != nullptr) + CloseHandle(mapping); + if (requestEvent != nullptr) + CloseHandle(requestEvent); + if (responseEvent != nullptr) + CloseHandle(responseEvent); + if (processThread != nullptr) + CloseHandle(processThread); + if (process != nullptr) + CloseHandle(process); + } +}; + +WorkerClient::WorkerClient() +{ + parameters_[kParamPitch].store(12.0f); + parameters_[kParamFormant].store(0.0f); + parameters_[kParamIndexRate].store(0.0f); + parameters_[kParamRmsMix].store(0.5f); + parameters_[kParamThreshold].store(-60.0f); + parameters_[kParamBlockMs].store(130.0f); + parameters_[kParamCrossfadeMs].store(80.0f); + parameters_[kParamExtraMs].store(2000.0f); + parameters_[kParamF0Method].store(0.0f); + + paths_.model = RVC_DEFAULT_MODEL; + paths_.index = RVC_DEFAULT_INDEX; + paths_.rvcRoot = RVC_DEFAULT_ROOT; + paths_.python = RVC_DEFAULT_PYTHON; + + thread_ = std::thread(&WorkerClient::threadMain, this); +} + +WorkerClient::~WorkerClient() +{ + stopRequested_.store(true, std::memory_order_release); + if (thread_.joinable()) + thread_.join(); +} + +void WorkerClient::setEnabled(const bool enabled) noexcept +{ + if (enabled_.exchange(enabled, std::memory_order_acq_rel) != enabled) + configVersion_.fetch_add(1, std::memory_order_release); +} + +void WorkerClient::setSampleRate(const double sampleRate) noexcept +{ + if (std::abs(sampleRate_.load(std::memory_order_relaxed) - sampleRate) > 0.5) { + sampleRate_.store(sampleRate, std::memory_order_relaxed); + configVersion_.fetch_add(1, std::memory_order_release); + } +} + +void WorkerClient::setParameter(const ParameterId id, const float value) noexcept +{ + if (id >= kParameterCount) + return; + const float old = parameters_[id].exchange(value, std::memory_order_relaxed); + if ((id == kParamBlockMs || id == kParamCrossfadeMs || id == kParamExtraMs) && std::abs(old - value) > 0.01f) + configVersion_.fetch_add(1, std::memory_order_release); +} + +void WorkerClient::setPath(const StateId id, const char* const value) +{ + if (value == nullptr) + return; + std::lock_guard lock(pathsMutex_); + switch (id) { + case kStateModelPath: paths_.model = value; break; + case kStateIndexPath: paths_.index = value; break; + case kStateRvcRoot: paths_.rvcRoot = value; break; + case kStatePythonPath: paths_.python = value; break; + default: return; + } + configVersion_.fetch_add(1, std::memory_order_release); +} + +std::size_t WorkerClient::pushInput(const float* const samples, const std::size_t count) noexcept +{ + if (!isReady()) + return 0; + const std::size_t pushed = inputRing_.push(samples, count); + if (pushed != count) + droppedBlocks_.fetch_add(1, std::memory_order_relaxed); + return pushed; +} + +std::size_t WorkerClient::popOutput(float* const samples, const std::size_t count) noexcept +{ + if (!isReady()) + return 0; + return outputRing_.pop(samples, count); +} + +std::string WorkerClient::statusText() const +{ + std::lock_guard lock(statusTextMutex_); + return statusText_; +} + +WorkerClient::Paths WorkerClient::pathsSnapshot() const +{ + std::lock_guard lock(pathsMutex_); + return paths_; +} + +void WorkerClient::setStatus(const int status, const std::string& text) +{ + status_.store(status, std::memory_order_release); + std::lock_guard lock(statusTextMutex_); + statusText_ = text; +} + +uint32_t WorkerClient::calculateBlockFrames() const noexcept +{ + const double sampleRate = sampleRate_.load(std::memory_order_relaxed); + const double zc = std::max(1.0, std::floor(sampleRate / 100.0)); + const double seconds = parameters_[kParamBlockMs].load(std::memory_order_relaxed) / 1000.0; + const auto frames = static_cast(std::round(seconds * sampleRate / zc) * zc); + return std::clamp(frames, static_cast(zc), kMaxFrames); +} + +std::string WorkerClient::writeWorkerConfig(const Paths& paths, std::string& error) const +{ + const DWORD pid = GetCurrentProcessId(); + const std::wstring logDirectory = temporaryLogDirectory(error); + if (logDirectory.empty()) + return {}; + + std::wostringstream name; + name << logDirectory << L"\\instance_" << pid << L"_" << GetTickCount64() << L".json"; + const std::wstring configPath = name.str(); + HANDLE file = CreateFileW(configPath.c_str(), GENERIC_WRITE, FILE_SHARE_READ, nullptr, + CREATE_ALWAYS, FILE_ATTRIBUTE_TEMPORARY, nullptr); + if (file == INVALID_HANDLE_VALUE) { + error = "Cannot create " + wideToUtf8(configPath) + " (Windows error " + + std::to_string(GetLastError()) + ")"; + return {}; + } + + std::ostringstream json; + json << "{\n" + << " \"rvc_root\": \"" << jsonEscape(paths.rvcRoot) << "\",\n" + << " \"model_path\": \"" << jsonEscape(paths.model) << "\",\n" + << " \"index_path\": \"" << jsonEscape(paths.index) << "\",\n" + << " \"sample_rate\": " << static_cast(sampleRate_.load()) << ",\n" + << " \"block_ms\": " << parameters_[kParamBlockMs].load() << ",\n" + << " \"crossfade_ms\": " << parameters_[kParamCrossfadeMs].load() << ",\n" + << " \"extra_ms\": " << parameters_[kParamExtraMs].load() << "\n" + << "}\n"; + const std::string contents = json.str(); + DWORD bytesWritten = 0; + const BOOL written = WriteFile(file, contents.data(), static_cast(contents.size()), &bytesWritten, nullptr); + const DWORD writeError = written != FALSE ? ERROR_SUCCESS : GetLastError(); + CloseHandle(file); + if (written == FALSE || bytesWritten != static_cast(contents.size())) { + DeleteFileW(configPath.c_str()); + error = "Cannot write " + wideToUtf8(configPath) + " (Windows error " + + std::to_string(writeError) + ")"; + return {}; + } + error.clear(); + return wideToUtf8(configPath); +} + +bool WorkerClient::launchWorker(const Paths& paths, const uint64_t) +{ + ipc_ = std::make_unique(); + const DWORD pid = GetCurrentProcessId(); + static std::atomic instanceCounter {0}; + const uint32_t instance = instanceCounter.fetch_add(1); + const std::string id = std::to_string(pid) + "_" + std::to_string(instance) + "_" + std::to_string(GetTickCount64()); + ipc_->mapName = "Local\\RVCVST_" + id + "_map"; + ipc_->requestName = "Local\\RVCVST_" + id + "_request"; + ipc_->responseName = "Local\\RVCVST_" + id + "_response"; + + const std::wstring mapName = utf8ToWide(ipc_->mapName); + const std::wstring requestName = utf8ToWide(ipc_->requestName); + const std::wstring responseName = utf8ToWide(ipc_->responseName); + ipc_->mapping = CreateFileMappingW(INVALID_HANDLE_VALUE, nullptr, PAGE_READWRITE, 0, kMapBytes, mapName.c_str()); + ipc_->requestEvent = CreateEventW(nullptr, FALSE, FALSE, requestName.c_str()); + ipc_->responseEvent = CreateEventW(nullptr, FALSE, FALSE, responseName.c_str()); + if (ipc_->mapping == nullptr || ipc_->requestEvent == nullptr || ipc_->responseEvent == nullptr) { + setStatus(kStatusError, "IPC initialization failed"); + stopWorker(); + return false; + } + + ipc_->view = MapViewOfFile(ipc_->mapping, FILE_MAP_ALL_ACCESS, 0, 0, kMapBytes); + if (ipc_->view == nullptr) { + setStatus(kStatusError, "Shared memory mapping failed"); + stopWorker(); + return false; + } + std::memset(ipc_->view, 0, kMapBytes); + writeAt(ipc_->view, 0, kMagic); + writeAt(ipc_->view, 4, kProtocolVersion); + writeAt(ipc_->view, 8, kStatusStarting); + + std::string configError; + ipc_->configPath = writeWorkerConfig(paths, configError); + if (ipc_->configPath.empty()) { + setStatus(kStatusError, configError.empty() ? "Could not write worker configuration" : configError); + stopWorker(); + return false; + } + + const std::wstring workerScript = workerScriptPath(); + if (workerScript.empty()) { + setStatus(kStatusError, "Plugin worker resource is missing"); + stopWorker(); + return false; + } + std::string command = quoteArg(paths.python) + " -I " + quoteArg(wideToUtf8(workerScript)) + + " --map " + quoteArg(ipc_->mapName) + + " --request " + quoteArg(ipc_->requestName) + + " --response " + quoteArg(ipc_->responseName) + + " --config " + quoteArg(ipc_->configPath); + std::wstring commandWide = utf8ToWide(command); + std::vector commandBuffer(commandWide.begin(), commandWide.end()); + commandBuffer.push_back(L'\0'); + + STARTUPINFOW startup {}; + startup.cb = sizeof(startup); + SECURITY_ATTRIBUTES security {}; + security.nLength = sizeof(security); + security.bInheritHandle = TRUE; + const std::wstring logPath = utf8ToWide(ipc_->configPath + ".process.log"); + HANDLE logFile = CreateFileW(logPath.c_str(), GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, + &security, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); + if (logFile != INVALID_HANDLE_VALUE) { + startup.dwFlags |= STARTF_USESTDHANDLES; + startup.hStdOutput = logFile; + startup.hStdError = logFile; + startup.hStdInput = GetStdHandle(STD_INPUT_HANDLE); + } + PROCESS_INFORMATION process {}; + const std::wstring cwd = utf8ToWide(paths.rvcRoot); + const BOOL inheritHandles = logFile != INVALID_HANDLE_VALUE; + const std::wstring application = utf8ToWide(paths.python); + const BOOL launched = CreateProcessW(application.c_str(), commandBuffer.data(), nullptr, nullptr, inheritHandles, + CREATE_NO_WINDOW, nullptr, cwd.c_str(), &startup, &process); + if (logFile != INVALID_HANDLE_VALUE) + CloseHandle(logFile); + if (!launched) { + setStatus(kStatusError, "Python worker launch failed"); + stopWorker(); + return false; + } + ipc_->process = process.hProcess; + ipc_->processThread = process.hThread; + setStatus(kStatusLoading, "Loading RVC model"); + return true; +} + +void WorkerClient::stopWorker() +{ + ready_.store(false, std::memory_order_release); + if (ipc_ && ipc_->view != nullptr && ipc_->requestEvent != nullptr) { + writeAt(ipc_->view, 8, -2); + SetEvent(ipc_->requestEvent); + } + if (ipc_ && ipc_->process != nullptr) { + if (WaitForSingleObject(ipc_->process, 1200) == WAIT_TIMEOUT) + TerminateProcess(ipc_->process, 0); + } + ipc_.reset(); +} + +bool WorkerClient::processOneBlock() +{ + if (!ipc_ || !ipc_->view) + return false; + const uint32_t frames = blockFrames_.load(std::memory_order_relaxed); + if (inputRing_.readable() < frames) + return true; + if (requestBuffer_.size() < frames) { + requestBuffer_.resize(frames); + responseBuffer_.resize(frames); + } + if (inputRing_.pop(requestBuffer_.data(), frames) != frames) + return true; + + const uint32_t sequence = ++ipc_->sequence; + writeAt(ipc_->view, 12, sequence); + writeAt(ipc_->view, 20, frames); + writeAt(ipc_->view, 24, static_cast(sampleRate_.load(std::memory_order_relaxed))); + writeAt(ipc_->view, 32, parameters_[kParamPitch].load(std::memory_order_relaxed)); + writeAt(ipc_->view, 36, parameters_[kParamFormant].load(std::memory_order_relaxed)); + writeAt(ipc_->view, 40, parameters_[kParamIndexRate].load(std::memory_order_relaxed)); + writeAt(ipc_->view, 44, parameters_[kParamRmsMix].load(std::memory_order_relaxed)); + writeAt(ipc_->view, 48, parameters_[kParamThreshold].load(std::memory_order_relaxed)); + writeAt(ipc_->view, 64, static_cast(std::round(parameters_[kParamF0Method].load(std::memory_order_relaxed)))); + std::memcpy(static_cast(ipc_->view) + kInputOffset, requestBuffer_.data(), frames * sizeof(float)); + SetEvent(ipc_->requestEvent); + + const DWORD timeout = std::max(5000, static_cast(parameters_[kParamBlockMs].load() * 8.0f)); + const DWORD wait = WaitForSingleObject(ipc_->responseEvent, timeout); + if (wait != WAIT_OBJECT_0) { + setStatus(kStatusError, "RVC inference timed out"); + return false; + } + if (readAt(ipc_->view, 16) != sequence) { + droppedBlocks_.fetch_add(1, std::memory_order_relaxed); + return true; + } + + const int workerState = readAt(ipc_->view, 8); + if (workerState < 0) { + const char* const text = reinterpret_cast(static_cast(ipc_->view) + kStatusTextOffset); + setStatus(kStatusError, text[0] != '\0' ? text : "Worker error"); + return false; + } + + inferMs_.store(readAt(ipc_->view, 56), std::memory_order_relaxed); + std::memcpy(responseBuffer_.data(), static_cast(ipc_->view) + kOutputOffset, frames * sizeof(float)); + if (outputRing_.push(responseBuffer_.data(), frames) != frames) + droppedBlocks_.fetch_add(1, std::memory_order_relaxed); + return true; +} + +void WorkerClient::threadMain() +{ + uint64_t activeVersion = 0; + while (!stopRequested_.load(std::memory_order_acquire)) { + const bool enabled = enabled_.load(std::memory_order_acquire); + const uint64_t requestedVersion = configVersion_.load(std::memory_order_acquire); + if (!enabled) { + if (ipc_) + stopWorker(); + setStatus(kStatusOff, "Off"); + std::this_thread::sleep_for(std::chrono::milliseconds(40)); + continue; + } + + if (!ipc_ || activeVersion != requestedVersion) { + stopWorker(); + const Paths paths = pathsSnapshot(); + if (paths.model.empty() || paths.rvcRoot.empty() || paths.python.empty()) { + setStatus(kStatusError, "Select a model and RVC runtime"); + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + continue; + } + activeVersion = requestedVersion; + blockFrames_.store(calculateBlockFrames(), std::memory_order_relaxed); + latencyFrames_.store(blockFrames_.load() * 2, std::memory_order_relaxed); + droppedBlocks_.store(0, std::memory_order_relaxed); + inferMs_.store(0.0f, std::memory_order_relaxed); + if (!launchWorker(paths, activeVersion)) { + std::this_thread::sleep_for(std::chrono::milliseconds(500)); + continue; + } + } + + if (!ready_.load(std::memory_order_acquire)) { + const int workerState = readAt(ipc_->view, 8); + const char* const workerText = reinterpret_cast(static_cast(ipc_->view) + kStatusTextOffset); + if (workerState == kStatusReady) { + inputRing_.resetUnsafe(); + outputRing_.resetUnsafe(); + outputRing_.pushZeros(latencyFrames_.load(std::memory_order_relaxed)); + setStatus(kStatusReady, workerText[0] != '\0' ? workerText : "Ready"); + ready_.store(true, std::memory_order_release); + } else if (workerState < 0) { + setStatus(kStatusError, workerText[0] != '\0' ? workerText : "Model load failed"); + stopWorker(); + std::this_thread::sleep_for(std::chrono::milliseconds(500)); + continue; + } else if (workerText[0] != '\0') { + setStatus(kStatusLoading, workerText); + } + if (WaitForSingleObject(ipc_->process, 0) == WAIT_OBJECT_0) { + if (workerState >= 0) { + DWORD exitCode = 0; + GetExitCodeProcess(ipc_->process, &exitCode); + setStatus(kStatusError, "Python worker exited with code " + std::to_string(exitCode)); + } + stopWorker(); + std::this_thread::sleep_for(std::chrono::milliseconds(500)); + continue; + } + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + continue; + } + + if (!processOneBlock()) { + ready_.store(false, std::memory_order_release); + stopWorker(); + std::this_thread::sleep_for(std::chrono::milliseconds(300)); + continue; + } + if (inputRing_.readable() < blockFrames_.load(std::memory_order_relaxed)) + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + stopWorker(); +} + +} // namespace rvc diff --git a/RVCRealtimeVST/src/WorkerClient.hpp b/RVCRealtimeVST/src/WorkerClient.hpp new file mode 100644 index 0000000..eab54f5 --- /dev/null +++ b/RVCRealtimeVST/src/WorkerClient.hpp @@ -0,0 +1,87 @@ +#pragma once + +#include "RvcParameters.hpp" +#include "SpscRing.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace rvc { + +class WorkerClient { +public: + WorkerClient(); + ~WorkerClient(); + + WorkerClient(const WorkerClient&) = delete; + WorkerClient& operator=(const WorkerClient&) = delete; + + void setEnabled(bool enabled) noexcept; + void setSampleRate(double sampleRate) noexcept; + void setParameter(ParameterId id, float value) noexcept; + void setPath(StateId id, const char* value); + + std::size_t pushInput(const float* samples, std::size_t count) noexcept; + std::size_t popOutput(float* samples, std::size_t count) noexcept; + + bool isReady() const noexcept { return ready_.load(std::memory_order_acquire); } + int status() const noexcept { return status_.load(std::memory_order_acquire); } + float inferMs() const noexcept { return inferMs_.load(std::memory_order_relaxed); } + float droppedBlocks() const noexcept { return static_cast(droppedBlocks_.load(std::memory_order_relaxed)); } + uint32_t blockFrames() const noexcept { return blockFrames_.load(std::memory_order_relaxed); } + uint32_t latencyFrames() const noexcept { return latencyFrames_.load(std::memory_order_relaxed); } + std::string statusText() const; + +private: + struct Paths { + std::string model; + std::string index; + std::string rvcRoot; + std::string python; + }; + + struct Ipc; + + void threadMain(); + bool launchWorker(const Paths& paths, uint64_t version); + void stopWorker(); + bool processOneBlock(); + Paths pathsSnapshot() const; + void setStatus(int status, const std::string& text); + uint32_t calculateBlockFrames() const noexcept; + std::string writeWorkerConfig(const Paths& paths, std::string& error) const; + + static constexpr std::size_t kRingCapacity = 1u << 20; + SpscFloatRing inputRing_ {kRingCapacity}; + SpscFloatRing outputRing_ {kRingCapacity}; + + std::array, kParameterCount> parameters_ {}; + std::atomic enabled_ {false}; + std::atomic stopRequested_ {false}; + std::atomic ready_ {false}; + std::atomic status_ {kStatusOff}; + std::atomic inferMs_ {0.0f}; + std::atomic droppedBlocks_ {0}; + std::atomic blockFrames_ {6240}; + std::atomic latencyFrames_ {12480}; + std::atomic sampleRate_ {48000.0}; + std::atomic configVersion_ {1}; + + mutable std::mutex pathsMutex_; + Paths paths_; + mutable std::mutex statusTextMutex_; + std::string statusText_ {"Off"}; + + std::unique_ptr ipc_; + std::vector requestBuffer_; + std::vector responseBuffer_; + std::thread thread_; +}; + +} // namespace rvc diff --git a/RVCRealtimeVST/third_party/iPlug2 b/RVCRealtimeVST/third_party/iPlug2 new file mode 160000 index 0000000..5c2df9d --- /dev/null +++ b/RVCRealtimeVST/third_party/iPlug2 @@ -0,0 +1 @@ +Subproject commit 5c2df9dce3f5258acfeff3846a6a9563f382212c diff --git a/RVCRealtimeVST/third_party/licenses/Roboto-Apache-2.0.txt b/RVCRealtimeVST/third_party/licenses/Roboto-Apache-2.0.txt new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/RVCRealtimeVST/third_party/licenses/Roboto-Apache-2.0.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/RVCRealtimeVST/third_party/vst2-compat/aeffectx.h b/RVCRealtimeVST/third_party/vst2-compat/aeffectx.h new file mode 100644 index 0000000..b7f5cf0 --- /dev/null +++ b/RVCRealtimeVST/third_party/vst2-compat/aeffectx.h @@ -0,0 +1,336 @@ +// Permissive compatibility names for iPlug2's VST2 adapter. +// The ABI values and layout are backed by the BSD-3-Clause clean-room SDK in +// third_party/vst2sdk. This file contains only the subset used by iPlug2. + +#pragma once + +#include "vst.h" + +#include +#include + +using VstInt16 = std::int16_t; +using VstInt32 = std::int32_t; +using VstInt64 = std::int64_t; +using VstIntPtr = std::intptr_t; + +#define VSTCALLBACK VST_FUNCTION_INTERFACE + +struct AEffect; + +using audioMasterCallback = VstIntPtr (VSTCALLBACK*)(AEffect*, VstInt32, VstInt32, VstIntPtr, void*, float); +using AEffectDispatcherProc = VstIntPtr (VSTCALLBACK*)(AEffect*, VstInt32, VstInt32, VstIntPtr, void*, float); +using AEffectProcessProc = void (VSTCALLBACK*)(AEffect*, float**, float**, VstInt32); +using AEffectProcessDoubleProc = void (VSTCALLBACK*)(AEffect*, double**, double**, VstInt32); +using AEffectSetParameterProc = void (VSTCALLBACK*)(AEffect*, VstInt32, float); +using AEffectGetParameterProc = float (VSTCALLBACK*)(AEffect*, VstInt32); + +#pragma pack(push, 8) + +struct ERect { + VstInt16 top; + VstInt16 left; + VstInt16 bottom; + VstInt16 right; +}; + +struct AEffect { + VstInt32 magic; + AEffectDispatcherProc dispatcher; + AEffectProcessProc __processDeprecated; + AEffectSetParameterProc setParameter; + AEffectGetParameterProc getParameter; + VstInt32 numPrograms; + VstInt32 numParams; + VstInt32 numInputs; + VstInt32 numOutputs; + VstInt32 flags; + VstIntPtr resvd1; + VstIntPtr resvd2; + VstInt32 initialDelay; + VstInt32 __realQualitiesDeprecated; + VstInt32 __offQualitiesDeprecated; + float __ioRatioDeprecated; + void* object; + void* user; + VstInt32 uniqueID; + VstInt32 version; + AEffectProcessProc processReplacing; + AEffectProcessDoubleProc processDoubleReplacing; + char future[56]; +}; + +struct VstEvent { + VstInt32 type; + VstInt32 byteSize; + VstInt32 deltaFrames; + VstInt32 flags; + char data[16]; +}; + +struct VstEvents { + VstInt32 numEvents; + VstIntPtr reserved; + VstEvent* events[2]; +}; + +struct VstMidiEvent { + VstInt32 type; + VstInt32 byteSize; + VstInt32 deltaFrames; + VstInt32 flags; + VstInt32 noteLength; + VstInt32 noteOffset; + char midiData[4]; + char detune; + char noteOffVelocity; + char reserved1; + char reserved2; +}; + +struct VstMidiSysexEvent { + VstInt32 type; + VstInt32 byteSize; + VstInt32 deltaFrames; + VstInt32 flags; + VstInt32 dumpBytes; + VstIntPtr resvd1; + char* sysexDump; + VstIntPtr resvd2; +}; + +struct VstTimeInfo { + double samplePos; + double sampleRate; + double nanoSeconds; + double ppqPos; + double tempo; + double barStartPos; + double cycleStartPos; + double cycleEndPos; + VstInt32 timeSigNumerator; + VstInt32 timeSigDenominator; + VstInt32 smpteOffset; + VstInt32 smpteFrameRate; + VstInt32 samplesToNextClock; + VstInt32 flags; +}; + +struct VstVariableIo { + float** inputs; + float** outputs; + VstInt32 numSamplesInput; + VstInt32 numSamplesOutput; + VstInt32* numSamplesInputProcessed; + VstInt32* numSamplesOutputProcessed; +}; + +inline constexpr int kVstMaxLabelLen = 64; +inline constexpr int kVstMaxShortLabelLen = 8; +inline constexpr int kVstMaxCategLabelLen = 24; +inline constexpr int kVstMaxNameLen = 64; + +struct VstParameterProperties { + float stepFloat; + float smallStepFloat; + float largeStepFloat; + char label[kVstMaxLabelLen]; + VstInt32 flags; + VstInt32 minInteger; + VstInt32 maxInteger; + VstInt32 stepInteger; + VstInt32 largeStepInteger; + char shortLabel[kVstMaxShortLabelLen]; + VstInt16 displayIndex; + VstInt16 category; + VstInt16 numParametersInCategory; + VstInt16 reserved; + char categoryLabel[kVstMaxCategLabelLen]; + char future[16]; +}; + +struct VstPinProperties { + char label[kVstMaxLabelLen]; + VstInt32 flags; + VstInt32 arrangementType; + char shortLabel[kVstMaxShortLabelLen]; + char future[48]; +}; + +struct MidiKeyName { + VstInt32 thisProgramIndex; + VstInt32 thisKeyNumber; + char keyName[kVstMaxNameLen]; + VstInt32 reserved; + VstInt32 flags; +}; + +struct VstSpeakerProperties { + float azimuth; + float elevation; + float radius; + float reserved; + char name[kVstMaxNameLen]; + VstInt32 type; + char future[28]; +}; + +struct VstSpeakerArrangement { + VstInt32 type; + VstInt32 numChannels; + VstSpeakerProperties speakers[8]; +}; + +#pragma pack(pop) + +static_assert(sizeof(AEffect) == sizeof(vst_effect_t), "VST2 effect ABI size mismatch"); +static_assert(offsetof(AEffect, dispatcher) == offsetof(vst_effect_t, control), "VST2 dispatcher ABI mismatch"); +static_assert(offsetof(AEffect, object) == offsetof(vst_effect_t, effect_internal), "VST2 object ABI mismatch"); +static_assert(offsetof(AEffect, processReplacing) == offsetof(vst_effect_t, process_float), "VST2 float process ABI mismatch"); +static_assert(offsetof(ERect, right) == offsetof(vst_rect_t, right), "VST2 editor rectangle ABI mismatch"); + +#define CCONST(a, b, c, d) VST_FOURCC(a, b, c, d) +#define kEffectMagic static_cast(VST_MAGICNUMBER) +inline constexpr VstInt32 kVstVersion = VST_VERSION_2_4_0_0; + +enum VstAEffectFlags : VstInt32 { + effFlagsHasEditor = VST_EFFECT_FLAG_EDITOR, + __effFlagsCanMonoDeprecated = 1 << 3, + effFlagsCanReplacing = VST_EFFECT_FLAG_SUPPORTS_FLOAT, + effFlagsProgramChunks = VST_EFFECT_FLAG_CHUNKS, + effFlagsIsSynth = VST_EFFECT_FLAG_INSTRUMENT, + effFlagsCanDoubleReplacing = VST_EFFECT_FLAG_SUPPORTS_DOUBLE +}; + +enum AEffectOpcodes : VstInt32 { + effOpen = 0, + effClose = 1, + effSetProgram = 2, + effGetProgram = 3, + effSetProgramName = 4, + effGetProgramName = 5, + effGetParamLabel = 6, + effGetParamDisplay = 7, + effGetParamName = 8, + effSetSampleRate = 10, + effSetBlockSize = 11, + effMainsChanged = 12, + effEditGetRect = 13, + effEditOpen = 14, + effEditClose = 15, + effEditIdle = 19, + __effIdentifyDeprecated = 22, + effGetChunk = 23, + effSetChunk = 24, + effProcessEvents = 25, + effCanBeAutomated = 26, + effString2Parameter = 27, + effGetProgramNameIndexed = 29, + effGetInputProperties = 33, + effGetOutputProperties = 34, + effGetPlugCategory = 35, + effProcessVarIo = 41, + effSetSpeakerArrangement = 42, + effSetBypass = 44, + effGetEffectName = 45, + effGetVendorString = 47, + effGetProductString = 48, + effGetVendorVersion = 49, + effVendorSpecific = 50, + effCanDo = 51, + effGetTailSize = 52, + __effIdleDeprecated = 53, + effGetParameterProperties = 56, + effGetVstVersion = 58, + effEditKeyDown = 59, + effEditKeyUp = 60, + effGetMidiProgramName = 62, + effGetCurrentMidiProgram = 63, + effGetMidiProgramCategory = 64, + effHasMidiProgramsChanged = 65, + effGetMidiKeyName = 66, + effBeginSetProgram = 67, + effEndSetProgram = 68, + effGetSpeakerArrangement = 69 +}; + +enum AudioMasterOpcodes : VstInt32 { + audioMasterAutomate = 0, + audioMasterVersion = 1, + audioMasterCurrentId = 2, + audioMasterIdle = 3, + __audioMasterWantMidiDeprecated = 6, + audioMasterGetTime = 7, + audioMasterProcessEvents = 8, + audioMasterIOChanged = 13, + audioMasterSizeWindow = 15, + audioMasterGetCurrentProcessLevel = 23, + audioMasterGetProductString = 33, + audioMasterGetVendorVersion = 34, + audioMasterUpdateDisplay = 42, + audioMasterBeginEdit = 43, + audioMasterEndEdit = 44 +}; + +enum VstPlugCategory : VstInt32 { + kPlugCategUnknown = 0, + kPlugCategEffect = VST_EFFECT_CATEGORY_EFFECT, + kPlugCategSynth = VST_EFFECT_CATEGORY_INSTRUMENT +}; + +enum VstSpeakerArrangementType : VstInt32 { + kSpeakerArrUserDefined = VST_SPEAKER_ARRANGEMENT_TYPE_CUSTOM, + kSpeakerArrEmpty = VST_SPEAKER_ARRANGEMENT_TYPE_UNKNOWN, + kSpeakerArrMono = VST_SPEAKER_ARRANGEMENT_TYPE_MONO, + kSpeakerArrStereo = VST_SPEAKER_ARRANGEMENT_TYPE_STEREO +}; + +enum VstEventTypes : VstInt32 { + kVstMidiType = VST_EVENT_TYPE_MIDI, + kVstSysExType = VST_EVENT_TYPE_MIDI_SYSEX +}; + +enum VstParameterFlags : VstInt32 { + kVstParameterIsSwitch = VST_PARAMETER_FLAG_SWITCH, + kVstParameterUsesIntegerMinMax = VST_PARAMETER_FLAG_INTEGER_LIMITS, + kVstParameterUsesFloatStep = VST_PARAMETER_FLAG_STEP_FLOAT, + kVstParameterUsesIntStep = VST_PARAMETER_FLAG_STEP_INT +}; + +enum VstPinPropertiesFlags : VstInt32 { + kVstPinIsActive = 1 << 0, + kVstPinIsStereo = VST_STREAM_FLAG_STEREO +}; + +enum VstProcessLevels : VstInt32 { + kVstProcessLevelOffline = 4 +}; + +enum VstTimeInfoFlags : VstInt32 { + kVstTransportPlaying = 1 << 1, + kVstTransportCycleActive = 1 << 2, + kVstPpqPosValid = 1 << 9, + kVstTempoValid = 1 << 10, + kVstBarsValid = 1 << 11, + kVstCyclePosValid = 1 << 12, + kVstTimeSigValid = 1 << 13 +}; + +enum VstVirtualKey : VstInt32 { + VKEY_BACK = 1, VKEY_TAB, VKEY_CLEAR, VKEY_RETURN, VKEY_PAUSE, VKEY_ESCAPE, + VKEY_SPACE, VKEY_NEXT, VKEY_END, VKEY_HOME, VKEY_LEFT, VKEY_UP, VKEY_RIGHT, + VKEY_DOWN, VKEY_PAGEUP, VKEY_PAGEDOWN, VKEY_SELECT, VKEY_PRINT, VKEY_ENTER, + VKEY_SNAPSHOT, VKEY_INSERT, VKEY_DELETE, VKEY_HELP, VKEY_NUMPAD0, VKEY_NUMPAD1, + VKEY_NUMPAD2, VKEY_NUMPAD3, VKEY_NUMPAD4, VKEY_NUMPAD5, VKEY_NUMPAD6, VKEY_NUMPAD7, + VKEY_NUMPAD8, VKEY_NUMPAD9, VKEY_MULTIPLY, VKEY_ADD, VKEY_SEPARATOR, VKEY_SUBTRACT, + VKEY_DECIMAL, VKEY_DIVIDE, VKEY_F1, VKEY_F2, VKEY_F3, VKEY_F4, VKEY_F5, VKEY_F6, + VKEY_F7, VKEY_F8, VKEY_F9, VKEY_F10, VKEY_F11, VKEY_F12, VKEY_NUMLOCK, VKEY_SCROLL, + VKEY_SHIFT, VKEY_CONTROL, VKEY_ALT, VKEY_EQUALS +}; + +enum VstModifierKey : VstInt32 { + MODIFIER_SHIFT = VST_VKEY_MODIFIER_SHIFT, + MODIFIER_ALTERNATE = VST_VKEY_MODIFIER_ALT, + MODIFIER_COMMAND = VST_VKEY_MODIFIER_SYSTEM, + MODIFIER_CONTROL = VST_VKEY_MODIFIER_CONTROL +}; diff --git a/RVCRealtimeVST/third_party/vst2sdk b/RVCRealtimeVST/third_party/vst2sdk new file mode 160000 index 0000000..339d4f3 --- /dev/null +++ b/RVCRealtimeVST/third_party/vst2sdk @@ -0,0 +1 @@ +Subproject commit 339d4f31590bf77c0d0d248e09a380ac6285e069 diff --git a/RVCRealtimeVST/third_party/vst3sdk b/RVCRealtimeVST/third_party/vst3sdk new file mode 160000 index 0000000..58f8da7 --- /dev/null +++ b/RVCRealtimeVST/third_party/vst3sdk @@ -0,0 +1 @@ +Subproject commit 58f8da7936800732561402d7936584ca4505de07 diff --git a/RVCRealtimeVST/tools/vst2_smoke.cpp b/RVCRealtimeVST/tools/vst2_smoke.cpp new file mode 100644 index 0000000..a3b1942 --- /dev/null +++ b/RVCRealtimeVST/tools/vst2_smoke.cpp @@ -0,0 +1,107 @@ +#include + +#include "aeffectx.h" + +#include +#include +#include +#include +#include +#include + +namespace { + +VstIntPtr VSTCALLBACK hostCallback(AEffect*, VstInt32 opcode, VstInt32, VstIntPtr, void*, float) +{ + if (opcode == audioMasterVersion) + return kVstVersion; + return 0; +} + +std::wstring widen(const char* text) +{ + if (text == nullptr) + return {}; + const int size = MultiByteToWideChar(CP_UTF8, 0, text, -1, nullptr, 0); + std::wstring result(static_cast(size), L'\0'); + MultiByteToWideChar(CP_UTF8, 0, text, -1, result.data(), size); + if (!result.empty()) + result.pop_back(); + return result; +} + +} // namespace + +int main(int argc, char** argv) +{ + if (argc != 2) { + std::cerr << "Usage: rvc-vst2-smoke \n"; + return EXIT_FAILURE; + } + + const std::wstring path = widen(argv[1]); + HMODULE module = LoadLibraryW(path.c_str()); + if (module == nullptr) { + std::cerr << "LoadLibraryW failed: " << GetLastError() << '\n'; + return EXIT_FAILURE; + } + + using PluginMain = AEffect* (VSTCALLBACK*)(audioMasterCallback); + const auto pluginMain = reinterpret_cast(GetProcAddress(module, "VSTPluginMain")); + if (pluginMain == nullptr) { + std::cerr << "VSTPluginMain export is missing\n"; + FreeLibrary(module); + return EXIT_FAILURE; + } + + AEffect* effect = pluginMain(hostCallback); + if (effect == nullptr || effect->magic != kEffectMagic || effect->dispatcher == nullptr + || effect->processReplacing == nullptr) { + std::cerr << "Invalid VST2 effect structure\n"; + FreeLibrary(module); + return EXIT_FAILURE; + } + + effect->dispatcher(effect, effOpen, 0, 0, nullptr, 0.0f); + effect->dispatcher(effect, effSetSampleRate, 0, 0, nullptr, 48000.0f); + constexpr VstInt32 frames = 512; + effect->dispatcher(effect, effSetBlockSize, 0, frames, nullptr, 0.0f); + effect->dispatcher(effect, effMainsChanged, 0, 1, nullptr, 0.0f); + + std::vector left(frames); + std::vector right(frames); + std::vector outputLeft(frames, -10.0f); + std::vector outputRight(frames, -10.0f); + for (VstInt32 i = 0; i < frames; ++i) { + left[static_cast(i)] = static_cast(0.1 * std::sin(i * 0.03)); + right[static_cast(i)] = static_cast(0.1 * std::cos(i * 0.03)); + } + float* inputs[] = {left.data(), right.data()}; + float* outputs[] = {outputLeft.data(), outputRight.data()}; + effect->processReplacing(effect, inputs, outputs, frames); + + float maxError = 0.0f; + for (VstInt32 i = 0; i < frames; ++i) { + const auto index = static_cast(i); + maxError = std::max(maxError, std::abs(outputLeft[index] - left[index])); + maxError = std::max(maxError, std::abs(outputRight[index] - right[index])); + } + + const VstInt32 inputsCount = effect->numInputs; + const VstInt32 outputsCount = effect->numOutputs; + const VstInt32 paramsCount = effect->numParams; + effect->dispatcher(effect, effMainsChanged, 0, 0, nullptr, 0.0f); + effect->dispatcher(effect, effClose, 0, 0, nullptr, 0.0f); + FreeLibrary(module); + + if (inputsCount != 2 || outputsCount != 2 || paramsCount != 12 || maxError > 1e-6f) { + std::cerr << "Unexpected VST2 behavior: inputs=" << inputsCount + << " outputs=" << outputsCount << " params=" << paramsCount + << " max_error=" << maxError << '\n'; + return EXIT_FAILURE; + } + + std::cout << "VST2 OK inputs=" << inputsCount << " outputs=" << outputsCount + << " params=" << paramsCount << " dry_max_error=" << maxError << '\n'; + return EXIT_SUCCESS; +} diff --git a/RVCRealtimeVST/tools/worker_smoke.cpp b/RVCRealtimeVST/tools/worker_smoke.cpp new file mode 100644 index 0000000..581d6da --- /dev/null +++ b/RVCRealtimeVST/tools/worker_smoke.cpp @@ -0,0 +1,94 @@ +#include "WorkerClient.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +bool waitForReady(rvc::WorkerClient& worker, const std::chrono::seconds timeout) +{ + const auto deadline = std::chrono::steady_clock::now() + timeout; + while (std::chrono::steady_clock::now() < deadline) { + if (worker.isReady()) + return true; + if (worker.status() == rvc::kStatusError) + return false; + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + return false; +} + +} // namespace + +int main(const int argc, char** argv) +{ + constexpr double sampleRate = 48000.0; + constexpr double frequency = 220.0; + constexpr double pi = 3.14159265358979323846; + + rvc::WorkerClient worker; + if (argc < 4) { + std::cerr << "Usage: rvc-worker-smoke RVC_ROOT PYTHON_EXE MODEL_PTH [INDEX] [BLOCK_MS] [CROSSFADE_MS] [BLOCK_COUNT]\n"; + return EXIT_FAILURE; + } + worker.setPath(rvc::kStateRvcRoot, argv[1]); + worker.setPath(rvc::kStatePythonPath, argv[2]); + worker.setPath(rvc::kStateModelPath, argv[3]); + worker.setPath(rvc::kStateIndexPath, argc > 4 ? argv[4] : ""); + if (argc > 5) + worker.setParameter(rvc::kParamBlockMs, std::stof(argv[5])); + if (argc > 6) + worker.setParameter(rvc::kParamCrossfadeMs, std::stof(argv[6])); + const std::size_t blockCount = argc > 7 ? std::max(1, std::stoul(argv[7])) : 1; + worker.setSampleRate(sampleRate); + worker.setEnabled(true); + + if (!waitForReady(worker, std::chrono::seconds(180))) { + std::cerr << "Worker did not become ready: " << worker.statusText() << '\n'; + return EXIT_FAILURE; + } + + const std::size_t frames = worker.blockFrames(); + std::vector input(frames * blockCount); + for (std::size_t i = 0; i < input.size(); ++i) + input[i] = static_cast(0.1 * std::sin(2.0 * pi * frequency * static_cast(i) / sampleRate)); + + if (worker.pushInput(input.data(), input.size()) != input.size()) { + std::cerr << "Input ring rejected the test block\n"; + return EXIT_FAILURE; + } + const std::size_t expected = worker.latencyFrames() + input.size(); + std::vector output(expected); + std::size_t received = 0; + const auto outputDeadline = std::chrono::steady_clock::now() + std::chrono::seconds(120); + while (received < expected && std::chrono::steady_clock::now() < outputDeadline) { + received += worker.popOutput(output.data() + received, expected - received); + if (worker.status() == rvc::kStatusError) + break; + if (received < expected) + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + if (received != expected) { + std::cerr << "Output ring returned " << received << " of " << expected << " frames\n"; + return EXIT_FAILURE; + } + + double sumSquares = 0.0; + for (std::size_t i = expected - frames; i < expected; ++i) + sumSquares += static_cast(output[i]) * output[i]; + const double rms = std::sqrt(sumSquares / static_cast(frames)); + + std::cout << "READY frames=" << frames + << " latency=" << worker.latencyFrames() + << " infer_ms=" << worker.inferMs() + << " output_rms=" << rms + << " drops=" << worker.droppedBlocks() + << " blocks=" << blockCount + << " status=\"" << worker.statusText() << "\"\n"; + return rms > 0.0 ? EXIT_SUCCESS : EXIT_FAILURE; +} diff --git a/RVCRealtimeVST/worker/rvc_worker.py b/RVCRealtimeVST/worker/rvc_worker.py new file mode 100644 index 0000000..122c953 --- /dev/null +++ b/RVCRealtimeVST/worker/rvc_worker.py @@ -0,0 +1,345 @@ +"""RVC inference worker for the RVC Realtime VST2/VST3 plugin.""" + +from __future__ import annotations + +import argparse +import ctypes +import json +import mmap +import os +import struct +import sys +import time +import traceback +from pathlib import Path + +MAGIC = 0x50564352 +PROTOCOL_VERSION = 1 +HEADER_BYTES = 4096 +MAX_FRAMES = 131072 +MAP_BYTES = HEADER_BYTES + MAX_FRAMES * 4 * 2 +INPUT_OFFSET = HEADER_BYTES +OUTPUT_OFFSET = HEADER_BYTES + MAX_FRAMES * 4 +STATUS_TEXT_OFFSET = 128 +STATUS_TEXT_BYTES = 512 + +STATUS_STARTING = 1 +STATUS_LOADING = 2 +STATUS_READY = 3 +STATUS_ERROR = -1 +STATUS_STOP = -2 + +SYNCHRONIZE = 0x00100000 +EVENT_MODIFY_STATE = 0x0002 +WAIT_OBJECT_0 = 0 +WAIT_TIMEOUT = 258 + + +def write_value(shared: mmap.mmap, offset: int, fmt: str, value) -> None: + struct.pack_into("<" + fmt, shared, offset, value) + + +def read_value(shared: mmap.mmap, offset: int, fmt: str): + return struct.unpack_from("<" + fmt, shared, offset)[0] + + +def write_status(shared: mmap.mmap, state: int, message: str) -> None: + write_value(shared, 8, "i", state) + encoded = message.encode("utf-8", errors="replace")[: STATUS_TEXT_BYTES - 1] + shared[STATUS_TEXT_OFFSET : STATUS_TEXT_OFFSET + STATUS_TEXT_BYTES] = b"\0" * STATUS_TEXT_BYTES + shared[STATUS_TEXT_OFFSET : STATUS_TEXT_OFFSET + len(encoded)] = encoded + shared.flush() + + +class WinEvent: + def __init__(self, name: str): + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.OpenEventW.argtypes = [ctypes.c_uint32, ctypes.c_int, ctypes.c_wchar_p] + kernel32.OpenEventW.restype = ctypes.c_void_p + self._wait = kernel32.WaitForSingleObject + self._wait.argtypes = [ctypes.c_void_p, ctypes.c_uint32] + self._wait.restype = ctypes.c_uint32 + self._set = kernel32.SetEvent + self._set.argtypes = [ctypes.c_void_p] + self._set.restype = ctypes.c_int + self._close = kernel32.CloseHandle + self._close.argtypes = [ctypes.c_void_p] + self._close.restype = ctypes.c_int + self.handle = kernel32.OpenEventW(SYNCHRONIZE | EVENT_MODIFY_STATE, False, name) + if not self.handle: + raise OSError(ctypes.get_last_error(), f"OpenEventW failed: {name}") + + def wait(self, timeout_ms: int) -> int: + return int(self._wait(self.handle, timeout_ms)) + + def set(self) -> None: + if not self._set(self.handle): + raise OSError(ctypes.get_last_error(), "SetEvent failed") + + def close(self) -> None: + if self.handle: + self._close(self.handle) + self.handle = None + + +class RVCStreamEngine: + def __init__(self, cfg: dict): + self.root = Path(cfg["rvc_root"]).resolve() + os.chdir(self.root) + sys.path.insert(0, str(self.root)) + os.environ.setdefault("OPENBLAS_NUM_THREADS", "1") + os.environ.setdefault("OMP_NUM_THREADS", "4") + + import librosa + import numpy as np + import torch + import torch.nn.functional as F + import torchaudio.transforms as tat + + from configs.config import Config + from infer import rtrvc + from tools.cuda_graph import run_cuda_graph + + self.librosa = librosa + self.np = np + self.torch = torch + self.F = F + self.tat = tat + self.run_cuda_graph = run_cuda_graph + + self.sample_rate = int(cfg["sample_rate"]) + self.block_ms = float(cfg["block_ms"]) + self.crossfade_ms = float(cfg["crossfade_ms"]) + self.extra_ms = float(cfg["extra_ms"]) + self.zc = max(1, self.sample_rate // 100) + self.block_frame = int(round(self.block_ms / 1000 * self.sample_rate / self.zc) * self.zc) + self.block_frame_16k = 160 * self.block_frame // self.zc + self.crossfade_frame = int(round(self.crossfade_ms / 1000 * self.sample_rate / self.zc) * self.zc) + # Match the source realtime GUI: SOLA overlap is capped at 40 ms and + # remains independent of the audio callback block length. + self.sola_buffer_frame = min(self.crossfade_frame, 4 * self.zc) + self.effective_crossfade_ms = 1000.0 * self.sola_buffer_frame / self.sample_rate + self.sola_search_frame = self.zc + self.extra_frame = int(round(self.extra_ms / 1000 * self.sample_rate / self.zc) * self.zc) + + self.config = Config() + self.rvc = rtrvc.RVC( + 0.0, + 0.0, + str(Path(cfg["model_path"]).resolve()), + str(Path(cfg["index_path"]).resolve()) if cfg.get("index_path") else "", + 0.0, + self.config, + None, + ) + + total_frames = self.extra_frame + self.crossfade_frame + self.sola_search_frame + self.block_frame + self.input_wav = torch.zeros(total_frames, device=self.config.device, dtype=torch.float32) + self.input_wav_res = torch.zeros(160 * total_frames // self.zc, device=self.config.device, dtype=torch.float32) + self.rms_buffer = np.zeros(4 * self.zc, dtype="float32") + self.sola_buffer = torch.zeros(self.sola_buffer_frame, device=self.config.device, dtype=torch.float32) + self.sola_den_kernel = torch.ones(1, 1, self.sola_buffer_frame, device=self.config.device, dtype=torch.float32) + self.skip_head = self.extra_frame // self.zc + self.return_length = (self.block_frame + self.sola_buffer_frame + self.sola_search_frame) // self.zc + self.fade_in_window = torch.sin( + 0.5 * np.pi * torch.linspace(0.0, 1.0, steps=self.sola_buffer_frame, device=self.config.device) + ) ** 2 + self.fade_out_window = 1 - self.fade_in_window + self.resampler = tat.Resample(orig_freq=self.sample_rate, new_freq=16000, dtype=torch.float32).to(self.config.device) + self.resampler2 = None + if self.rvc.tgt_sr != self.sample_rate: + self.resampler2 = tat.Resample(orig_freq=self.rvc.tgt_sr, new_freq=self.sample_rate, dtype=torch.float32).to(self.config.device) + self.last_pitch = None + self.last_formant = None + self.last_index_rate = None + + def prewarm(self) -> None: + phase = self.torch.arange(self.block_frame, device=self.config.device, dtype=self.torch.float32) + probe = 0.05 * self.torch.sin(2 * self.np.pi * 220.0 * phase / self.sample_rate) + self.process(probe.cpu().numpy(), 12.0, 0.0, 0.0, 0.5, -60.0, 0) + if self.torch.device(self.config.device).type == "cuda": + self.torch.cuda.synchronize(self.config.device) + + self.input_wav.zero_() + self.input_wav_res.zero_() + self.rms_buffer.fill(0.0) + self.sola_buffer.zero_() + if hasattr(self.rvc, "cache_pitch"): + self.rvc.cache_pitch.zero_() + if hasattr(self.rvc, "cache_pitchf"): + self.rvc.cache_pitchf.zero_() + + def process(self, audio, pitch: float, formant: float, index_rate: float, + rms_mix: float, threshold: float, f0_method: int): + np = self.np + torch = self.torch + F = self.F + if len(audio) != self.block_frame: + raise ValueError(f"block mismatch: worker={self.block_frame}, request={len(audio)}") + + if self.last_pitch != pitch: + self.rvc.change_key(pitch) + self.last_pitch = pitch + if self.last_formant != formant: + self.rvc.change_formant(formant) + self.last_formant = formant + if self.last_index_rate != index_rate: + self.rvc.change_index_rate(index_rate) + self.last_index_rate = index_rate + + indata = np.asarray(audio, dtype=np.float32).copy() + if threshold > -60.0: + gated = np.append(self.rms_buffer, indata) + rms = self.librosa.feature.rms(y=gated, frame_length=4 * self.zc, hop_length=self.zc)[:, 2:] + self.rms_buffer[:] = gated[-4 * self.zc :] + gated = gated[2 * self.zc - self.zc // 2 :] + below = self.librosa.amplitude_to_db(rms, ref=1.0)[0] < threshold + for i, mute in enumerate(below): + if mute: + gated[i * self.zc : (i + 1) * self.zc] = 0 + indata = gated[self.zc // 2 :] + + self.input_wav[:-self.block_frame] = self.input_wav[self.block_frame:].clone() + self.input_wav[-self.block_frame:] = torch.from_numpy(indata).to(self.config.device) + self.input_wav_res[:-self.block_frame_16k] = self.input_wav_res[self.block_frame_16k:].clone() + resample_input = self.input_wav[-self.block_frame - 2 * self.zc :] + resampled = self.run_cuda_graph( + self.resampler, + "vst-input-resample", + lambda value: self.resampler(value), + resample_input, + )[160:] + self.input_wav_res[-self.block_frame_16k:] = resampled[-self.block_frame_16k:] + + method = ("rmvpe", "fcpe", "pm")[max(0, min(2, int(f0_method)))] + infer_wav = self.rvc.infer( + self.input_wav_res, + self.block_frame_16k, + self.skip_head, + self.return_length, + method, + ) + if self.resampler2 is not None: + infer_wav = self.run_cuda_graph( + self.resampler2, + "vst-output-resample", + lambda value: self.resampler2(value), + infer_wav, + ) + + if rms_mix < 1.0: + input_tail = self.input_wav[self.extra_frame :] + rms1 = self.librosa.feature.rms( + y=input_tail[: infer_wav.shape[0]].cpu().numpy(), frame_length=4 * self.zc, hop_length=self.zc + ) + rms1 = torch.from_numpy(rms1).to(self.config.device) + rms1 = F.interpolate(rms1.unsqueeze(0), size=infer_wav.shape[0] + 1, mode="linear", align_corners=True)[0, 0, :-1] + rms2 = self.librosa.feature.rms( + y=infer_wav.cpu().numpy(), frame_length=4 * self.zc, hop_length=self.zc + ) + rms2 = torch.from_numpy(rms2).to(self.config.device) + rms2 = F.interpolate(rms2.unsqueeze(0), size=infer_wav.shape[0] + 1, mode="linear", align_corners=True)[0, 0, :-1] + rms2 = torch.maximum(rms2, torch.full_like(rms2, 1e-3)) + infer_wav *= torch.pow(rms1 / rms2, 1.0 - rms_mix) + + conv_input = infer_wav[None, None, : self.sola_buffer_frame + self.sola_search_frame] + cor_nom = F.conv1d(conv_input, self.sola_buffer[None, None, :]) + cor_den = torch.sqrt(F.conv1d(conv_input**2, self.sola_den_kernel) + 1e-8) + sola_offset = int(torch.argmax(cor_nom[0, 0] / cor_den[0, 0])) + infer_wav = infer_wav[sola_offset:] + infer_wav[: self.sola_buffer_frame] *= self.fade_in_window + infer_wav[: self.sola_buffer_frame] += self.sola_buffer * self.fade_out_window + self.sola_buffer[:] = infer_wav[self.block_frame : self.block_frame + self.sola_buffer_frame] + return infer_wav[: self.block_frame].float().cpu().numpy() + + +def run(args: argparse.Namespace) -> int: + shared = None + request_event = None + response_event = None + try: + shared = mmap.mmap(-1, MAP_BYTES, tagname=args.map, access=mmap.ACCESS_WRITE) + request_event = WinEvent(args.request) + response_event = WinEvent(args.response) + if read_value(shared, 0, "I") != MAGIC or read_value(shared, 4, "I") != PROTOCOL_VERSION: + raise RuntimeError("RVC VST protocol mismatch") + write_status(shared, STATUS_LOADING, "Loading Python and CUDA") + with open(args.config, "r", encoding="utf-8") as handle: + cfg = json.load(handle) + # RVC's Config parses process-wide CLI flags intended for its WebUI. + sys.argv = [sys.argv[0]] + engine = RVCStreamEngine(cfg) + write_status(shared, STATUS_LOADING, "Prewarming CUDA and F0") + engine.prewarm() + write_status(shared, STATUS_READY, f"Ready (actual CF {engine.effective_crossfade_ms:.0f} ms)") + + import numpy as np + + input_view = np.ndarray((MAX_FRAMES,), dtype=np.float32, buffer=shared, offset=INPUT_OFFSET) + output_view = np.ndarray((MAX_FRAMES,), dtype=np.float32, buffer=shared, offset=OUTPUT_OFFSET) + last_sequence = 0 + while True: + result = request_event.wait(1000) + if result == WAIT_TIMEOUT: + continue + if result != WAIT_OBJECT_0: + raise OSError("WaitForSingleObject failed") + if read_value(shared, 8, "i") == STATUS_STOP: + break + sequence = read_value(shared, 12, "I") + if sequence == last_sequence: + continue + frames = read_value(shared, 20, "I") + if frames <= 0 or frames > MAX_FRAMES: + raise ValueError(f"invalid frame count: {frames}") + started = time.perf_counter() + processed = engine.process( + input_view[:frames], + read_value(shared, 32, "f"), + read_value(shared, 36, "f"), + read_value(shared, 40, "f"), + read_value(shared, 44, "f"), + read_value(shared, 48, "f"), + read_value(shared, 64, "I"), + ) + output_view[:frames] = processed + write_value(shared, 56, "f", (time.perf_counter() - started) * 1000.0) + write_value(shared, 16, "I", sequence) + write_value(shared, 8, "i", STATUS_READY) + last_sequence = sequence + response_event.set() + return 0 + except BaseException: + message = traceback.format_exc() + try: + Path(args.config + ".log").write_text(message, encoding="utf-8") + except Exception: + pass + try: + if shared is not None: + write_status(shared, STATUS_ERROR, message[-500:]) + if response_event is not None: + response_event.set() + except Exception: + pass + return 1 + finally: + if request_event is not None: + request_event.close() + if response_event is not None: + response_event.close() + if shared is not None: + shared.close() + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--map", required=True) + parser.add_argument("--request", required=True) + parser.add_argument("--response", required=True) + parser.add_argument("--config", required=True) + return parser.parse_args() + + +if __name__ == "__main__": + raise SystemExit(run(parse_args()))