Ableton Live crash in large projects after minimal JUCE VST3 program-change UI update

Title: Ableton Live crash in large projects after minimal JUCE VST3 program-change UI implementation

Hi everyone,

I made a very small change to a JUCE VST3 plugin to display host-driven program changes in the UI, and now Ableton Live crashes in large projects.

I am not parsing incoming MIDI Program Change events for this feature.
I only use the AudioProcessor program API path.

Environment

  • macOS
  • Ableton Live
  • JUCE plugin, VST3 only
  • Started from basic JUCE template

What I changed

  1. Implemented program support in processor
  • Number of programs: 128
  • currentProgram stored in atomic int
  • setCurrentProgram clamps and stores
  • getProgramName returns Program 1..128
  1. Added processor accessor for UI
  • getLastHostProgramChange reads atomic currentProgram
  1. Added state save and restore
  • Save currentProgram in getStateInformation
  • Restore currentProgram in setStateInformation
  1. Updated editor
  • Timer at 30 Hz
  • Poll processor value
  • repaint only if value changed
  • Draw current program in UI

Expected behavior

  • Stable plugin
  • UI updates when host changes program

Actual behavior

  • Works in small/simple sessions
  • Ableton Live crashes in large projects after this change

Main snippets

Header changes:

class Test2AudioProcessor  : public juce::AudioProcessor
{
public:
    int getNumPrograms() override;
    int getCurrentProgram() override;
    void setCurrentProgram (int index) override;
    const juce::String getProgramName (int index) override;
    void changeProgramName (int index, const juce::String& newName) override;

    int getLastHostProgramChange() const noexcept;

private:
    static constexpr int kNumPrograms = 128;
    std::atomic<int> currentProgram { 0 };
};

Processor program implementation:

int Test2AudioProcessor::getNumPrograms()
{
    return kNumPrograms;
}

int Test2AudioProcessor::getCurrentProgram()
{
    return currentProgram.load (std::memory_order_relaxed);
}

void Test2AudioProcessor::setCurrentProgram (int index)
{
    const auto clamped = juce::jlimit (0, getNumPrograms() - 1, index);
    currentProgram.store (clamped, std::memory_order_relaxed);
}

const juce::String Test2AudioProcessor::getProgramName (int index)
{
    if (! juce::isPositiveAndBelow (index, getNumPrograms()))
        return {};

    return "Program " + juce::String (index + 1);
}

void Test2AudioProcessor::changeProgramName (int index, const juce::String& newName)
{
    juce::ignoreUnused (index, newName);
}

int Test2AudioProcessor::getLastHostProgramChange() const noexcept
{
    return currentProgram.load (std::memory_order_relaxed);
}

State persistence:

void Test2AudioProcessor::getStateInformation (juce::MemoryBlock& destData)
{
    juce::ValueTree state ("Test2State");
    state.setProperty ("currentProgram", getCurrentProgram(), nullptr);

    if (auto xml = state.createXml())
        copyXmlToBinary (*xml, destData);
}

void Test2AudioProcessor::setStateInformation (const void* data, int sizeInBytes)
{
    if (auto xml = getXmlFromBinary (data, sizeInBytes))
    {
        const juce::ValueTree state = juce::ValueTree::fromXml (*xml);

        if (state.isValid() && state.hasType ("Test2State"))
            setCurrentProgram ((int) state.getProperty ("currentProgram", 0));
    }
}

Editor timer and repaint logic:

class Test2AudioProcessorEditor  : public juce::AudioProcessorEditor
                                 , private juce::Timer
{
private:
    void timerCallback() override;
    int lastDisplayedProgram = -1;
};
Test2AudioProcessorEditor::Test2AudioProcessorEditor (Test2AudioProcessor& p)
    : AudioProcessorEditor (&p), audioProcessor (p)
{
    setSize (400, 300);
    startTimerHz (30);
}

void Test2AudioProcessorEditor::timerCallback()
{
    const auto newProgram = audioProcessor.getLastHostProgramChange();

    if (newProgram != lastDisplayedProgram)
    {
        lastDisplayedProgram = newProgram;
        repaint();
    }
}
void Test2AudioProcessorEditor::paint (juce::Graphics& g)
{
    g.fillAll (juce::Colours::black);

    g.setColour (juce::Colours::darkgrey);
    g.setFont (juce::FontOptions (14.0f));
    g.drawFittedText ("Host Program Change (VST3)",
                      getLocalBounds().removeFromTop (44),
                      juce::Justification::centred,
                      1);

    const auto currentProgram = audioProcessor.getLastHostProgramChange() + 1;

    g.setColour (juce::Colours::white);
    g.setFont (juce::FontOptions (48.0f));
    g.drawFittedText ("Program " + juce::String (currentProgram),
                      getLocalBounds().reduced (16),
                      juce::Justification::centred,
                      1);
}

Questions

  1. Is this pattern for host program display known to be problematic with Ableton Live VST3?
  2. Is there a known safer JUCE approach for this in large sessions?
  3. Could this be related to program-count handling or host lifecycle around setCurrentProgram?

If needed, I can share full crash logs and a minimal repro project.

No, I’ve not heard of this problem before, and I don’t see any obvious issues from reviewing the code snippets.

I would expect the code you’ve posted to work without issues.

My guess would be that this is a bug in Live or the JUCE wrapper, unless I’ve overlooked something in the code examples you posted.

Thanks, in order to debug the issue it would be helpful to have a way of triggering the problem. For this, we’d need both a full code example, and instructions for setting up a Live session to trigger the problem.

You could also try running Live under a debugger. You can either launch Live directly under the debugger, or launch Live normally and then attach the debugger afterwards. Then, when you trigger the issue, the debugger should allow you to inspect the state of each thread, which might give you more information about where the problem is coming from.