Dynamic Slider linking

Hi everyone!

I’m working on a plugin with separated L/R controls and a Link toggle. I researched how other products handle this and I ended up with the following Host-Client design. I wanted to share my implementation and see if I missed any hidden (or trivial) detail, especially regarding DAW automations, threading and JUCE’s attachment system.

Desired behaviour

  1. Unlinked
    1. sliderL controls paramL and sliderR controls paramR.
    2. Automations are independent.
  2. Linked
    1. paramL is used by both channels in the processor/DSP. paramR is ignored.
    2. Both sliders stay in sync. Moving either knobs updates the other and updates paramL.
    3. Automating paramL moves both knobs, automating paramR has no visual effect.
  3. State change
    1. Unlinked to linked → sliderR snaps to current value of sliderL
    2. Linked to unlinked → sliderR snaps to paramR’s value.

Strategy

Manage the lifecycle of attachmentR (that links sliderR to paramR) according to the link toggle state. This to avoid slider-update feedback loops and conflicting automations.

Code

// Editor.h 
juce::Slider m_sliderL, m_sliderR;
std::unique_ptr <juce::AudioProcessorValueTreeState::SliderAttachment> m_attachmentL, m_attachmentR;
juce::ToggleButton m_linkToggle; 

void updateLinkedSliders(juce::Slider& host, juce::Slider& client, bool sendNotification)
{
    if(m_linkToggle.getToggleState())
    {
        client.setValue(host.getValue(), sendNotification ? juce::sendNotification : juce::dontSendNotification);
    }
}

// In Editor's constructor
m_attachmentL = std::make_unique <juce::AudioProcessorValueTreeState::SliderAttachment>(m_audioProcessor.m_apvts, "ParamL", m_sliderL);
m_attachmentR = std::make_unique <juce::AudioProcessorValueTreeState::SliderAttachment>(m_audioProcessor.m_apvts, "ParamR", m_sliderR);

m_sliderL.onValueChange = [this] { updateLinkedSliders(m_sliderL, m_sliderR, false); };
m_sliderR.onValueChange = [this] { updateLinkedSliders(m_sliderR, m_sliderL, true); };

m_linkToggle.onStateChange = [this] 
{ 
    if(m_linkToggle.getToggleState()) 
    { 
        m_attachmentR.reset();
        updateLinkedSliders(m_sliderL, m_sliderR, false);
    } 
    else
    {
        m_attachmentR = std::make_unique <juce::AudioProcessorValueTreeState::SliderAttachment>(m_audioProcessor.m_apvts, "ParamR", m_sliderR);
    }
};

Logic

  • When link is active, m_attachmentR is destroyed. This prevents the DAW from trying to move m_sliderR while it’s supposed to be following m_sliderL.
  • Notification when linked:
    • L → R: No notification is sent when updating m_sliderR’s value.
      • ParamR is ignored and the m_attachmentR is destroyed anyway.
      • This allows to break the infinite onValueChange ping-pong loop between the 2 sliders.
    • R → L: Notification is sent when updating the m_sliderL’s value.
      • This allows to update ParamL regardless of which knob was dragged.
  • Automation: Since m_attachmentL stays active, DAW automation on ParamL triggers m_sliderL.onValueChange, which in turn updates m_sliderR visually.

Known side effects

The only downside I found is that m_sliderR is set twice (with the same value) when the user drags it (once by the mouse, once by the L→ R callback). However, while testing, this proved to be robust and I didn’t notice any lag/jitter or odd behaviour.

I’m not entirely sure about creating and destroying m_attachmentR each time the toggle changes state, but there seem to be no other way of dynamically linking/unlinking a slider from an attachment.

Is there a better approach for dealing with this? Am I missing some issue that this would cause?

Any other feedback?

Thanks!

Instead of manually linking the sliders together, you could just replace sliderR’s attachment so it either links to paramL or paramR, depending on the link toggle. Much cleaner solution for sure, and you don’t have the slider values set twice and that kind of stuff. The right slider then simply shows and controls the same parameter as the left one.

Oh wow, so basically just

    m_linkToggle.onStateChange = [this] 
    { 
        m_attachmentR.reset();

        if (m_linkToggle.getToggleState()) 
            m_attachmentR = std::make_unique <juce::AudioProcessorValueTreeState::SliderAttachment>(m_audioProcessor.m_apvts, "ParamL", m_sliderR);
        else
            m_attachmentR = std::make_unique <juce::AudioProcessorValueTreeState::SliderAttachment>(m_audioProcessor.m_apvts, "ParamR", m_sliderR);
    };

That’s easier than I thought :sweat_smile: For some reason I assumed only one attachment could be attached to a parameter.

Thank you very much!

You can even skip the reset() as it’s redundant. Replacing the attachment will delete the old one anyway.

If I remove the explicit reset() there is a brief moment where both the old and new attachments coexists though, right? Since the old one is destroyed after the assignment of the new one.

It’s probably nothing, but I guess it could result in some glitch when turning on and off the link.

Or, rather than a glitch, ParamR would be set to the value of ParamL when the link is turned on before the detachment.

Just tested, that’s indeed what happens without the reset()!

Hmm, ok that seems strange. Normally an attachment shouldn’t modify a parameter without any slider activity. Only the other way around (the slider is updated initially when creating the attachment), and that shouldn’t be a problem as it all happens on the Message thread, so the correct value would be set in the slider before it gets painted later. I would have expected that this might even be optimized away in a release build.

Normally an attachment shouldn’t modify a parameter without any slider activity.

There is Slider activity: When the new attachment is created, the m_sliderR jumps to the value currently set by ParamL because of the link, and before its deletion, the old attachment propagates the change to ParamR.