VST3プラグインのパラメータ更新をログで理解する – AGainで見るProcessData::inputParameterChangesとsampleOffset

VST3プラグインのパラメータ更新をログで理解する – AGainで見るProcessData::inputParameterChangesとsampleOffset

VST3 プラグインで DAW のフェーダーやオートメーションと連動させようとしたときに、音量が段階的に変化して聞こえたり、効き始めのタイミングがずれて感じられたことはないでしょうか。本記事では、VST3 SDK のサンプルプラグイン AGain にログ出力を仕込み、Host → Controller → Processor へと渡されるパラメータの経路と、ProcessData::inputParameterChanges / IParamValueQueue / sampleOffset の関係を、具体的なログを用いて整理します。ブロック単位の処理モデルとパラメータ変化の扱い方を押さえることで、フェーダー連動やオートメーション実装時のつまずきを解消することを目指します。
2025.12.07

はじめに

VST3 プラグインを書き始めると、多くの方が次のような壁にぶつかります。とりあえずゲインを掛けるだけのプラグインは動くのに、DAW のフェーダーやオートメーションと連動させようとすると、急に分からなくなる という壁です。

フェーダーを動かしているのに音量が段階的に変わってしまったり、オートメーションを描いたのに効き始めのタイミングが微妙に遅れて聞こえたりすることがあります。 VST3 SDK のドキュメント を読むと ParamIDProcessDatainputParameterChangesIParamValueQueuesampleOffset といった用語が並んでいますが、それぞれがどのようにつながっているのかを一度で理解するのは簡単ではありません。

本記事では、このような疑問を抱えている方を対象に、 DAW 側のパラメータ操作が VST3 プラグイン内部の process に届くまでの流れを整理します。 VST3 SDK のサンプルプラグインである AGain にログを仕込み、フェーダー操作やオートメーションが ProcessData のどこに、どのような形で現れるのかを、実際のログを手掛かりに見ていきます。

VST プラグインとは

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

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

本記事のゴール

本記事のゴールは、 DAW のフェーダー操作やオートメーションが、 VST3 プラグインの ProcessData::inputParameterChanges にどのように流れ込み、 IParamValueQueuesampleOffsetvalue としてどのように表現されるのかをイメージできるようになること です。

対象読者

  • C++ で VST3 プラグインを書き始めたものの、パラメータ周りの仕組みがつかみきれていない方
  • ゲームやアプリケーション開発が主な業務で、必要に応じて DAW 連携や VST3 対応を行うエンジニアの方
  • フェーダーやオートメーションの操作が、プラグイン内部のパラメータ更新としてどのように届いているのかを実測ベースで確認したい方

参考

背景と前提知識

なぜ「パラメータの流れ」を知らないと問題が起きやすいのか

VST3 プラグインを書いていると、次のような違和感を覚えることがあります。

  • フェーダーは滑らかに動いているのに、音量が段階的に変化して聞こえる
  • オートメーションを描いたのに、効き始めのタイミングが少し遅れたり早まったりして聞こえる

このような現象は、いずれも 「DAW の画面で見えている情報」と「プラグイン内部の process で見えている情報」が一致していない ときに起こりやすいです。フェーダーを動かしたつもりでも、プラグイン側ではブロック単位でしか値を読んでいなかったり、 sampleOffset をまったく参照していなかったりします。その結果として、 DAW 側で意図したタイミングと、音が変化するタイミングのあいだにずれが生じます。

DAW とプラグイン内部の見え方の違い

原因はおおまかにいえば次の二点です。

1 つ目は、オーディオ処理がブロック単位で行われていることです。 process が呼ばれるたびに、一定長のサンプル列をまとめて受け取り、そのかたまりに対して一括で処理を行います。フェーダーの値を「ブロックの先頭で 1 回だけ読む」という実装にしていると、どうしてもブロックごとの段差が残ります。

2 つ目は、 ProcessData::inputParameterChanges に含まれる sampleOffset を活用していないことです。本来は、 1 つのブロックの中に複数のパラメータ変化が入ってきますが、 sampleOffset を無視していると、そのどれもが「ブロック先頭の変化」として扱われてしまいます。その結果として、変化のタイミングが耳で聞いた印象とずれてしまいます。

ブロック単位で処理するイメージ

オーディオブロックと sampleOffset をおさらい

なぜ 1 sample ずつではなくブロック単位で処理するのか

オーディオ信号は、離散的なサンプルの列として扱われます。たとえば 48 kHz の場合は、 1 秒間に 48,000 個のサンプルが並んでいるイメージです。

離散的なサンプルの列

これを 1 sample ごとに process に渡していると、関数の呼び出し回数が多くなりすぎて、実装もオーバーヘッドも現実的ではありません。そのため、通常は「一定数のサンプルをまとめたブロック」を単位として処理します。

たとえば sample rate が 48 kHz、 buffer size が 1,024 sample の場合、 1 ブロックの長さは約 21 ms 程度になります。21 ms ごとに process が呼び出され、プラグインはそのたびに 1,024 sample のかたまりを処理します。

「一定数のサンプルをまとめたブロック」を単位として処理

VST3 から見ると「1 ブロック分の音」が流れてくる

VST3 の process には、 ProcessData 構造体が渡されます。この中の numSamples が、今回処理すべきブロックの長さです。

DAW から見れば、オーディオデバイスのバッファリング設定に従って、一定長ごとにオーディオが流れてきます。プラグインから見れば、 process 呼び出しごとに「今回分のサンプル列(inputs / outputs)」と「その間に起きたイベント(inputEvents / outputEvents)」がまとめて届く形になります。

プラグインは、各ブロックごとに処理を完結させる前提で設計されます。別のブロックのサンプルに直接アクセスすることはなく、「いま渡されているブロックの範囲内」で完結するように実装します。

ProcessData

イベントの sampleOffset は「ブロックの中での位置」

ProcessData には、ノートオンやノートオフといったイベントが格納されるフィールドもあります。ここで使われる sampleOffset は「今回のブロックの先頭から何 sample 目か」を表す値です。

たとえば numSamples = 1024 のブロックに対して、 sampleOffset = 0 のノートオン、 sampleOffset = 256 のパラメータ変更、 sampleOffset = 900 のノートオフというように、複数のイベントが同じブロック内に並ぶことがあります。このとき、 sampleOffset を正しく扱うことで、ブロックの中での時間的な位置関係を再現できます。

この考え方は、後述するパラメータの変化にもそのまま適用されます。 inputParameterChanges に対しても、同じように sampleOffset が付与されているためです。

1 ブロック内の sampleOffset とイベント

VST3 におけるパラメータの通り道

VST3 では、パラメータの流れを理解するために、次の 3 つの役割を区別しておくと整理しやすくなります。

  • Host : DAW 側です。フェーダーやオートメーションカーブを持ち、ユーザー操作をパラメータ値としてプラグインに送ります。
  • Controller : プラグイン側のコントローラクラスです。パラメータの定義や文字列表現、エディタ UI を担当します。
  • Processor : プロセッサクラスです。 process 内でオーディオを処理し、受け取ったパラメータの値を音に反映します。

関係を簡単な図にすると、次のようになります。

Host は、同じ ParamID をキーとして Controller と Processor の両方にパラメータ値を伝えます。Controller 側では setParamNormalized が呼ばれて内部状態や UI が更新されます。 Processor 側では process に渡される ProcessData::inputParameterChanges の中に、どの ParamID がどのタイミングでどの値に変化したかが格納されます。

検証環境と準備

公式 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

  5. Configuration Properties > Configuration を開き、Configuration: Debug Platform: x64 であることを確認してから、 again の Build のみチェックを入れます。
    configuration properties

  6. プロパティのウインドウを 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

検証と考察

AGain のコードでは、Processor 側の状態を Controller に伝えるために、 AGainController::setComponentState の中でストリームからゲイン値を読み取り、 setParamNormalized (kGainId, savedGain) を呼び出しています。これにより、 Processor が保存していたパラメータ状態と Controller 側の表示が同期します。

AGain の parameters.addParameterParamID

AGainController::initialize の中でゲインやバイパスなどのパラメータが登録されています。ゲインは汎用の Parameter ではなく、GainParameter というクラスで定義されています。

GainParameter::GainParameter (int32 flags, int32 id)
{
    Steinberg::UString (info.title, USTRINGSIZE (info.title)).assign (USTRING ("Gain"));
    Steinberg::UString (info.units, USTRINGSIZE (info.units)).assign (USTRING ("dB"));

    info.flags = flags;
    info.id = id;
    info.stepCount = 0;
    info.defaultNormalizedValue = 0.5f;
    info.unitId = kRootUnitId;

    setNormalized (1.f);
}

ここでは、表示名が Gain、単位が dB であること、info.idParamID が入ること、defaultNormalizedValue に既定値が入ることが分かります。

AGainController::initialize では、GainParameter に加えて Vu メーターとバイパスのパラメータを登録しています。

tresult PLUGIN_API AGainController::initialize (FUnknown* context)
{
    tresult result = EditControllerEx1::initialize (context);
    if (result != kResultOk)
    {
        return result;
    }

    //---Gain parameter---
    auto* gainParam = new GainParameter (ParameterInfo::kCanAutomate, kGainId);
    parameters.addParameter (gainParam);
    gainParam->setUnitID (1);

    //---VuMeter parameter---
    parameters.addParameter (STR16 ("VuPPM"), nullptr, 0, 0,
                             ParameterInfo::kIsReadOnly, kVuPPMId);

    //---Bypass parameter---
    parameters.addParameter (STR16 ("Bypass"), nullptr, 1, 0,
                             ParameterInfo::kCanAutomate | ParameterInfo::kIsBypass, kBypassId);

    return result;
}

kGainIdkVuPPMIdkBypassId といった ParamIDagainparamids.h で整数値として定義されており、Host、Controller、Processor のあいだで共通のキーとして使われます。Host はユーザー操作に応じて「どの ParamID をどの正規化値にするか」を送り、Controller は setParamNormalized で内部状態や UI を更新します。Processor は ProcessData::inputParameterChanges の中で同じ ParamID を読み取り、どのパラメータがどのタイミングで変化したかを判断します。

ProcessDatainputParameterChanges をログで覗いてみる

パラメータの変化

AGain::process には ProcessData が渡されます。この中の inputParameterChanges が、「今回のブロックのあいだに、どのパラメータがどのように変化したか」を表すフィールドです。

tresult PLUGIN_API AGain::process (ProcessData& data)
{
    // 省略
    IParameterChanges* paramChanges = data.inputParameterChanges;
    // 省略
}

inputParameterChanges の内部では、パラメータごとに IParamValueQueue が 1 本ずつ用意されています。各キューの中に、sampleOffsetvalue の組が並びます。sampleOffset は「今回のブロックの先頭から何 sample 目か」を表す値で、value は 0.0 から 1.0 の正規化値です。どのパラメータかは ParamID で区別します。

ここに入っているのは「変化が起きたポイント」だけです。フェーダーの値がブロックのあいだ中まったく変わらなければ、そのブロックには何も入らないこともあります。

AGain に簡単なログを仕込む

inputParameterChanges の中身を具体的に見るために、AGain に最小限のログ出力を追加します。

まず、again.cpp の先頭付近に、デバッグログ用のヘルパ関数を追加します。

#include <cstdio>

#if SMTG_OS_WINDOWS
#include <windows.h>
#endif

namespace {

    inline void debugLog (const char* msg)
    {
    #if defined (SMTG_OS_WINDOWS) && defined (_DEBUG)
        ::OutputDebugStringA (msg);
    #else
        (void)msg;
    #endif
    }

} // anonymous namespace

次に、AGain のクラスにブロック番号を数えるカウンタを追加します。again.h のメンバ変数定義に、次のようなフィールドを足します。

class AGain : public AudioEffect
{
public:
    // 省略

protected:
    Steinberg::uint64 blockCounter_ {0};
};

次に、AGain::process の先頭でブロックごとのカウンタを進めつつ、inputParameterChanges の中身を列挙してログを出力します。

tresult PLUGIN_API AGain::process (ProcessData& data)
{
    ++blockCounter_;

    if (IParameterChanges* paramChanges = data.inputParameterChanges)
    {
        int32 numParamsChanged = paramChanges->getParameterCount ();
        for (int32 i = 0; i < numParamsChanged; i++)
        {
            if (IParamValueQueue* paramQueue = paramChanges->getParameterData (i))
            {
                ParamID pid = paramQueue->getParameterId ();
                int32 numPoints = paramQueue->getPointCount ();

                // デバッグ用 : このブロックで受け取った全ポイントを列挙してログ出力する
                for (int32 j = 0; j < numPoints; ++j)
                {
                    int32 sampleOffset = 0;
                    ParamValue value = 0.0;

                    if (paramQueue->getPoint (j, sampleOffset, value) == kResultTrue)
                    {
                        char buf[256];
                        std::snprintf (
                            buf,
                            sizeof (buf),
                            "[again][Processor] block=%llu, paramId=%d, point=%d, "
                            "offset=%d, value=%.3f\n",
                            static_cast<unsigned long long> (blockCounter_),
                            static_cast<int> (pid),
                            static_cast<int> (j),
                            static_cast<int> (sampleOffset),
                            value);
                        debugLog (buf);
                    }
                }

                // 実際のパラメータ値としては、元の実装と同様に「最後のポイント」だけを使う
                ParamValue value = 0.0;
                int32 sampleOffset = 0;

                if (numPoints > 0 &&
                    paramQueue->getPoint (numPoints - 1, sampleOffset, value) == kResultTrue)
                {
                    switch (pid)
                    {
                        case kGainId:
                            fGain = static_cast<float> (value);
                            break;

                        case kBypassId:
                            bBypass = (value > 0.5f);
                            break;
                    }
                }
            }
        }
    }

    // (ここから下は元の AGain と同じ : イベント処理、オーディオ処理、Vu メーター更新など)
    // ...

    return kResultOk;
}

Visual Studio でデバッグ実行し、AGain のゲインノブをゆっくり動かすと、Output ウィンドウにはホストや各種 DLL のロードログと並んで、プラグイン側から出力したログが混ざって表示されます。

debugging

debugLog から出力されるのは、次のように [again][Processor] で始まる行です。

[again][Processor] block=208, paramId=0, point=0, offset=0, value=0.806
[again][Processor] block=209, paramId=0, point=0, offset=0, value=0.815
[again][Processor] block=210, paramId=0, point=0, offset=0, value=0.833
[again][Processor] block=211, paramId=0, point=0, offset=0, value=0.843
...

ここでは paramId=0kGainId に対応しており、blockprocess の呼び出し回数、value がそのブロックの中で有効な正規化値を表しています。

今回の環境では、すべての行で offset=0 になっており、ホストが「各ブロックの先頭で 1 点だけ値を送る」という形でパラメータを更新していることが読み取れます。同じブロックの中で複数の (sampleOffset, value) が入る場合もありますが、この例のように「ブロック単位で 1 点だけ」というホスト実装も一般的です。

このように、実際に blockParamIDsampleOffset、正規化値をログとして眺めることで、「DAW 上のフェーダー操作が、ProcessData::inputParameterChanges のどこに、どのような粒度で現れているか」という点を具体的にイメージしやすくなります。

プラグイン側での使い分け

ブロック先頭の値だけ見てよいパラメータ

ミックス比やオン/オフの切り替え、モード選択のように、「このブロックのあいだはこの設定で処理できれば十分」というタイプのパラメータもあります。こうしたパラメータについては、各ブロックの先頭の値だけを読んで処理しても、実用上問題にならない場面が多いです。

フェーダーを細かく動かしても、耳で聞いた印象としては「このブロックのあいだは A の設定」「次のブロックのあいだは B の設定」といった単位でしか違いが分からないケースもあります。そのような場合は、IParamValueQueue の最後の 1 点だけを拾って内部状態を更新する、AGain のような実装でも十分に成立します。

もう少し丁寧に扱いたいパラメータ

一方で、ボリュームフェードのように、急に値が変わると違和感が出やすいパラメータもあります。このようなパラメータでは、本来のオートメーションカーブに近い形で音量が変化してくれたほうが自然に聞こえます。

その場合は、IParamValueQueue に並んだ sampleOffsetvalue をブロック内部で意識しながら処理する、という設計が選択肢になります。たとえば、ブロック内の複数ポイントを使って内部的に補間し、サンプル列に対してなめらかにゲインを掛けていくような実装が考えられます。

sampleOffset による補間

本記事では具体的な補間アルゴリズムには踏み込みませんが、少なくとも「ブロック先頭の値だけを見ると段差が残りやすい」「sampleOffset を使えばブロック内での時間的な位置を補正できる」という構造を押さえておくと、どのパラメータをどこまで丁寧に扱うべきか設計しやすくなります。

まとめ

本記事では、DAW のフェーダー操作やオートメーションが、VST3 プラグイン内部の process に届くまでの経路を整理しました。Host 側で操作されたパラメータでは、Controller で定義された ParamID がキーとして扱われ、最終的には Processor 側の ProcessData::inputParameterChanges に、IParamValueQueuesampleOffsetvalue の組として渡されます。

プラグインから見ると、「オーディオサンプルのブロック」と「そのブロック中に起きたパラメータ変化」がセットで process に渡されているイメージになります。DAW 上のカーブがそのまま 1 サンプルごとの値として届いているわけではなく、ブロック単位の処理とブロック内のイベント列という二段構造になっていることを理解しておくと、フェーダーが段階的に聞こえてしまう理由や、オートメーションの効き始めがずれて感じられる理由を、自分の実装の前提として説明しやすくなります。

この記事をシェアする

FacebookHatena blogX

関連記事