Understanding parameter updates in VST3 plugins through logs - Examining ProcessData::inputParameterChanges and sampleOffset in AGain

Understanding parameter updates in VST3 plugins through logs - Examining ProcessData::inputParameterChanges and sampleOffset in AGain

Have you ever experienced volume changing in steps or felt a delay in the response when trying to link a VST3 plugin with your DAW's faders or automation? This article examines the path of parameters from Host → Controller → Processor by adding logging to the VST3 SDK's sample plugin AGain. We'll organize the relationship between ProcessData::inputParameterChanges, IParamValueQueue, and sampleOffset using concrete log examples. The goal is to resolve issues when implementing fader linkage and automation by understanding the block-based processing model and how parameter changes are handled.
2025.12.07

This page has been translated by machine translation. View original

Introduction

When starting to write a VST3 plugin, many people encounter the following obstacle: while a simple plugin that just applies gain works fine, they suddenly get confused when trying to make it interact with the DAW's faders or automation.

You might experience volume changing in steps even though you're moving the fader smoothly, or notice a slight delay in the timing when automation starts to take effect. Reading the VST3 SDK documentation reveals terms like ParamID, ProcessData, inputParameterChanges, IParamValueQueue, and sampleOffset, but understanding how they all connect is not straightforward at first glance.

This article aims to clarify how parameter operations from the DAW flow into the process function of VST3 plugins for those who have such questions. We'll examine the flow by adding logging to the AGain sample plugin from the VST3 SDK, and investigate how fader operations and automation appear in ProcessData using actual logs as our guide.

What is a VST Plugin?

VST (Virtual Studio Technology) is a common interface for connecting host applications such as DAWs with audio plugins like effects and synthesizers.

In October 2025, Steinberg published the VST 3.8 SDK and changed the license to the MIT license (reference). Previously, Steinberg's proprietary license (and sometimes individual contracts) was required, but now it can be freely used, including in commercial products, by following the MIT license. The main conditions are keeping the copyright notice and license text. Please check the license text for details.

Goal of This Article

The goal of this article is to help you visualize how DAW fader operations and automation flow into the VST3 plugin's ProcessData::inputParameterChanges, and how they are represented as sampleOffset and value in IParamValueQueue.

Target Audience

  • Those who have started writing VST3 plugins in C++ but haven't fully grasped the parameter mechanisms
  • Engineers whose main work is game or application development, but who occasionally need to handle DAW integration or VST3 support
  • Those who want to verify how fader and automation operations are delivered to plugins as parameter updates based on actual measurements

References

Background and Prerequisites

Why Not Understanding "Parameter Flow" Causes Problems

When writing VST3 plugins, you might notice these issues:

  • Volume changes in steps even though the fader moves smoothly
  • Automation seems to take effect slightly later or earlier than expected

These phenomena typically occur when "what you see on the DAW screen" doesn't match "what the plugin sees in the process function". Even if you move a fader, the plugin might only read values at block boundaries, or might not be referencing the sampleOffset at all. This creates a discrepancy between when the DAW intends a change to happen and when the sound actually changes.

Difference between DAW view and plugin internal view

The causes can be broken down into two main points:

The first is that audio processing happens in blocks. Each time process is called, it receives a batch of samples to process all at once. If your implementation only reads the fader value "once at the beginning of each block," you'll inevitably end up with step-like changes between blocks.

The second is not utilizing the sampleOffset in ProcessData::inputParameterChanges. Multiple parameter changes can occur within a single block, but if you ignore sampleOffset, all changes are treated as if they happened at the beginning of the block. This results in timing discrepancies between what you hear and what was intended.

Processing in blocks

Reviewing Audio Blocks and sampleOffset

Why Process in Blocks Instead of Sample by Sample?

Audio signals are handled as sequences of discrete samples. For example, at 48 kHz, there are 48,000 samples per second.

Sequence of discrete samples

Passing these to process one sample at a time would result in too many function calls, making both implementation and overhead impractical. Therefore, processing typically happens in "blocks" of a fixed number of samples.

For example, with a sample rate of 48 kHz and a buffer size of 1,024 samples, each block is approximately 21 ms long. Every 21 ms, process is called, and the plugin processes a chunk of 1,024 samples.

Processing in blocks of samples

How VST3 Sees "One Block of Sound"

The process function in VST3 receives a ProcessData structure. Within this, numSamples indicates the length of the current block to process.

From the DAW's perspective, audio flows according to the audio device's buffer settings. From the plugin's perspective, each process call delivers "the current batch of samples (inputs/outputs)" and "events that occurred during that time (inputEvents/outputEvents)".

Plugins are designed to complete processing for each block independently. They don't directly access samples from other blocks but work within the context of "the block currently being passed".

ProcessData

Event sampleOffset is "Position Within the Block"

ProcessData also contains fields for events like note-on and note-off. The sampleOffset used here represents "how many samples from the beginning of the current block".

For example, in a block where numSamples = 1024, you might have a note-on at sampleOffset = 0, a parameter change at sampleOffset = 256, and a note-off at sampleOffset = 900. By correctly handling sampleOffset, you can recreate the temporal relationships within the block.

This concept applies directly to parameter changes as well, since inputParameterChanges also uses sampleOffset in the same way.

Events and sampleOffset within one block

Parameter Pathways in VST3

In VST3, understanding parameter flow becomes easier when you distinguish between these three roles:

  • Host: The DAW side, which has faders and automation curves, and sends parameter values to the plugin based on user operations.
  • Controller: The plugin's controller class, responsible for parameter definitions, string representations, and editor UI.
  • Processor: The processor class that handles audio in the process function and applies parameter values to the sound.

A simplified relationship diagram looks like this:

The Host communicates parameter values to both Controller and Processor using the same ParamID as a key. For the Controller, setParamNormalized is called to update internal state and UI. For the Processor, information about which ParamID changed to what value at what timing is stored in ProcessData::inputParameterChanges passed to process.

Test Environment and Setup

I added logging to the AGain plugin provided as a sample in the official SDK to observe its behavior.

Test Environment

  • OS: Windows 11 (64 bit)
  • Visual Studio 2022 (MSVC)
  • VST3 SDK 3.8 series
  • Steinberg VST3PluginTestHost
  • ASIO-compatible audio interface
    • sample rate: 44.1 kHz
    • buffer size: 1,024 samples

Note that behavior can vary significantly depending on the ASIO driver selection. In my environment, the generic Realtek ASIO driver didn't work well, so I used the dedicated ASIO driver for my audio interface.

Getting and Building the SDK

First, let's get the VST3 SDK and set up the official samples for building:

  1. Clone the VST3 SDK from GitHub:

    git clone --recursive https://github.com/steinbergmedia/vst3sdk.git
    cd vst3sdk
    
  2. Create a build directory and generate Visual Studio project files using CMake:

    mkdir build
    cd build
    
    cmake -G "Visual Studio 17 2022" -A x64 .. -DSMTG_CREATE_PLUGIN_LINK=OFF
    
  3. Open the generated vstsdk.sln in Visual Studio.
    open solution

  4. Open solution properties and set Common Properties > Configure Startup Projects to Single startup project > again.
    configure startup projects
    Single startup project

  5. Close the properties window with OK. Ensure the configuration is set to Debug and the platform to x64. Build the solution with Build > Build Solution.
    configuration and platform
    build solution

After a successful build, again.vst3 will be generated in build/VST3/Debug.

built plugin

Setting Up VST3PluginTestHost

VST3PluginTestHost is included in the full VST3 SDK. Download the full SDK ZIP from the official site. After extracting, locate and extract VST3PluginTestHost_x64_Installer_x.xx.xx.zip from this path:

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

Run the extracted VST3PluginTestHost_x64.msi to install VST3PluginTestHost.

VST3PluginTestHost

Launching the Host from Visual Studio for Debugging

To observe AGain's behavior, we'll launch VST3PluginTestHost from Visual Studio with the debugger attached to view logs via OutputDebugString.

  1. In Visual Studio's Solution Explorer, right-click the Plugin-Examples > again project and open "Properties".
    open again properties

  2. Ensure Configuration: Debug and Platform: x64 are selected, then go to "Configuration Properties > Debugging" and set:

    • Command: C:\path\to\VST3PluginTestHost.exe (path to the VST3PluginTestHost.exe you installed)
    • Command Arguments: --pluginfolder "C:\path\to\build\VST3\Debug" (folder containing the built again.vst3)
      again project properties
  3. Close the properties window with OK, set the configuration to Debug x64, and press Local Windows Debugger or F5 to launch VST3PluginTestHost with the debugger attached.
    Local Windows Debugger button
    debugger view

Now when you load AGain and start playback, content output with OutputDebugStringA from AGain's code will appear in Visual Studio's Output window.

output viewing

Testing and Analysis

In the AGain code, to communicate the Processor's state to the Controller, AGainController::setComponentState reads gain values from a stream and calls setParamNormalized(kGainId, savedGain). This synchronizes the Controller's display with the parameter state saved by the Processor.

AGain's parameters.addParameter and ParamID

Parameters like gain and bypass are registered in AGainController::initialize. Gain is defined using a GainParameter class rather than the generic Parameter.

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);
}

Here we see that the display name is Gain, the unit is dB, info.id contains the ParamID, and defaultNormalizedValue holds the default value.

AGainController::initialize registers not only the GainParameter but also Vu meter and bypass parameters:

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;
}

ParamID values like kGainId, kVuPPMId, and kBypassId are defined as integers in againparamids.h and used as common keys between Host, Controller, and Processor. The Host sends "which ParamID should be set to what normalized value" based on user actions, the Controller updates its internal state and UI via setParamNormalized, and the Processor reads the same ParamID in ProcessData::inputParameterChanges to determine which parameter changed at what timing.

Examining inputParameterChanges in ProcessData with Logging

Parameter Changes

ProcessData is passed to AGain::process. Within this, inputParameterChanges represents "how parameters changed during this block."

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

Inside inputParameterChanges, there's one IParamValueQueue per parameter. Each queue contains pairs of sampleOffset and value. sampleOffset represents "how many samples from the beginning of the current block," while value is a normalized value between 0.0 and 1.0. Parameters are distinguished by ParamID.

This only contains "points where changes occurred." If a fader's value doesn't change throughout a block, nothing will be included for that block.

Adding Simple Logging to AGain

To see what's inside inputParameterChanges, let's add minimal logging to AGain.

First, add a helper function for debug logging near the top of 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

Next, add a block counter to the AGain class. Add this field to the member variable definitions in again.h:

class AGain : public AudioEffect
{
public:
    // omitted

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

Then, at the beginning of AGain::process, increment the counter for each block and log the contents of 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 ();

                // Debug: enumerate all points received in this block
                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);
                    }
                }

                // For actual parameter values, use only "the last point" as in the original implementation
                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;
                    }
                }
            }
        }
    }

    // (Everything below is the same as the original AGain: event processing, audio processing, Vu meter updates, etc.)
    // ...

    return kResultOk;
}

When you run in debug mode in Visual Studio and slowly move the AGain gain knob, logs from the plugin will appear in the Output window among host and DLL loading messages.

debugging

Lines output from debugLog start with [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
...

Here, paramId=0 corresponds to kGainId, block is the number of process calls, and value represents the normalized value effective in that block.

In this environment, all lines have offset=0, indicating that the host updates parameters by "sending a single point at the beginning of each block." While some hosts might include multiple (sampleOffset, value) pairs within the same block, this "one point per block" approach is also common.

By examining actual logs of block, ParamID, sampleOffset, and normalized values, you can better visualize "how DAW fader operations appear in ProcessData::inputParameterChanges and at what granularity."

Plugin-Side Differentiation

Parameters Where Only the Block's Initial Value Matters

For parameters like mix ratio, on/off switches, or mode selections, where "processing with this setting throughout the block is sufficient," it's often practical to just read the value at the beginning of each block.

Even when moving a fader rapidly, the audible impression may only register changes at the block level: "setting A during this block" then "setting B during the next block." In such cases, an implementation like AGain's that only picks up the last point from IParamValueQueue to update internal state is sufficient.

Parameters That Need More Careful Handling

On the other hand, parameters like volume fades can sound unnatural if values change abruptly. For these parameters, it's better if the volume changes more closely match the original automation curve.

In this case, you might design your plugin to process while being aware of sampleOffset and value pairs within the IParamValueQueue throughout the block. For example, you could interpolate between multiple points internally and apply gain smoothly across the sample array.

Interpolation using sampleOffset

While this article doesn't delve into specific interpolation algorithms, understanding that "looking only at the block's initial value can leave steps" and that "sampleOffset allows you to correct timing within a block" makes it easier to design which parameters need more careful handling.

Summary

This article has clarified how DAW fader operations and automation reach the process function in VST3 plugins. Parameters operated on the Host side use the ParamID defined in the Controller as a key, ultimately being passed to the Processor side in ProcessData::inputParameterChanges as pairs of sampleOffset and value in IParamValueQueue.

From the plugin's perspective, "blocks of audio samples" and "parameter changes that occurred during that block" are passed together to process. Understanding that DAW curves don't arrive as sample-by-sample values but as a two-tier structure of block-based processing and event sequences within blocks helps explain why faders might sound stepped or automation timing might feel off.

Share this article

FacebookHatena blogX

Related articles