Nuendo rejecting parameter changes/updates from non-message threads?

I have a plugin which generates parameter values on the audio thread based on midi data, which automates a user-facing parameter on the DAW automation lane. The purpose is to automate a parameter based on the plugin’s internal state, and for our situation we do need to write this to a parameter rather than keeping it as internal state. The automation data needs to be smooth regardless of CPU load or other GUI activity, so we cannot update the parameter directly on the message thread.

We have implemented this with a juce::HighResolutionTimer. Using a FIFO we queue values from the audio thread, and the HighResolutionTimer callback dequeues and uses these values to update the parameter:

#include "AutomationStateBlock.h"

using namespace juce;

class AutomationStateBlock :
    public juce::HighResolutionTimer
{
public:
    AutomationStateBlock() { startTimer(10); }

    ~AutomationStateBlock()
    {
        if (isTimerRunning())
            stopTimer();
    }

    // Parameter change gestures are triggered on the message thread
    void handleIsPlayingChanged()
    {
        auto* param = dynamic_cast<AudioParameterFloat*>(mVTS->getParameter("paramOne"))
        if (getIsPlaying())
            param->endChangeGesture();
        else
            param->beginChangeGesture();
    }

    // Values get generated on the audio thread
    void processBlock(AudioSampleBuffer& buffer, MidiBuffer& midiMessages)
    {
        float computedValue = DoAlgorithm(midiMessages);
        mValueQueue.enqueue(computedValue);
    }

    // Parameter is updated on the high-resolution timer thread
    void hiResTimerCallback()
    {
        float value;
        auto* param = dynamic_cast<AudioParameterFloat*>(mVTS->getParameter("paramOne"));
        while (mValueQueue.dequeue(value))  // Returns false if the queue is empty
            *param = value;
    }

    FifoBuffer<float> mValueQueue;
}

This works very well on most platforms we have tested (PT, Logic, Ableton), but does not work for Nuendo. On Nuendo, the change gestures coming from the message thread work fine (they cause the automation lane to turn red), but the parameter values sent from the hiresTimerCallback do not get updated.

However, if I switch the juce::HighResolutionTimer to a juce::Timer, then the parameter values are updated correctly from the timerCallback and the automation gets written to the lane.

I have even tried updating the parameter value directly on the audio thread, as well as sending the change gestures on both the highResolutionTimer thread and the audio thread (just for a test - I know about the performance caveats of updating parameters in the audio thread). In any of these cases, Nuendo does not respond to parameter gestures or updates.

From this I have to conclude that Nuendo only handles parameter changes and updates on the message thread. Though that goes against what I’ve known in the past, that parameter updates can be called from any thread (though I know it is problematic performance-wise to update from the audio thread). Nor do I understand how Nuendo could even know which thread a parameter was being touched/updated from.

Is there anyone who has experienced similar behavior, or solved a similar problem?
This is on Nuendo 10.3 and Juce 6.0.8.

Goes for Cubase too.

The host can send parameter changes “from any thread”, mainly in the sense that each host has its own way: from the audio thread, from a separate thread, even from the message thread (e.g., moving an automation lane with the mouse).

It’s worth seeing what actually happens in VST3. There are three EditController functions, beginEdit, performEdit and endEdit, for starting a gesture, sending parameter changes, and ending the gesture. As it says here, all EditController methods must be called from the UI Thread. When you call beginChangeGesture / endChangeGesture, the wrapper calls beginEdit / endEdit only if it’s the message thread. Gestures from any other thread are ignored by the wrapper already. When you call setValueNotifyingHost, if it’s the message thread the wrapper calls performEdit. Otherwise it saves the change, and in the next process callback, after processBlock returns, it writes all these changes to the structure intended for sample accurate automation (in a non sample accurate way). It is this data that seems to be ignored by Cubendo.

I’m not sure how sample accurate (audio thread) automation is supposed to work with automation modes, given that edits / gestures are strictly UI actions. In any case, Juce doesn’t support sample accurate automation, so I think the only safe, consistent way of passing parameter changes to the host is through the message thread.

1 Like

Plain as day on that page! Thank you very much for the link - I had not seen that page before.

And unfortunate for my situation. :frowning:

I’m not sure I see where the JUCE wrapper is performing any thread-aware logic to ignore gestures. I see calls to setValueNotifyingHost() eventually drill down to EditController::performEdit() regardless of which thread I’m setting a value in. Of course, if Steinberg is simply going to ignore EditController methods in non-message threads, then that’s all moot anyways.

beginChangeGesture, endChangeGesture and setValueNotifyingHost reach the wrapper through AudioProcessorParameter::Listener functions, parameterValueChanged and parameterGestureChanged. The listeners are instances of OwnedParameterListener, which redirect to functions of the owner, beginGesture, endGesture and paramChanged. So eventually you reach this code in JuceVST3EditController:

void beginGesture (Vst::ParamID vstParamId)
{
    if (MessageManager::getInstance()->isThisTheMessageThread())
        beginEdit (vstParamId);
}

void endGesture (Vst::ParamID vstParamId)
{
    if (MessageManager::getInstance()->isThisTheMessageThread())
        endEdit (vstParamId);
}

void paramChanged (Steinberg::int32 parameterIndex, Vst::ParamID vstParamId, double newValue)
{
    if (inParameterChangedCallback)
        return;

    if (MessageManager::getInstance()->isThisTheMessageThread())
    {
        // NB: Cubase has problems if performEdit is called without setParamNormalized
        EditController::setParamNormalized (vstParamId, newValue);
        performEdit (vstParamId, newValue);
    }
    else
    {
        audioProcessor->setParameterValue (parameterIndex, (float) newValue);
    }
}

On the last line, setParameterValue saves the change:

void setParameterValue (Steinberg::int32 paramIndex, float value)
{
    cachedParamValues.set (paramIndex, value);
}

template <typename Callback>
void forAllChangedParameters (Callback&& callback)
{
    cachedParamValues.ifSet ([&] (Steinberg::int32 index, float value)
    {
        callback (cachedParamValues.getParamID (index), value);
    });
}

to be recalled later at the end of JuceVST3Component::processAudio:

if (auto* changes = data.outputParameterChanges)
{
    comPluginInstance->forAllChangedParameters ([&] (Vst::ParamID paramID, float value)
    {
        Steinberg::int32 queueIndex = 0;

        if (auto* queue = changes->addParameterData (paramID, queueIndex))
        {
            Steinberg::int32 pointIndex = 0;
            queue->addPoint (0, value, pointIndex);
        }
    });
}

outputParameterChanges holds the queue intended for sample accurate automation, which in this case is filled once per block.

1 Like

Odd - In my juce_VST3_Wrapper.cpp file I can’t find any of the JUCE code that you have quoted in your post. For instance, the paramChanged() method in my juce_VST3_Wrapper.cpp looks like (JUCE 6.0.8):

void paramChanged (Vst::ParamID vstParamId, double newValue)
{
    if (inParameterChangedCallback.get())
    {
        inParameterChangedCallback = false;
        return;
    }

    // NB: Cubase has problems if performEdit is called without setParamNormalized
    EditController::setParamNormalized (vstParamId, newValue);
    performEdit (vstParamId, newValue);
}

(from JUCE/juce_VST3_Wrapper.cpp at 90e8da0cfb54ac593cdbed74c3d0c9b09bad3a9f · juce-framework/JUCE · GitHub, which notably doesn’t have any thread-dependent behavior)

Are you running on a development version of JUCE, or possibly have made local changes?

[Edit: Never mind, I found the code you quoted in the develop branch from a change in April. I’ll switch to that branch so that I’m investigating using the same code as you]

1 Like

Yup, this made me notice that I actually hadn’t checked Cubase’s behavior after that change, so updating: it doesn’t ignore audio thread changes :sweat_smile: They can’t be recorded though -enabling write has no effect. Also, if read is enabled, the audio thread and written automation will both pass and fight each other. I think the idea of output audio thread changes is not for automatable parameters, but for things like meters. Like, you’re not supposed to output audio thread changes for a parameter that you’re also reading. If the idea is to send changes for writing, we’re back to the message thread.

1 Like