| name | audio-thread |
| description | Audio thread safety and lock-free programming patterns for JUCE/Tracktion Engine. Use when writing Plugin::applyToBuffer(), real-time audio callbacks, metering, or any code that touches the audio thread. Covers what is forbidden, lock-free communication, and codebase-specific patterns. |
Audio Thread Safety & Lock-Free Patterns
The audio thread in JUCE/Tracktion Engine runs under strict real-time constraints. Any blocking or unbounded operation causes audible glitches (clicks, dropouts, silence). This skill covers what is forbidden, how to communicate safely between threads, and the patterns used in this codebase.
What Is Forbidden on the Audio Thread
All of the following can block, allocate, or take unbounded time. Never do any of these inside Plugin::applyToBuffer(), AudioProcessor::processBlock(), or any code called from the audio callback.
1. Memory Allocation
auto* p = new MyObject();
std::vector<float> temp(numSamples);
juce::String s = "level: " + juce::String(value);
juce::Array<int> arr;
arr.add(42);
myStdVector.push_back(x);
Instead: Pre-allocate in initialise() or use stack-allocated fixed-size buffers.
float temp[2048];
std::vector<float> scratchBuffer;
2. Locks and Mutexes
juce::ScopedLock sl(criticalSection);
std::lock_guard<std::mutex> lock(mutex);
std::unique_lock<std::mutex> ul(mutex);
Instead: Use atomics, lock-free queues, or juce::AbstractFifo.
3. MessageManager Calls
juce::MessageManager::callAsync([this] { });
sendChangeMessage();
triggerAsyncUpdate();
4. File I/O
juce::File("x.wav").loadFileAsData();
fopen(), fread(), fwrite();
DBG("value: " + juce::String(x));
5. Objective-C Message Sends (macOS)
6. Unbounded Operations
while (!ready.load()) { }
for (auto& item : dynamicContainer) { }
Lock-Free Communication Patterns
Pattern 1: std::atomic for Simple Values
Use for single values shared between audio and UI threads. memory_order_relaxed is sufficient when there is no ordering dependency between multiple variables.
class MyPlugin : public te::Plugin {
std::atomic<float> gainLevel{1.0f};
void setGain(float g) {
gainLevel.store(g, std::memory_order_relaxed);
}
void applyToBuffer(const PluginRenderContext& rc) override {
float g = gainLevel.load(std::memory_order_relaxed);
rc.destBuffer->applyGain(0, rc.bufferNumSamples, g);
}
};
Pattern 2: Atomic Exchange for Peak Metering
The audio thread writes a running maximum. The UI thread reads and resets in one atomic operation, ensuring no peaks are lost and no lock is needed.
class MyPlugin : public te::Plugin {
std::atomic<float> peakLeft{0.0f};
std::atomic<float> peakRight{0.0f};
void applyToBuffer(const PluginRenderContext& rc) override {
auto& buf = *rc.destBuffer;
float newPeakL = buf.getMagnitude(0, rc.bufferStartSample, rc.bufferNumSamples);
float newPeakR = buf.getMagnitude(1, rc.bufferStartSample, rc.bufferNumSamples);
auto prevL = peakLeft.load(std::memory_order_relaxed);
if (newPeakL > prevL)
peakLeft.store(newPeakL, std::memory_order_relaxed);
auto prevR = peakRight.load(std::memory_order_relaxed);
if (newPeakR > prevR)
peakRight.store(newPeakR, std::memory_order_relaxed);
}
float consumePeakLeft() {
return peakLeft.exchange(0.0f, std::memory_order_relaxed);
}
float consumePeakRight() {
return peakRight.exchange(0.0f, std::memory_order_relaxed);
}
};
Pattern 3: juce::CachedValue for ValueTree-Backed Properties
CachedValue<T> caches a ValueTree property in a local variable that is safe to read on the audio thread (the cached copy is updated on the message thread via listener, but the read is just a plain member access).
class MyPlugin : public te::Plugin {
juce::CachedValue<float> levelParam;
juce::CachedValue<float> panParam;
void initialise(const PluginInitialisationInfo&) override {
levelParam.referTo(state, IDs::level, nullptr, 0.8f);
panParam.referTo(state, IDs::pan, nullptr, 0.0f);
}
void applyToBuffer(const PluginRenderContext& rc) override {
float level = levelParam.get();
float pan = panParam.get();
}
};
Pattern 4: std::atomic for Flags and Triggers
Use for one-shot triggers (e.g., pad hit, reset signal) or boolean state flags.
class DrumPad {
std::atomic<bool> triggered{false};
void hit() {
triggered.store(true, std::memory_order_relaxed);
}
void processBlock(juce::AudioBuffer<float>& buffer) {
if (triggered.exchange(false, std::memory_order_relaxed)) {
playbackPosition = 0;
}
}
};
Pattern 5: juce::AbstractFifo / Lock-Free Queues
Use when you need to pass variable-sized data or multiple messages between threads.
class MeterBridge {
juce::AbstractFifo fifo{512};
std::array<float, 512> buffer{};
void pushMeterValue(float value) {
const auto scope = fifo.write(1);
if (scope.blockSize1 > 0)
buffer[(size_t)scope.startIndex1] = value;
}
float popMeterValue() {
float result = 0.0f;
const auto scope = fifo.read(1);
if (scope.blockSize1 > 0)
result = buffer[(size_t)scope.startIndex1];
return result;
}
};
Tracktion Engine Specifics
Plugin Lifecycle & Threading
Message Thread Audio Thread
────────────── ────────────
Plugin::initialise() Plugin::applyToBuffer()
Plugin::deinitialise() (called every audio block)
Plugin::restorePluginStateFromValueTree()
ValueTree listeners fire
Plugin::applyToBuffer(const PluginRenderContext& rc) runs on the audio thread.
Plugin::initialise() and Plugin::deinitialise() run on the message thread.
- Never access
Edit&, ValueTree, or UndoManager from applyToBuffer(). Use CachedValue instead.
PluginRenderContext Quick Reference
void applyToBuffer(const PluginRenderContext& rc) override {
auto& audio = *rc.destBuffer;
auto& midi = *rc.bufferForMidiMessages;
int startSample = rc.bufferStartSample;
int numSamples = rc.bufferNumSamples;
double sampleRate = sampleRateValue;
}
Rack Wrapping
When a plugin is rack-wrapped (inserted into a RackType), Tracktion Engine manages the audio routing. The plugin's applyToBuffer() is still called on the audio thread, but the buffer routing is handled by the rack. Initialise/deinitialise lifecycle is managed by the rack's node graph.
Common Patterns in This Codebase
Per-Chain Peak Metering
struct Chain {
std::atomic<float> peak{0.0f};
};
std::array<Chain, 16> chains;
void applyToBuffer(const PluginRenderContext& rc) override {
for (int i = 0; i < numActiveChains; ++i) {
float mag = getChainMagnitude(i, rc);
auto prev = chains[i].peak.load(std::memory_order_relaxed);
if (mag > prev)
chains[i].peak.store(mag, std::memory_order_relaxed);
}
}
float consumePeak(int chainIndex) {
return chains[chainIndex].peak.exchange(0.0f, std::memory_order_relaxed);
}
Pad Trigger Flags
std::array<std::atomic<bool>, 16> padTriggers{};
void triggerPad(int padIndex) {
padTriggers[padIndex].store(true, std::memory_order_relaxed);
}
void applyToBuffer(const PluginRenderContext& rc) override {
for (int i = 0; i < 16; ++i) {
if (padTriggers[i].exchange(false, std::memory_order_relaxed)) {
startPadPlayback(i);
}
}
}
CachedValue for Level/Pan
juce::CachedValue<float> level, pan;
void initialise(const PluginInitialisationInfo& info) override {
level.referTo(state, IDs::level, nullptr, 0.8f);
pan.referTo(state, IDs::pan, nullptr, 0.0f);
}
void applyToBuffer(const PluginRenderContext& rc) override {
float l = level.get();
float p = pan.get();
}
Debugging Checklist
If you hear clicks, dropouts, or glitches:
- Search for allocations in
applyToBuffer() - look for new, String, Array, vector operations
- Search for locks - grep for
ScopedLock, lock_guard, CriticalSection in audio path
- Check for DBG() calls in audio code - these do file I/O
- Verify pre-allocation - all buffers sized in
initialise(), not in applyToBuffer()
- Check CachedValue usage - ensure
referTo() is called in initialise(), not in applyToBuffer()