ComponentBoundsConstrainer + VST3 Resizing Issues

gui

#1

I’ve been working on new plugin where the ability to resize is quite significant. The desired behavior is to drag the bottom right corner to snap to several different predefined sizes.

To do this I’m using a custom ComponentBoundsConstrainer within my PluginEditor. In AU & VST this works fine. However the VST3 builds seem to cause glitchy issues in certain hosts. I’ve tested on a fair amount of hosts and these are the ones that seem to exhibit the issue:

macOS:
• Cubase Pro 9: Constrainer doesn’t snap. Resizing occurs smoothly.
• WaveLab Pro 9: Hard to describe. A lot of the drawing bounds get messed up.
• Studio One: Flickering/glitching when resizing.
• Bitwig: Constrainer doesn’t snap. Resizing occurs smoothly.

Windows:
• Studio One
• Cubase Elements 9 (didn’t have the pro version to test)

Note: the VST2 equivalents in these hosts work fine!

Here is an example of how I’m customizing the ComponentBoundsConstrainer. This one snaps between two sizes:

class Constrainer : public ComponentBoundsConstrainer
{
public:
    void checkBounds (Rectangle<int>& bounds,
        const Rectangle<int>& previousBounds,
        const Rectangle<int>& limits,
        bool isStretchingTop,
        bool isStretchingLeft,
        bool isStretchingBottom,
        bool isStretchingRight) override
    {
        if (bounds.getHeight() > 350)
        {
            bounds.setHeight (600);
            bounds.setWidth (400);
        }
        else
        {
            bounds.setHeight (200);
            bounds.setWidth (200);
        }
    }
};

To reproduce the issue:

  1. Pull the latest changes in the JUCE develop branch
  2. In the JuceDemoPlugin project replace PluginEditor.h with the following code (use git diff to see the difference):
/*
  ==============================================================================

   This file is part of the JUCE library.
   Copyright (c) 2017 - ROLI Ltd.

   JUCE is an open source library subject to commercial or open-source
   licensing.

   By using JUCE, you agree to the terms of both the JUCE 5 End-User License
   Agreement and JUCE 5 Privacy Policy (both updated and effective as of the
   27th April 2017).

   End User License Agreement: www.juce.com/juce-5-licence
   Privacy Policy: www.juce.com/juce-5-privacy-policy

   Or: You may also use this code under the terms of the GPL v3 (see
   www.gnu.org/licenses).

   JUCE IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER
   EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE
   DISCLAIMED.

  ==============================================================================
*/

#pragma once

#include "../JuceLibraryCode/JuceHeader.h"
#include "PluginProcessor.h"

class Constrainer : public ComponentBoundsConstrainer
{
public:
    void checkBounds (Rectangle<int>& bounds,
        const Rectangle<int>& previousBounds,
        const Rectangle<int>& limits,
        bool isStretchingTop,
        bool isStretchingLeft,
        bool isStretchingBottom,
        bool isStretchingRight) override
    {
        if (bounds.getHeight() > 350)
        {
            bounds.setHeight (600);
            bounds.setWidth (400);
        }
        else
        {
            bounds.setHeight (200);
            bounds.setWidth (200);
        }
    }
};

//==============================================================================
/** This is the editor component that our filter will display.
*/
class JuceDemoPluginAudioProcessorEditor  : public AudioProcessorEditor,
                                            private Timer
{
public:
    JuceDemoPluginAudioProcessorEditor (JuceDemoPluginAudioProcessor&);
    ~JuceDemoPluginAudioProcessorEditor();

    //==============================================================================
    void paint (Graphics&) override;
    void resized() override;
    void timerCallback() override;
    void hostMIDIControllerIsAvailable (bool) override;
    void updateTrackProperties();

private:
    class ParameterSlider;

    MidiKeyboardComponent midiKeyboard;
    Label timecodeDisplayLabel, gainLabel, delayLabel;
    ScopedPointer<ParameterSlider> gainSlider, delaySlider;
    Colour backgroundColour;

    Constrainer constrain;

    //==============================================================================
    JuceDemoPluginAudioProcessor& getProcessor() const
    {
        return static_cast<JuceDemoPluginAudioProcessor&> (processor);
    }

    void updateTimecodeDisplay (AudioPlayHead::CurrentPositionInfo);
};
  1. Replace PluginEditor.cpp with the following code:
/*
  ==============================================================================

   This file is part of the JUCE library.
   Copyright (c) 2017 - ROLI Ltd.

   JUCE is an open source library subject to commercial or open-source
   licensing.

   By using JUCE, you agree to the terms of both the JUCE 5 End-User License
   Agreement and JUCE 5 Privacy Policy (both updated and effective as of the
   27th April 2017).

   End User License Agreement: www.juce.com/juce-5-licence
   Privacy Policy: www.juce.com/juce-5-privacy-policy

   Or: You may also use this code under the terms of the GPL v3 (see
   www.gnu.org/licenses).

   JUCE IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER
   EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE
   DISCLAIMED.

  ==============================================================================
*/

#include "PluginProcessor.h"
#include "PluginEditor.h"

//==============================================================================
// This is a handy slider subclass that controls an AudioProcessorParameter
// (may move this class into the library itself at some point in the future..)
class JuceDemoPluginAudioProcessorEditor::ParameterSlider   : public Slider,
                                                              private Timer
{
public:
    ParameterSlider (AudioProcessorParameter& p)
        : Slider (p.getName (256)), param (p)
    {
        setRange (0.0, 1.0, 0.0);
        startTimerHz (30);
        updateSliderPos();
    }

    void valueChanged() override        { param.setValueNotifyingHost ((float) Slider::getValue()); }

    void timerCallback() override       { updateSliderPos(); }

    void startedDragging() override     { param.beginChangeGesture(); }
    void stoppedDragging() override     { param.endChangeGesture();   }

    double getValueFromText (const String& text) override   { return param.getValueForText (text); }
    String getTextFromValue (double value) override         { return param.getText ((float) value, 1024); }

    void updateSliderPos()
    {
        const float newValue = param.getValue();

        if (newValue != (float) Slider::getValue() && ! isMouseButtonDown())
            Slider::setValue (newValue, NotificationType::dontSendNotification);
    }

    AudioProcessorParameter& param;

    JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (ParameterSlider)
};

//==============================================================================
JuceDemoPluginAudioProcessorEditor::JuceDemoPluginAudioProcessorEditor (JuceDemoPluginAudioProcessor& owner)
    : AudioProcessorEditor (owner),
      midiKeyboard (owner.keyboardState, MidiKeyboardComponent::horizontalKeyboard),
      timecodeDisplayLabel (String()),
      gainLabel (String(), "Throughput level:"),
      delayLabel (String(), "Delay:")
{
    // add some sliders..
    addAndMakeVisible (gainSlider = new ParameterSlider (*owner.gainParam));
    gainSlider->setSliderStyle (Slider::Rotary);

    addAndMakeVisible (delaySlider = new ParameterSlider (*owner.delayParam));
    delaySlider->setSliderStyle (Slider::Rotary);

    // add some labels for the sliders..
    gainLabel.attachToComponent (gainSlider, false);
    gainLabel.setFont (Font (11.0f));

    delayLabel.attachToComponent (delaySlider, false);
    delayLabel.setFont (Font (11.0f));

    // add the midi keyboard component..
    addAndMakeVisible (midiKeyboard);

    // add a label that will display the current timecode and status..
    addAndMakeVisible (timecodeDisplayLabel);
    timecodeDisplayLabel.setFont (Font (Font::getDefaultMonospacedFontName(), 15.0f, Font::plain));

    // set resize limits for this plug-in
//    setResizeLimits (400, 200, 1024, 700);
    setConstrainer (&constrain);
    setResizable (true, true);

    // set our component's initial size to be the last one that was stored in the filter's settings
    setSize (owner.lastUIWidth,
             owner.lastUIHeight);

    updateTrackProperties();

    // start a timer which will keep our timecode display updated
    startTimerHz (30);
}

JuceDemoPluginAudioProcessorEditor::~JuceDemoPluginAudioProcessorEditor()
{
}

//==============================================================================
void JuceDemoPluginAudioProcessorEditor::paint (Graphics& g)
{
    g.setColour (backgroundColour);
    g.fillAll();
}

void JuceDemoPluginAudioProcessorEditor::resized()
{
    // This lays out our child components...

    Rectangle<int> r (getLocalBounds().reduced (8));

    timecodeDisplayLabel.setBounds (r.removeFromTop (26));
    midiKeyboard.setBounds (r.removeFromBottom (70));

    r.removeFromTop (20);
    Rectangle<int> sliderArea (r.removeFromTop (60));
    gainSlider->setBounds (sliderArea.removeFromLeft (jmin (180, sliderArea.getWidth() / 2)));
    delaySlider->setBounds (sliderArea.removeFromLeft (jmin (180, sliderArea.getWidth())));

    getProcessor().lastUIWidth = getWidth();
    getProcessor().lastUIHeight = getHeight();
}

//==============================================================================
void JuceDemoPluginAudioProcessorEditor::timerCallback()
{
    updateTimecodeDisplay (getProcessor().lastPosInfo);
}

void JuceDemoPluginAudioProcessorEditor::hostMIDIControllerIsAvailable (bool controllerIsAvailable)
{
    midiKeyboard.setVisible (! controllerIsAvailable);
}

void JuceDemoPluginAudioProcessorEditor::updateTrackProperties ()
{
    auto trackColour = getProcessor().trackProperties.colour;
    auto& lf = getLookAndFeel();

    backgroundColour = (trackColour == Colour() ? lf.findColour (ResizableWindow::backgroundColourId)
                                                : trackColour.withAlpha (1.0f).withBrightness (0.266f));
    repaint();
}

//==============================================================================
// quick-and-dirty function to format a timecode string
static String timeToTimecodeString (double seconds)
{
    const int millisecs = roundToInt (seconds * 1000.0);
    const int absMillisecs = std::abs (millisecs);

    return String::formatted ("%02d:%02d:%02d.%03d",
                              millisecs / 3600000,
                              (absMillisecs / 60000) % 60,
                              (absMillisecs / 1000) % 60,
                              absMillisecs % 1000);
}

// quick-and-dirty function to format a bars/beats string
static String quarterNotePositionToBarsBeatsString (double quarterNotes, int numerator, int denominator)
{
    if (numerator == 0 || denominator == 0)
        return "1|1|000";

    const int quarterNotesPerBar = (numerator * 4 / denominator);
    const double beats  = (fmod (quarterNotes, quarterNotesPerBar) / quarterNotesPerBar) * numerator;

    const int bar    = ((int) quarterNotes) / quarterNotesPerBar + 1;
    const int beat   = ((int) beats) + 1;
    const int ticks  = ((int) (fmod (beats, 1.0) * 960.0 + 0.5));

    return String::formatted ("%d|%d|%03d", bar, beat, ticks);
}

// Updates the text in our position label.
void JuceDemoPluginAudioProcessorEditor::updateTimecodeDisplay (AudioPlayHead::CurrentPositionInfo pos)
{
    MemoryOutputStream displayText;

    displayText << "[" << SystemStats::getJUCEVersion() << "]   "
                << String (pos.bpm, 2) << " bpm, "
                << pos.timeSigNumerator << '/' << pos.timeSigDenominator
                << "  -  " << timeToTimecodeString (pos.timeInSeconds)
                << "  -  " << quarterNotePositionToBarsBeatsString (pos.ppqPosition,
                                                                    pos.timeSigNumerator,
                                                                    pos.timeSigDenominator);

    if (pos.isRecording)
        displayText << "  (recording)";
    else if (pos.isPlaying)
        displayText << "  (playing)";

    timecodeDisplayLabel.setText (displayText.toString(), dontSendNotification);
}
  1. Open the VST3 plugin in one of the above listed hosts. Drag the bottom right corner to snap between the two different sizes.

Many thanks! :smile:


#2

Thank you for your code. I can see that the VST3 backend only respects the minimum and maximum size of the constrainer. I have a fix for this but I want to give it a bit more testing before I push it. I’ll keep this post updated.


#3

Awesome!


#4

OK. I think this is now fixed on develop with commit 4bb58c7. Can you check if this works for you?


#5

It’s working much better now!

On macOS just testing these hosts are still causing issues (with the updated builds of the JuceDemoPlugin after pulling the new develop branch changes):
• WaveLab Pro 9
• Studio One

Let me know if you can reproduce the same issues. Perhaps I’m not implementing the constrainer correctly, but then again the VST2 equivalents all work fine.

Thanks!


#6

Hi @fabian, I just pulled the latest changes on develop. I’m still getting wonky issues with these two hosts. When you get a moment could you take a look? Many thanks!


#7

OK I had a look at the Studio One issue and it’s not really a bug.

Studio One does the following: it will check if the plug-in editor is resizable. It does this by asking the plug-in editor to resize to a really large size and asking the editor to re-size to a really small size. If the actual size of the editor is different in both settings, Studio One assumes the plug-in is resizable. In this mode, Studio One does not allow your plug-in to have a width smaller than 384 pixels. If you change your constraint in a way that the smaller size has a width of at least 384 pixels then everything will be fine. I don’t think we as JUCE developers can do anything about this behaviour.


#8

Hi @fabian, WaveLab 9 (which btw is the most finicky host in my experience) is still giving us issues.

We’ve abandoned the constrainer for now, since having a simple dropdown menu with available sizes provides a much smoother UX.

In WaveLab 9 using an explicit setSize causes a weird issue. In the PluginDemo project, adding the line
Timer::callAfterDelay(3000, [this]{ this->setSize (700, 800); });
to line 104 in the plugin editor constructor results in this:


The equivalent with VST2 build results in:


#9

OK Thanks. I’ll have a look.


#10

Sorry, my wavelab activation expired. I need to wait for an NFR version before I can get back on to this. I’ll let you know once I’ve fixed this.


#11

Hi @fabian any word on testing WaveLab? We’re getting a little too close to release for my comfort :wink:


#12

Yes, we are in contact with WaveLab and have narrowed down the bug. I’m just waiting for them to reply my last e-mail.


#13

Don’t want to be naggy, but any word on this? I still get the issues with the latest changes from the develop branch.


#14

Looks like this issue got resolved :smiley: Thanks for the help @fabian & the JUCE team!!


#15

We’ve been running into a seemingly similar issue with resizing VST3’s in Studio One.

We’re using getConstrainer()->setFixedAspectRatio() to set a fixed aspect ratio. Normally, this works well. However, if the user clicks extremely close to the edge of the right corner of the plugin, the plugin can be resized to any aspect ratio. It seems that in these cases, the corner resizer never gets a mouseDown. I’m not sure if this happens in other host — so far we’ve only seen it in Studio One.

We’re able to replicate this behavior in the JuceDemoPlugin by the adding getConstrainer()->setFixedAspectRatio(1.f); immediately after setResizeLimits (400, 200, 1024, 700);


#16

#17

Thanks very much for the quick response! We’re now seeing that the editor remains properly constrained but the plugin window is not necessarily:

Is that the expected behavior? Is there anything we can do to also constrain the window?


#18

We’re correctly reporting plug-in sizes in checkSizeConstraint, but Studio One is only calling that function to find the minimum and maximum sizes. For intermediate shapes I don’t see how Studio One would know what to do.

I also tried forcing a host frame resize when we move away from the fixed aspect ratio, but I couldn’t make anything work.