SPANDAN (स्पंदन)

Advanced Real-Time Polyphonic Synthesizer

By Prashant Singh & Parth Srivastava

Chapter 7: The Canvas of Modular Control – Dynamic JUCE Component Architecture, Scrollable Viewports, Reordering, and Collapsible UI Panels

Executive Overview

In Chapters 1 through 5 of the SPANDAN (स्पंदन) Cookbook, we designed and implemented the core digital signal processing (DSP) engine: Numerically Controlled Oscillators (NCOs), Topology-Preserving Transform (TPT) State Variable Filters, exponential ADSR envelopes, FM synthesis networks, and lock-free Radix-2 FFT spectral visualizers.

However, as a synthesizer expands to include multiple oscillators, filter banks, envelope generators, LFOs, modulation matrices, and audio effects (such as saturation, delay, and reverb), a fixed, static graphical user interface (GUI) becomes unusable. Static UI layouts force controls into cramped windows or hidden menus, destroying workflow usability and limiting modular flexibility.

To deliver an intuitive, user-friendly interface that rivals commercial synthesizers while maintaining high performance on baseline hardware, SPANDAN implements a Dynamic JUCE Component Architecture.

This chapter explores the C++ architecture and JUCE framework mechanisms required to build a dynamic, scrollable, and modular synth UI:

  1. Scrollable Viewports (juce::Viewport): Hosting a dynamic canvas component that expands vertically beyond window boundaries without pixel distortion or fixed aspect ratio restrictions.
  2. Dynamic Component Allocation: Constructing, registering, and destroying UI panels dynamically at runtime using smart pointers (std::unique_ptr) and RAII principles.
  3. Thread-Safe Parameter Binding: Connecting dynamic UI controls (rotary sliders, combo boxes) to JUCE’s AudioProcessorValueTreeState (APVTS) via thread-safe attachments.
  4. Dynamic Component Reordering: Implementing a modular rack system where synth modules can be visually reordered, swapped, or moved up/down in the signal/UI pipeline.
  5. Collapsible UI Panels (Minimize & Expand): Toggling UI modules between expanded and minimized states, dynamically recalculating child component heights and triggering Viewport canvas layout updates.

📋 Table of Contents

  1. JUCE GUI Architecture: PluginEditor, Viewport, and Component Hierarchy
  2. Scrollable Viewports (juce::Viewport) & Canvas Management
  3. Dynamic Component Creation & Lifetime Management with Smart Pointers
  4. APVTS Thread-Safe Parameter Attachments in Dynamic Components
  5. Dynamic Component Re-Ordering Mechanics
  6. Collapsible Panels: Minimizing & Expanding Component Heights
  7. Full C++ Implementation: ModulePanel.h, ModuleContainer.h, and PluginEditor.cpp
  8. Cyber-Jungle Theme Integration (LookAndFeel_V4)
  9. Academic Viva Defense & University Panel Q&A

1. JUCE GUI Architecture: PluginEditor, Viewport, and Component Hierarchy

In the JUCE framework, the audio processing logic and the graphical user interface are strictly separated into two distinct C++ classes:

+-----------------------------------------------------------------------------------+
|                            SpandanAudioProcessorEditor                            |
|  +-----------------------------------------------------------------------------+  |
|  | Plugin Header (Title, Subtitle, Credits, Master Output Level)                |  |
|  +-----------------------------------------------------------------------------+  |
|  | juce::Viewport (Scrollable Window Container)                                 |  |
|  |  +-----------------------------------------------------------------------+  |  |
|  |  | ModuleContainerComponent (Canvas Component owned by Viewport)         |  |  |
|  |  |  +-----------------------------------------------------------------+  |  |  |
|  |  |  | ModulePanel 1: OSC 1 (Expanded: 180px)                         |  |  |  |
|  |  |  +-----------------------------------------------------------------+  |  |  |
|  |  |  | ModulePanel 2: OSC 2 (Minimized: 35px)                          |  |  |  |
|  |  |  +-----------------------------------------------------------------+  |  |  |
|  |  |  | ModulePanel 3: SVF Filter (Expanded: 180px)                   |  |  |  |
|  |  |  +-----------------------------------------------------------------+  |  |  |
|  |  |  | ModulePanel 4: ADSR Envelope (Expanded: 160px)                |  |  |  |
|  |  |  +-----------------------------------------------------------------+  |  |  |
|  |  |  | ... Additional Dynamic Modules (LFO, FX, Visualizers)          |  |  |  |
|  |  |  +-----------------------------------------------------------------+  |  |  |
|  |  +-----------------------------------------------------------------------+  |  |
|  +-----------------------------------------------------------------------------+  |
|  | On-Screen MIDI Keyboard Component                                           |  |
+-----------------------------------------------------------------------------------+

Component Hierarchy & Parent-Child Relationships

Every visual element in JUCE inherits from juce::Component. Components form a tree hierarchy where parent components own, position, and clip child components:

  1. Top-Level Window (SpandanAudioProcessorEditor): Contains persistent top elements (header title, master output volume, global preset selectors) and bottom elements (MIDI keyboard).
  2. Scroll Container (juce::Viewport): Placed in the center of the main editor window.
  3. Canvas Component (ModuleContainerComponent): Placed inside the Viewport via setViewedComponent(&container, false). Its height expands dynamically as modules are added, expanded, or reordered.
  4. Child Panels (ModulePanel): Represent individual synth building blocks (Oscillators, Filters, Envelopes, Effects, Oscilloscope).

2. Scrollable Viewports (juce::Viewport) & Canvas Management

When building complex VST plugins, fixed window dimensions lead to squeezed controls or multi-tab menus that hide controls from sound designers. JUCE provides juce::Viewport as a scrollable viewport container that handles clipping, scrollbar rendering, and mouse wheel navigation.

       JUCE Viewport Window (Visible Clipping Region)
       +--------------------------------------------+  ▲ Scroll Up
       | Module 1: Oscillator 1                     |  |
       | Module 2: Oscillator 2 (Minimized)         |  |
       | Module 3: State Variable Filter            |  |
       +--------------------------------------------+  ▼ Scroll Down
       : Module 4: ADSR Envelope (Clipped)          :
       : Module 5: Real-Time FFT Analyzer (Clipped) :
       +............................................+
       Total Canvas (ModuleContainerComponent Bounds)

Key Viewport Methods & Mechanics

To configure a juce::Viewport inside JUCE:

viewport.setViewedComponent(&moduleContainer, false); // false = viewport does NOT take ownership deletion
viewport.setScrollBarsShown(true, false);              // Vertical scrollbar ON, Horizontal OFF
viewport.setSingleStepSizes(0, 15);                    // Mouse-wheel scroll step speed

3. Dynamic Component Creation & Lifetime Management with Smart Pointers

Static C++ class members (e.g. OscillatorPanel osc1Panel;) require hardcoded UI layouts that cannot be instantiated on-the-fly or reordered dynamically.

SPANDAN utilizes modern C++ smart pointers (std::unique_ptr) and RAII (Resource Acquisition Is Initialization) to manage dynamic UI components safely:

std::vector<std::unique_ptr<ModulePanel>> modules;
                        Memory Management Architecture

    ModuleContainerComponent (Owner)
               │
               ├──> std::vector<std::unique_ptr<ModulePanel>>
               │          │
               │          ├──> ModulePanel 1 [OSC 1] (Heap Allocated)
               │          ├──> ModulePanel 2 [Filter] (Heap Allocated)
               │          └──> ModulePanel 3 [ADSR] (Heap Allocated)
               │
               └──> addAndMakeVisible(*module)
                          │
                          └──> Registered with JUCE Component Tree

Dynamic Insertion & Deletion Flow

When a user adds a new module or resets the synth rack:

  1. Instantiation: auto newModule = std::make_unique<ModulePanel>(moduleType, apvts); allocates the panel on the heap.
  2. Registration: addAndMakeVisible(*newModule); informs JUCE that the child component should be rendered and receive input events.
  3. Storage: modules.push_back(std::move(newModule)); transfers exclusive ownership to the container vector.
  4. Destruction & Safety: When a module is removed (modules.erase(...)), std::unique_ptr automatically frees heap memory, while JUCE’s parent component automatically unregisters child pointers, eliminating dangling references.

4. APVTS Thread-Safe Parameter Attachments in Dynamic Components

Connecting UI controls (rotary sliders, combo boxes) to background DSP parameters requires absolute thread safety. JUCE provides juce::AudioProcessorValueTreeState (APVTS) to manage parameter states between the audio thread and the GUI thread.

   GUI Thread (PluginEditor)                    Audio Thread (PluginProcessor)
  +--------------------------+                 +-----------------------------+
  |  juce::Slider Control    |                 |  Real-Time DSP Loop         |
  +-------------┬------------+                 +--------------▲--------------+
                │                                             │
                ▼                                             │
  +--------------------------+                 +--------------┴--------------+
  | APVTS::SliderAttachment  | ──(Lock-Free)──►| APVTS Parameter Memory      |
  | (RAII Managed Pointer)   |                 | (Atomic Float / Lock-Free)  |
  +--------------------------+                 +-----------------------------+

Managing Attachments in Dynamic Panels

When UI components are created dynamically at runtime, their parameter attachments must be created dynamically and tied strictly to the lifetime of the component:

class ModulePanel : public juce::Component
{
public:
    ModulePanel(const juce::String& panelName, 
                juce::AudioProcessorValueTreeState& apvts,
                const juce::String& paramID,
                juce::Slider::SliderStyle style)
    {
        slider.setSliderStyle(style);
        slider.setTextBoxStyle(juce::Slider::TextBoxBelow, false, 60, 20);
        addAndMakeVisible(slider);

        // Bind attachment dynamically
        attachment = std::make_unique<juce::AudioProcessorValueTreeState::SliderAttachment>(
            apvts, paramID, slider);
    }

private:
    juce::Slider slider;
    std::unique_ptr<juce::AudioProcessorValueTreeState::SliderAttachment> attachment;
};

When ModulePanel is destroyed, attachment is destroyed first, safely detaching the UI control from APVTS before the slider itself is deallocated, preventing race conditions or null pointer dereferences during runtime UI updates.


5. Dynamic Component Re-Ordering Mechanics

A key feature of modern software synthesizers is modular reordering—allowing users to swap module positions (e.g. moving Oscillator 2 above Oscillator 1, or re-arranging effect chains).

                     Component Re-Ordering Operation

   Initial Order Vector:               Move Module 2 Up:
   +-----------------------+           +-----------------------+
   | Index 0: OSC 1        |           | Index 0: Filter       |  ▲ Moved Up
   | Index 1: Filter       | ───────►  | Index 1: OSC 1        |  ▼ Shifted Down
   | Index 2: ADSR         |           | Index 2: ADSR         |
   +-----------------------+           +-----------------------+

Algorithmic Re-Ordering Steps

  1. Vector Swap: Swap smart pointers inside std::vector<std::unique_ptr<ModulePanel>>:
    std::swap(modules[indexA], modules[indexB]);
    
  2. Z-Order Adjustment: Call toFront(false) on child components in their new sequence to ensure proper focus and rendering order:
    for (auto& module : modules)
        module->toFront(false);
    
  3. Layout Recalculation: Trigger resized() on ModuleContainerComponent to update bounding boxes and vertical $y$-positions.

6. Collapsible Panels: Minimizing & Expanding Component Heights

To optimize visual space in a scrollable Viewport, each ModulePanel includes a Header Bar with a minimize/expand toggle button.

  Expanded Module State (Height = 180px)
  +-----------------------------------------------------------+ [ − ] Minimize
  | [≡] OSCILLATOR 1 PANEL                                    | (Header: 35px)
  +-----------------------------------------------------------+
  | Waveform: [ Sawtooth ▼ ]   Detune: ( ◯ )   Octave: [ 0 ▼ ]| (Body: 145px)
  | Cutoff:   ( ◯ )           Mix:    ( ◯ )   Phase:  ( ◯ )  |
  +-----------------------------------------------------------+

  Minimized / Collapsed Module State (Height = 35px)
  +-----------------------------------------------------------+ [ + ] Expand
  | [≡] OSCILLATOR 1 PANEL                                    | (Header: 35px)
  +-----------------------------------------------------------+ (Body Hidden)

Height Calculation Math

Each ModulePanel tracks its collapsed state using a boolean flag isCollapsed:

\[H_{\text{module}} = \begin{cases} H_{\text{header}} & \text{if } \text{isCollapsed} = \text{true} \\ H_{\text{header}} + H_{\text{body}} & \text{if } \text{isCollapsed} = \text{false} \end{cases}\]

For standard SPANDAN modules:

Canvas Recalculation Loop

When a user clicks the collapse button on module $i$:

  1. modules[i]->setCollapsed(!modules[i]->isCollapsed());
  2. Module $i$ hides/shows its body content (bodyContainer.setVisible(!isCollapsed)).
  3. The parent ModuleContainerComponent executes its layout loop:
\[Y_0 = 0\] \[Y_k = Y_{k-1} + H_{\text{module}, k-1} + \text{Padding} \quad \text{for } k = 1, 2, \dots, M-1\] \[H_{\text{total}} = Y_{M-1} + H_{\text{module}, M-1} + \text{Padding}\]
  1. The container updates its bounds: setSize(containerWidth, H_total).
  2. juce::Viewport automatically adjusts its scrollbars to reflect $H_{\text{total}}$ [146].

7. Full C++ Implementation: ModulePanel.h, ModuleContainer.h, and PluginEditor.cpp

Here is the complete, production-ready C++ implementation for SPANDAN’s dynamic UI layout architecture.

Header: ModulePanel.h

#pragma once
#include <JuceHeader.h>

class ModulePanel : public juce::Component
{
public:
    using LayoutChangedCallback = std::function<void()>;
    using OrderChangedCallback = std::function<void(ModulePanel*, bool)>; // true = move up, false = move down

    ModulePanel(const juce::String& titleText, juce::Colour headerColour)
        : title(titleText), accentColour(headerColour)
    {
        // Header Setup
        headerBar.onClick = [this] { toggleCollapse(); };
        addAndMakeVisible(headerBar);

        titleLabel.setText(titleText, juce::dontSendNotification);
        titleLabel.setFont(juce::FontOptions(16.0f, juce::Font::bold));
        titleLabel.setColour(juce::Label::textColourId, juce::Colours::white);
        addAndMakeVisible(titleLabel);

        toggleButton.setButtonText("-");
        toggleButton.onClick = [this] { toggleCollapse(); };
        addAndMakeVisible(toggleButton);

        moveUpButton.setButtonText("▲");
        moveUpButton.onClick = [this] { if (onOrderChanged) onOrderChanged(this, true); };
        addAndMakeVisible(moveUpButton);

        moveDownButton.setButtonText("▼");
        moveDownButton.onClick = [this] { if (onOrderChanged) onOrderChanged(this, false); };
        addAndMakeVisible(moveDownButton);

        // Body Container Setup
        addAndMakeVisible(bodyContainer);
    }

    ~ModulePanel() override = default;

    void setOnLayoutChanged(LayoutChangedCallback callback) { onLayoutChanged = callback; }
    void setOnOrderChanged(OrderChangedCallback callback)   { onOrderChanged = callback; }

    juce::Component& getBody() { return bodyContainer; }

    bool getIsCollapsed() const { return isCollapsed; }

    int getPreferredHeight() const
    {
        return isCollapsed ? headerHeight : (headerHeight + bodyHeight);
    }

    void setBodyHeight(int newBodyHeight)
    {
        bodyHeight = newBodyHeight;
        resized();
    }

    void toggleCollapse()
    {
        isCollapsed = !isCollapsed;
        toggleButton.setButtonText(isCollapsed ? "+" : "-");
        bodyContainer.setVisible(!isCollapsed);

        if (onLayoutChanged)
            onLayoutChanged();
    }

    void paint(juce::Graphics& g) override
    {
        // Draw Header Background
        g.setColour(accentColour);
        g.fillRoundedRectangle(getLocalBounds().removeFromTop(headerHeight).toFloat(), 4.0f);

        // Outer Border
        g.setColour(juce::Colours::darkgrey);
        g.drawRoundedRectangle(getLocalBounds().toFloat(), 4.0f, 1.0f);
    }

    void resized() override
    {
        auto bounds = getLocalBounds();
        auto header = bounds.removeFromTop(headerHeight);

        // Header controls layout
        moveDownButton.setBounds(header.removeFromRight(25).reduced(2));
        moveUpButton.setBounds(header.removeFromRight(25).reduced(2));
        toggleButton.setBounds(header.removeFromRight(30).reduced(2));
        titleLabel.setBounds(header.reduced(6, 2));

        // Body layout
        if (!isCollapsed)
        {
            bodyContainer.setBounds(bounds.reduced(4));
        }
    }

private:
    juce::String title;
    juce::Colour accentColour;
    bool isCollapsed { false };

    int headerHeight { 35 };
    int bodyHeight   { 145 };

    juce::Button headerBar;
    juce::Label titleLabel;
    juce::TextButton toggleButton;
    juce::TextButton moveUpButton;
    juce::TextButton moveDownButton;

    juce::Component bodyContainer;

    LayoutChangedCallback onLayoutChanged;
    OrderChangedCallback  onOrderChanged;

    JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(ModulePanel)
};

Header: ModuleContainerComponent.h

#pragma once
#include <JuceHeader.h>
#include "ModulePanel.h"

class ModuleContainerComponent : public juce::Component
{
public:
    using LayoutUpdatedCallback = std::function<void()>;

    ModuleContainerComponent() = default;
    ~ModuleContainerComponent() override = default;

    void setOnLayoutUpdated(LayoutUpdatedCallback callback)
    {
        onLayoutUpdated = callback;
    }

    void addModule(std::unique_ptr<ModulePanel> module)
    {
        auto* ptr = module.get();
        
        ptr->setOnLayoutChanged([this] { updateLayout(); });
        ptr->setOnOrderChanged([this](ModulePanel* m, bool up) { moveModule(m, up); });

        addAndMakeVisible(*ptr);
        modules.push_back(std::move(module));

        updateLayout();
    }

    void moveModule(ModulePanel* module, bool moveUp)
    {
        auto it = std::find_if(modules.begin(), modules.end(),
                               [module](const std::unique_ptr<ModulePanel>& p) { return p.get() == module; });

        if (it == modules.end()) return;

        size_t index = std::distance(modules.begin(), it);

        if (moveUp && index > 0)
        {
            std::swap(modules[index], modules[index - 1]);
        }
        else if (!moveUp && index + 1 < modules.size())
        {
            std::swap(modules[index], modules[index + 1]);
        }

        updateLayout();
    }

    void updateLayout()
    {
        int y = padding;
        int width = getWidth() > 0 ? getWidth() - (padding * 2) : 740;

        for (auto& module : modules)
        {
            int moduleHeight = module->getPreferredHeight();
            module->setBounds(padding, y, width, moduleHeight);
            y += moduleHeight + padding;
        }

        setSize(getWidth(), y);

        if (onLayoutUpdated)
            onLayoutUpdated();
    }

    void resized() override
    {
        updateLayout();
    }

private:
    std::vector<std::unique_ptr<ModulePanel>> modules;
    int padding { 8 };
    LayoutUpdatedCallback onLayoutUpdated;

    JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(ModuleContainerComponent)
};

Source: PluginEditor.cpp (Integration with juce::Viewport)

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

SpandanAudioProcessorEditor::SpandanAudioProcessorEditor(SpandanAudioProcessor& p)
    : AudioProcessorEditor(&p), audioProcessor(p)
{
    // Configure Viewport
    viewport.setViewedComponent(&moduleContainer, false);
    viewport.setScrollBarsShown(true, false);
    viewport.setSingleStepSizes(0, 15);
    addAndMakeVisible(viewport);

    // Whenever container resizes, trigger viewport refresh
    moduleContainer.setOnLayoutUpdated([this] {
        viewport.setViewedComponentRectangle(moduleContainer.getBounds());
    });

    // Populate Dynamic Modules
    auto osc1Panel = std::make_unique<ModulePanel>("OSCILLATOR 1", juce::Colour(0xFF1E5631));
    // Add OSC1 Sliders & Attachments into osc1Panel->getBody() here...
    moduleContainer.addModule(std::move(osc1Panel));

    auto osc2Panel = std::make_unique<ModulePanel>("OSCILLATOR 2", juce::Colour(0xFF1E5631));
    moduleContainer.addModule(std::move(osc2Panel));

    auto filterPanel = std::make_unique<ModulePanel>("STATE VARIABLE FILTER", juce::Colour(0xFF2E8B57));
    moduleContainer.addModule(std::move(filterPanel));

    auto adsrPanel = std::make_unique<ModulePanel>("ADSR ENVELOPE", juce::Colour(0xFFB8860B));
    moduleContainer.addModule(std::move(adsrPanel));

    setSize(800, 600);
}

void SpandanAudioProcessorEditor::resized()
{
    auto bounds = getLocalBounds();
    
    // Top Title Bar
    auto headerBounds = bounds.removeFromTop(50);
    
    // Bottom MIDI Keyboard
    auto keyboardBounds = bounds.removeFromBottom(100);

    // Scrollable Center Viewport
    viewport.setBounds(bounds);
    moduleContainer.setSize(viewport.getMaximumVisibleWidth(), moduleContainer.getHeight());
}

8. Cyber-Jungle Theme Integration (LookAndFeel_V4)

To maintain visual cohesion across dynamic UI components, SPANDAN applies the custom Cyber-Jungle design theme specification:

Element Color Name Hex Code JUCE LookAndFeel Mapping
Canvas Background Off-white 0xFFF0FDF4 ResizableWindow::backgroundColourId
Primary Headers Jungle Green 0xFF2E8B57 HeaderBar / Accent Colors
Highlights & Borders Dark Gold 0xFFB8860B Slider::rotarySliderFillColourId
Text & Outlines Earthy Brown 0xFF8B4513 Label::textColourId
Secondary Accents Deep Forest Green 0xFF1E5631 GroupComponent::outlineColourId
class CyberJungleLookAndFeel : public juce::LookAndFeel_V4
{
public:
    CyberJungleLookAndFeel()
    {
        setColour(juce::ResizableWindow::backgroundColourId, juce::Colour(0xFFF0FDF4));
        setColour(juce::Slider::thumbColourId,               juce::Colour(0xFFB8860B));
        setColour(juce::Slider::rotarySliderFillColourId,     juce::Colour(0xFF2E8B57));
        setColour(juce::Label::textColourId,                  juce::Colour(0xFF8B4513));
    }

    void drawRotarySlider(juce::Graphics& g, int x, int y, int width, int height,
                          float sliderPos, float rotaryStartAngle, float rotaryEndAngle,
                          juce::Slider& slider) override
    {
        auto bounds = juce::Rectangle<int>(x, y, width, height).toFloat().reduced(4.0f);
        auto radius = juce::jmin(bounds.getWidth(), bounds.getHeight()) / 2.0f;
        auto center = bounds.getCentre();
        auto angle  = rotaryStartAngle + sliderPos * (rotaryEndAngle - rotaryStartAngle);

        // Outer Cyber Ring
        g.setColour(juce::Colour(0xFF2E8B57).withAlpha(0.3f));
        g.drawEllipse(bounds, 3.0f);

        // Metallic Mesh Cap
        g.setColour(juce::Colour(0xFF1E5631));
        g.fillEllipse(center.x - radius * 0.7f, center.y - radius * 0.7f, radius * 1.4f, radius * 1.4f);

        // Glowing Gold Pointer Indicator
        juce::Point<float> thumbPoint(
            center.x + radius * 0.75f * std::sin(angle),
            center.y - radius * 0.75f * std::cos(angle));

        g.setColour(juce::Colour(0xFFB8860B));
        g.fillEllipse(thumbPoint.x - 4.0f, thumbPoint.y - 4.0f, 8.0f, 8.0f);
    }
};
Stay Updated
💻 GitHub Repo 🔗 Prashant's LinkedIn 🔗 Parth's LinkedIn