VST3プラグインUIのパラメータ同期とエディタViewの生成・破棄をログで確認

VST3プラグインUIのパラメータ同期とエディタViewの生成・破棄をログで確認

VST3 SDK のサンプル AGain にログ出力を追加し、UI 操作が DSP に届くまでの経路と、Processor から Controller に戻る経路を確認します。VST3PluginTestHost でエディタを開閉し、View がホスト主導で生成・破棄されることと、View がない状態でもパラメータ同期が継続しうることをログで観察します。
2025.12.20

はじめに

VST3 の UI 実装では、設計判断として整理しておきたい箇所がいくつかあります。

たとえば、UI のスライダーを動かしたとき、その値はどのタイミングで DSP に届くのか、どの API を起点としてホストへ伝わるのかを、実装目線で説明できる形にしておく必要があります。UI 側で DSP の状態に直接触れるべきか、ホストへ通知して処理スレッド側へ引き渡すべきかが曖昧なままだと、同期方法が増えたときに破綻しやすくなります。

ui dsp sync

また、エディタ View はホストが表示と非表示を切り替えるため、表示中だけ存在する前提で実装を組み立てる必要があります。表示を閉じた時点で View が破棄されるのか、破棄されたあともパラメータ同期が継続しうるのかを切り分けておくと、開閉を繰り返す環境でも初期化と解放を安定させやすくなります。

thread management

本記事では、 VST3 の UI 設計において特につまずきやすい下記の 2 点について、ログで検証していきます。

  • UI と DSP の値はどこで同期されるか
  • エディタ View の生成と破棄を主導するのは誰か

VST プラグインとは

VST (Virtual Studio Technology) は、DAW などのホストアプリケーションと、エフェクトやシンセサイザーなどのオーディオプラグインをつなぐための共通インターフェースです。

2025 年 10 月、Steinberg は VST 3.8 SDK を公開すると同時に、ライセンスを MIT ライセンスに変更 しました (参考)。以前は Steinberg 独自ライセンス (+ 場合によっては個別契約) が必要でしたが、現在は MIT ライセンスに従うことで、商用製品への組み込みも含めて自由に利用できる ようになりました。主な条件は、著作権表示とライセンス文を残すことです。詳しくはライセンス本文を確認してください。

対象読者

  • VST3 プラグインを開発しており、UI と DSP の同期経路を実装目線で把握したい方
  • エディタの開閉を繰り返す環境で、初期化と解放の仕様を確認したい方

参考

検証環境と準備

公式 SDK からサンプルとして提供されている AGain プラグインにログ出力を仕込んで観察するため、準備を行いました。

検証環境

  • OS: Windows 11 (64 bit)
  • Visual Studio 2022 (MSVC)
  • VST3 SDK 3.8 系
  • Steinberg VST3PluginTestHost
  • ASIO 対応オーディオインターフェース
    • sample rate : 44.1 kHz
    • buffer size : 1,024 samples

ASIO ドライバの選択については、環境によって挙動が大きく変わることがあります。今回の手元環境では、汎用的な Realtek ASIO ドライバではうまく動かなかったため、オーディオインターフェースの専用 ASIO ドライバで動作させました。

SDK の取得とビルド

まず、VST3 SDK を取得し、公式サンプルをビルドできる状態にします。

  1. GitHub から VST3 SDK を clone します。

    git clone --recursive https://github.com/steinbergmedia/vst3sdk.git
    cd vst3sdk
    
  2. ビルド用ディレクトリを作成し、CMake で Visual Studio 用プロジェクトを生成します。

    mkdir build
    cd build
    
    cmake -G "Visual Studio 17 2022" -A x64 .. -DSMTG_CREATE_PLUGIN_LINK=OFF
    
  3. 生成された vstsdk.sln を Visual Studio で開きます。
    open solution

  4. ソリューションのプロパティを開き、 Common Properties > Configure Startup ProjectsSingle startup project > again に設定します。
    configure startup projects
    Single startup project

  5. プロパティのウインドウを OK で閉じます。構成が Debug、プラットフォームが x64 であることを確認します。 Build > Build Solution でビルドファイルを作成します。
    configuration and platform
    build solution

ビルドが通ると、build/VST3/Debugagain.vst3 が生成されます。

built plugin

VST3PluginTestHost の準備

VST3PluginTestHost は、フル版 VST3 SDK の同梱物として提供されています。公式サイト からフル SDK の zip を取得します。解凍後、下記のパスにある VST3PluginTestHost_x64_Installer_x.xx.xx.zip を解凍します。

VST_SDK/
  vst3sdk/
    bin/
      Windows_x64/
        VST3PluginTestHost_x64_Installer_x.xx.xx.zip

解凍して得られる VST3PluginTestHost_x64.msi を実行すると、VST3PluginTestHost がインストールされます。

VST3PluginTestHost

Visual Studio からホストを起動してデバッグする

AGain の挙動を観察するため、Visual Studio から VST3PluginTestHost を起動し、デバッガをアタッチした状態で OutputDebugString のログを眺める構成にします。

  1. Visual Studio のソリューションエクスプローラーで Plugin-Examples > again プロジェクトを右クリックし、「プロパティ」を開きます。
    open again properties

  2. Configuration: Debug Platform: x64 になっていることを確認し、 「Configuration Properties > デバッグ」を選び、次のように設定します。

    • Command : C:\path\to\VST3PluginTestHost.exe (先の手順でインストールした VST3PluginTestHost.exe のパス)
    • Command Arguments : --pluginfolder "C:\path\to\build\VST3\Debug" (先にビルドした again.vst3 が格納されたフォルダー)
      again project properties
  3. プロパティ画面を OK で閉じ、構成を Debug x64 として Local Windows Debugger または F5 を押すと、VST3PluginTestHost がデバッガ付きで起動します。
    Local Windows Debugger button
    debugger view

この状態で AGain をロードし、再生を開始すると、AGain のコードから OutputDebugStringA で出力した内容が Visual Studio の Output ウィンドウに表示されるようになります。

output viewing

UI と DSP の値はどこで同期されるか

VST3 のパラメータ同期は、UI と DSP が直接やりとりするのではなく、ホストが中継します。

  • UI 側は Controller から beginEdit performEdit endEdit を呼び、ホストへ通知
  • DSP 側は process()inputParameterChanges を受け取り、処理に反映
  • DSP 側がメーター値などをホストへ返す場合は outputParameterChanges を使用
  • 返された値は、ホストから Controller の setParamNormalized として通知

共通のログ関数

上記の仕様をログで確かめるため、 again.cpp と againcontroller.cpp において、下記のようなログ出力用関数を導入しました。

ログ出力関数
#if defined(_WIN32) && defined(_DEBUG)
#include <windows.h>
#include <cstdarg>

static void DbgPrintf(const char* fmt, ...)
{
	char buf[1024];
	va_list args;
	va_start(args, fmt);
	vsnprintf(buf, sizeof(buf), fmt, args);
	va_end(args);
	OutputDebugStringA(buf);
}

static DWORD Tid()
{
	return GetCurrentThreadId();
}
#else
static void DbgPrintf(const char*, ...) {}
static unsigned long Tid() { return 0; }
#endif

UI から DSP への同期をログで確認する

まず、UI 操作が DSP へ届く経路を見ます。

againcontroller.cppbeginEdit performEdit endEdit にログを入れます。ここで確認したいのは「UI 操作時に performEdit が出る」ことです。

againcontroller.cpp より抜粋
tresult PLUGIN_API AGainController::beginEdit(ParamID tag)
{
	DbgPrintf("[AGain][C] beginEdit tag=%u tid=%lu\n",
		static_cast<unsigned>(tag), static_cast<unsigned long>(Tid()));
	return EditControllerEx1::beginEdit(tag);
}

tresult PLUGIN_API AGainController::performEdit(ParamID tag, ParamValue valueNormalized)
{
	DbgPrintf("[AGain][C] performEdit tag=%u value=%.9f tid=%lu\n",
		static_cast<unsigned>(tag),
		static_cast<double>(valueNormalized),
		static_cast<unsigned long>(Tid()));
	return EditControllerEx1::performEdit(tag, valueNormalized);
}

tresult PLUGIN_API AGainController::endEdit(ParamID tag)
{
	DbgPrintf("[AGain][C] endEdit tag=%u tid=%lu\n",
		static_cast<unsigned>(tag), static_cast<unsigned long>(Tid()));
	return EditControllerEx1::endEdit(tag);
}

次に again.cppprocess()inputParameterChanges を読む箇所へログを入れます。変更済みのファイルでは、Gain と Bypass について値が十分変わったときだけログを出す形になっています。このログが、UI の performEdit の後に出るようなら、UI 操作がホスト経由で DSP 側へ反映されていることを、実装とログの両面から説明できます。

again.cpp より抜粋
case kGainId:
{
	const float newGain = static_cast<float>(value);
	// Log only when it actually changes meaningfully.
	if (std::fabs(newGain - sLastLoggedGain) > 1e-7f)
	{
		sLastLoggedGain = newGain;
		DbgPrintf("[AGain][P] inputParameterChanges tag=%u points=%d lastOffset=%d value=%.9f tid=%lu\n",
			static_cast<unsigned>(pid),
			static_cast<int>(numPoints),
			static_cast<int>(sampleOffset),
			static_cast<double>(newGain),
			static_cast<unsigned long>(Tid()));
	}
	fGain = newGain;
	break;
}

case kBypassId:
{
	const bool newBypass = (value > 0.5f);
	if (newBypass != sLastLoggedBypass)
	{
		sLastLoggedBypass = newBypass;
		DbgPrintf("[AGain][P] inputParameterChanges tag=%u points=%d lastOffset=%d value=%d tid=%lu\n",
			static_cast<unsigned>(pid),
			static_cast<int>(numPoints),
			static_cast<int>(sampleOffset),
			newBypass ? 1 : 0,
			static_cast<unsigned long>(Tid()));
	}
	bBypass = newBypass;
	break;
}

DSP から Controller への同期をログで確認する

次に、DSP 側が値を返し、Controller が更新される経路です。AGain は VU メーター用のパラメータを返すため、そこにログを入れます。

again.cppoutputParameterChanges を変更します。

again.cpp より抜粋
#if defined(_WIN32) && defined(_DEBUG)
	// [Verification 2] Processor -> Host output queue logging
	DbgPrintf("[AGain][P] outputParameterChanges tag=%u offset=%d value=%.9f tid=%lu\n",
		static_cast<unsigned>(kVuPPMId),
		0,
		static_cast<double>(fVuPPM),
		static_cast<unsigned long>(Tid()));
#endif

againcontroller.cppsetParamNormalized にログを入れます。この 2 つが対になって観測できれば、DSP 側の出力がホスト経由で Controller に戻っていることを説明できます。

againcontroller.cpp より抜粋
tresult PLUGIN_API AGainController::setParamNormalized(ParamID tag, ParamValue value)
{
#if defined(_WIN32) && defined(_DEBUG)
	// [Verification 2] Host -> Controller update logging (VuMeter only, to avoid spam)
	if (tag == kVuPPMId)
	{
		static ParamValue sLastLogged = -1.0;
		if (std::fabs(value - sLastLogged) > 1e-12)
		{
			sLastLogged = value;
			DbgPrintf("[AGain][C] setParamNormalized tag=%u value=%.9f tid=%lu\n",
				static_cast<unsigned>(tag),
				static_cast<double>(value),
				static_cast<unsigned long>(Tid()));
		}
	}
#endif
	// called from host to update our parameters state
	return EditControllerEx1::setParamNormalized(tag, value);
}

ログ出力の結果と考察

まず、VST3PluginTestHost で AGain のエディタを開き、Gain のバーを操作しました。操作中に観測できたログの抜粋は次のとおりです。

[AGain][P] process first call tid=25828
[AGain][C] beginEdit tag=0 tid=17052
[AGain][C] performEdit tag=0 value=0.194444448 tid=17052
[AGain][P] inputParameterChanges tag=0 points=1 lastOffset=0 value=0.194444448 tid=5552
[AGain][C] performEdit tag=0 value=0.203703701 tid=17052
[AGain][P] inputParameterChanges tag=0 points=1 lastOffset=0 value=0.203703701 tid=5552
...
[AGain][C] endEdit tag=0 tid=17052
  • beginEditendEdit が 1 つの操作単位を表しており、その間に performEdit が繰り返し呼ばれる
  • performEditvalue と、Processor 側の inputParameterChanges で観測される value が一致しており、UI 操作の値がホスト経由で処理スレッド側へ引き渡されている
  • tid を見ると、Controller 側のログと Processor 側のログで値が異なる。UI の処理と音声処理が別スレッドで動作しうる

以上より、UI から DSP への反映は、Controller の performEdit からホストへ通知され、その後 process()inputParameterChanges として Processor 側へ渡される、という形で整理できます。

エディタ View の生成と破棄を主導するのは誰か

VST3 では、ホストが Controller の createView を呼び、要求する ViewType として editor を渡します。ViewType::kEditoreditor であることは ドキュメント に明記されています。そのため、エディタ View をいつ生成し、いつ破棄するかは、ホスト側の UI 操作の影響を強く受けます。

ホストは createView を呼び出して View を生成し、表示時に attached、非表示時に removed を呼び出します。以降は、開閉を繰り返したログで、生成と破棄が反復することを確認します。

View の生成と破棄をログで追う実装

View がホストのウィンドウへ取り付けられた瞬間と、取り外された瞬間を attachedremoved で観測します。加えて、デストラクタにログを置き、破棄まで到達するかを確認します。また、親ウィンドウのスレッド所有者を確認するために GetWindowThreadProcessId を使っています。この関数は「指定ウィンドウを作成したスレッド ID を取得する」と ドキュメント で説明されています。

againcontroller.cppcreateView を次のように変更しました。

againcontroller.cpp より抜粋
IPlugView* PLUGIN_API AGainController::createView(const char* _name)
{
	DbgPrintf("[AGain][UI] createView name=%s tid=%lu\n",
		_name ? _name : "(null)", static_cast<unsigned long>(Tid()));

	ConstString name(_name);
	if (name == ViewType::kEditor)
	{
		auto* view = new LoggingVST3Editor(this, "view", "again.uidesc");
		DbgPrintf("[AGain][UI] createView -> editor view=%p\n", view);
		return view;
	}

	DbgPrintf("[AGain][UI] createView -> nullptr (not editor)\n");
	return nullptr;
}

また、LoggingVST3Editor を次のように変更しました。

againcontroller.cpp より抜粋
class LoggingVST3Editor final : public VST3Editor
{
public:
	using VST3Editor::VST3Editor;

	tresult PLUGIN_API attached(void* parent, const char* type) SMTG_OVERRIDE
	{
		DbgPrintf("[AGain][UI] attached type=%s parent=%p tid=%lu\n",
			type ? type : "(null)", parent, static_cast<unsigned long>(Tid()));

#if defined(_WIN32) && defined(_DEBUG)
		if (type && strcmp(type, kPlatformTypeHWND) == 0)
		{
			HWND hwnd = reinterpret_cast<HWND>(parent);
			DbgPrintf("[AGain][UI] parent HWND=%p IsWindow=%d\n", hwnd, IsWindow(hwnd) ? 1 : 0);

			DWORD pid = 0;
			DWORD ownerTid = GetWindowThreadProcessId(hwnd, &pid);
			DbgPrintf("[AGain][UI] parent HWND ownerTid=%lu currentTid=%lu pid=%lu\n",
				static_cast<unsigned long>(ownerTid),
				static_cast<unsigned long>(Tid()),
				static_cast<unsigned long>(pid));
		}
#endif
		return VST3Editor::attached(parent, type);
	}

	tresult PLUGIN_API removed() SMTG_OVERRIDE
	{
		DbgPrintf("[AGain][UI] removed tid=%lu\n", static_cast<unsigned long>(Tid()));
		return VST3Editor::removed();
	}

	~LoggingVST3Editor() override
	{
		DbgPrintf("[AGain][UI] ~LoggingVST3Editor tid=%lu\n", static_cast<unsigned long>(Tid()));
	}
};

開閉を繰り返したとき毎回生成と破棄が発生するか

VST3PluginTestHost のエディタ表示を開く、閉じる操作を複数回繰り返し、ログを確認しました。

toggle button

ログの抜粋は次のとおりです。

[AGain][UI] createView name=editor tid=15000
[AGain][UI] attached type=HWND parent=... tid=15000
[AGain][UI] removed tid=15000
[AGain][UI] ~LoggingVST3Editor tid=15000

[AGain][UI] createView name=editor tid=15000
[AGain][UI] attached type=HWND parent=... tid=15000
[AGain][UI] removed tid=15000
[AGain][UI] ~LoggingVST3Editor tid=15000

[AGain][UI] createView name=editor tid=15000
[AGain][UI] attached type=HWND parent=... tid=15000
[AGain][UI] removed tid=15000
[AGain][UI] ~LoggingVST3Editor tid=15000

ログでは、開閉のたびに createView が呼ばれ、閉じる操作に対応して removed とデストラクタが呼ばれていることが分かります。少なくとも VST3PluginTestHost のこの操作では、エディタ View を保持して再利用するのではなく、表示のたびに生成し、非表示で破棄する挙動として観察できます。

ログでは、エディタの開閉を繰り返すたびに、少なくとも次の並びが繰り返し観測されています。

  • createView
  • attached
  • removed
  • ~LoggingVST3Editor

この結果から、少なくとも VST3PluginTestHost のこの操作系では、エディタ View を再利用するのではなく、開くたびに生成し、閉じるたびに破棄していることが分かります。

設計上は、View を常駐前提にせず 開閉に追従して初期化と解放が繰り返される 前提で書くのが安全です。

エディタを閉じたあとも Controller 更新と DSP 更新が続くか

プラグイン提供のエディタを閉じたあと、ホスト側の Generic Editor を用いて Gain を変更しました。

観測したログは以下のようになりました。

[AGain][UI] removed tid=27288
[AGain][UI] ~LoggingVST3Editor tid=27288
...
[AGain][P] inputParameterChanges tag=0 ... tid=26564
...
[AGain][P] outputParameterChanges tag=1 ... tid=26564
[AGain][C] setParamNormalized tag=1 ... tid=27288

この結果は、View の有無とパラメータ同期を分けて設計する必要がある ことを示します。View を閉じた状態でも、ホストはパラメータを DSP へ渡せます。また DSP 側の出力は Controller の setParamNormalized として戻りうるということが分かります。

  1. エディタ View が取り外される
  2. View のデストラクタが呼ばれる
  3. そのあとも DSP 側の inputParameterChanges が続く
  4. そのあとも DSP 側の outputParameterChanges が続く
  5. そのあとも Controller 側の setParamNormalized が続く

重要なのは、次の 2 つが別物だという点です。

  • エディタ View が存在するか
  • パラメータ同期が行われるか

View が存在しない状態でも、ホストはパラメータを DSP へ渡し続け、DSP はホストへ値を返し続け、Controller は更新され続けることが確認できます。

まとめ

VST3 のパラメータ同期は UI と DSP の直接通信ではなくホストを介した通知であるということを、実際のログを観察しながら確認しました。beginEdit performEdit endEditprocess()inputParameterChanges をログで対応付けると、UI 操作の値がどのタイミングで処理スレッドへ引き渡されるかを説明できます。また、エディタ View について、開閉に追従して生成と破棄が繰り返されうる一方で、View を閉じてもパラメータ同期が継続しうることを観察しました。View のライフサイクルと同期経路を分離して設計することが、開閉に強い UI 実装とホスト統合の土台になるでしょう。

この記事をシェアする

FacebookHatena blogX

関連記事