JUCE ValueTree & Serialization Patterns
ValueTree is the core data model in JUCE and Tracktion Engine. Every plugin, track, and edit stores its state as a ValueTree. This skill covers the patterns used throughout the MAGDA codebase.
ValueTree Basics
Creating and Populating
#include <juce_data_structures/juce_data_structures.h>
// Create a tree with an Identifier type
juce::ValueTree tree(juce::Identifier("MyPlugin"));
// Set properties
tree.setProperty("gain", 0.5f, nullptr); // no undo
tree.setProperty("name", "Lead", undoManager); // with undo
// Add child trees
juce::ValueTree child(juce::Identifier("CHAIN"));
child.setProperty("index", 0, nullptr);
tree.addChild(child, -1, nullptr); // -1 = append at end
Reading Properties
// Read with default fallback
float gain = tree.getProperty("gain", 1.0f);
juce::String name = tree.getProperty("name", "Default");
// Check existence
if (tree.hasProperty("gain")) { /* ... */ }
// Get typed child
juce::ValueTree chainTree = tree.getChildWithName("CHAIN");
if (chainTree.isValid()) {
int idx = chainTree.getProperty("index");
}
Hierarchy Navigation
// Parent access
juce::ValueTree parent = tree.getParent();
// Child iteration
for (int i = 0; i < tree.getNumChildren(); ++i) {
auto child = tree.getChild(i);
if (child.hasType("CHAIN")) {
// process chain child
}
}
// Range-based iteration (JUCE 7+)
for (auto child : tree) {
DBG(child.getType().toString());
}
// Find specific child by property
for (int i = 0; i < tree.getNumChildren(); ++i) {
auto child = tree.getChild(i);
if (child.hasType("CHAIN") && (int)child.getProperty("index") == 2)
return child;
}
Identifier Constants
Always define Identifiers as static constants to avoid repeated string hashing:
Header (.h):
class MyPlugin : public te::Plugin {
static const juce::Identifier gainId;
static const juce::Identifier chainId;
static const juce::Identifier muteId;
};
Source (.cpp):
const juce::Identifier MyPlugin::gainId("gain");
const juce::Identifier MyPlugin::chainId("CHAIN");
const juce::Identifier MyPlugin::muteId("mute");
CachedValue<T>
CachedValue<T> binds a C++ variable to a ValueTree property. It caches the value locally so reads are thread-safe (no locking), making it suitable for audio-thread reads.
Binding to a Property
class MyPlugin : public te::Plugin {
juce::CachedValue<float> level;
juce::CachedValue<float> pan;
juce::CachedValue<bool> mute;
juce::CachedValue<bool> solo;
MyPlugin(te::PluginCreationInfo info) : te::Plugin(info) {
// referTo(tree, propertyId, undoManager, defaultValue)
level.referTo(state, "level", nullptr, 1.0f);
pan.referTo(state, "pan", nullptr, 0.0f);
mute.referTo(state, "mute", nullptr, false);
solo.referTo(state, "solo", nullptr, false);
}
};
Reading and Writing
// Read — thread-safe, returns cached copy
float currentLevel = level.get();
// Can also use implicit conversion
float val = level;
// Write — updates the ValueTree property (triggers listeners)
level = 0.75f;
// Equivalent to:
state.setProperty("level", 0.75f, nullptr);
Force Update After State Restore
After restorePluginStateFromValueTree() copies new values into the state tree, the CachedValues may still hold stale data. You must call forceUpdateOfCachedValue():
void restorePluginStateFromValueTree(const juce::ValueTree& v) override {
// This copies matching properties from v into this->state
te::copyPropertiesToCachedValues(state, v, nullptr);
// Force each CachedValue to re-read from the tree
level.forceUpdateOfCachedValue();
pan.forceUpdateOfCachedValue();
mute.forceUpdateOfCachedValue();
solo.forceUpdateOfCachedValue();
}
Serialization Pattern in Tracktion Engine Plugins
Plugin state Member
Every te::Plugin has a state member which is a juce::ValueTree. This is the single source of truth for all plugin data.
Full Plugin Pattern
// Header
class MyPlugin : public te::Plugin {
public:
MyPlugin(te::PluginCreationInfo);
~MyPlugin() override;
static const char* xmlTypeName;
// te::Plugin overrides
void restorePluginStateFromValueTree(const juce::ValueTree&) override;
juce::String getName() const override { return "My Plugin"; }
private:
static const juce::Identifier levelId;
static const juce::Identifier panId;
static const juce::Identifier chainTreeId;
juce::CachedValue<float> level;
juce::CachedValue<float> pan;
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(MyPlugin)
};
// Source
const juce::Identifier MyPlugin::levelId("level");
const juce::Identifier MyPlugin::panId("pan");
const juce::Identifier MyPlugin::chainTreeId("CHAIN");
MyPlugin::MyPlugin(te::PluginCreationInfo info)
: te::Plugin(info)
{
// Bind CachedValues to state
level.referTo(state, levelId, nullptr, 1.0f);
pan.referTo(state, panId, nullptr, 0.0f);
// Initialize child trees if not present
if (!state.getChildWithName(chainTreeId).isValid()) {
juce::ValueTree chainTree(chainTreeId);
chainTree.setProperty("index", 0, nullptr);
state.addChild(chainTree, -1, nullptr);
}
}
MyPlugin::~MyPlugin() {
notifyListenersOfDeletion();
}
void MyPlugin::restorePluginStateFromValueTree(const juce::ValueTree& v) {
te::copyPropertiesToCachedValues(state, v, nullptr);
level.forceUpdateOfCachedValue();
pan.forceUpdateOfCachedValue();
// Restore child trees
for (int i = state.getNumChildren(); --i >= 0;)
state.removeChild(i, nullptr);
for (auto child : v) {
state.addChild(child.createCopy(), -1, nullptr);
}
}
ValueTree::Listener
Implementing a Listener
class MyComponent : public juce::Component,
private juce::ValueTree::Listener
{
public:
MyComponent(juce::ValueTree stateToWatch)
: watchedState(stateToWatch)
{
watchedState.addListener(this);
}
~MyComponent() override {
watchedState.removeListener(this); // Always remove in destructor
}
private:
void valueTreePropertyChanged(juce::ValueTree& tree,
const juce::Identifier& property) override
{
if (property == MyPlugin::levelId) {
// Update UI — but check what thread you're on!
// If changed from audio thread, use AsyncUpdater
triggerAsyncUpdate();
}
}
void valueTreeChildAdded(juce::ValueTree& parent,
juce::ValueTree& child) override
{
// A child tree was added
}
void valueTreeChildRemoved(juce::ValueTree& parent,
juce::ValueTree& child,
int index) override
{
// A child tree was removed
}
juce::ValueTree watchedState;
};
Thread Safety Warning
Listeners fire on the thread that made the change. If a property is set from the audio thread, the listener callback runs on the audio thread. Never do UI work directly in a listener that might be called from the audio thread. Use juce::AsyncUpdater or juce::MessageManager::callAsync() to bounce to the message thread.
UndoManager Integration
// With undo support (UI-driven changes)
state.setProperty("gain", newValue, &edit.getUndoManager());
state.addChild(child, -1, &edit.getUndoManager());
// Without undo (initialization, audio-thread, internal state)
state.setProperty("gain", newValue, nullptr);
state.addChild(child, -1, nullptr);
// In TE plugins, get the undo manager via:
auto* um = getUndoManager();
state.setProperty("gain", newValue, um);
Common Patterns in This Codebase
Chain Properties
Plugins with multiple chains (e.g., drum grid, multi-output) store per-chain state as child trees:
// Setting up chain state
for (int i = 0; i < numChains; ++i) {
juce::ValueTree chainTree("CHAIN");
chainTree.setProperty("index", i, nullptr);
chainTree.setProperty("level", 1.0f, nullptr);
chainTree.setProperty("pan", 0.0f, nullptr);
chainTree.setProperty("mute", false, nullptr);
chainTree.setProperty("solo", false, nullptr);
state.addChild(chainTree, -1, nullptr);
}
// Reading chain state
for (int i = 0; i < state.getNumChildren(); ++i) {
auto child = state.getChild(i);
if (child.hasType("CHAIN")) {
float chainLevel = child.getProperty("level", 1.0f);
float chainPan = child.getProperty("pan", 0.0f);
bool chainMute = child.getProperty("mute", false);
}
}
CachedValue Arrays for Chains
When you need audio-thread-safe reads for multiple chains:
struct ChainState {
juce::CachedValue<float> level;
juce::CachedValue<float> pan;
juce::CachedValue<bool> mute;
juce::CachedValue<bool> solo;
void referTo(juce::ValueTree& chainTree) {
level.referTo(chainTree, "level", nullptr, 1.0f);
pan.referTo(chainTree, "pan", nullptr, 0.0f);
mute.referTo(chainTree, "mute", nullptr, false);
solo.referTo(chainTree, "solo", nullptr, false);
}
void forceUpdate() {
level.forceUpdateOfCachedValue();
pan.forceUpdateOfCachedValue();
mute.forceUpdateOfCachedValue();
solo.forceUpdateOfCachedValue();
}
};
std::vector<ChainState> chainStates;
Finding Child Trees by Type
// Find a specific child tree
juce::ValueTree findChainByIndex(const juce::ValueTree& parentState, int index) {
for (int i = 0; i < parentState.getNumChildren(); ++i) {
auto child = parentState.getChild(i);
if (child.hasType("CHAIN") && (int)child.getProperty("index") == index)
return child;
}
return {}; // invalid ValueTree
}
// Collect all children of a type
std::vector<juce::ValueTree> getChains(const juce::ValueTree& parentState) {
std::vector<juce::ValueTree> chains;
for (int i = 0; i < parentState.getNumChildren(); ++i) {
auto child = parentState.getChild(i);
if (child.hasType("CHAIN"))
chains.push_back(child);
}
return chains;
}
Common Pitfalls
- •Forgetting
forceUpdateOfCachedValue()afterrestorePluginStateFromValueTree()— CachedValues will hold stale data - •Setting properties from the audio thread — Triggers listeners on the audio thread; use CachedValue for reads, avoid writes from audio thread when possible
- •Not removing listeners in destructors — Dangling listener = crash
- •Using string literals instead of Identifier constants — Creates a new Identifier each time, wastes CPU on hashing
- •Passing
undoManagerduring initialization — Usenullptrfor initial setup; only pass undoManager for user-driven changes - •Forgetting
isValid()checks —getChildWithName()returns an invalid ValueTree if not found; always check before use