The audio callback is not your program
You can look at a processBlock and say which lines have an unbounded worst case, and you can prove a processor correct offline before a DAW ever loads it.
What the callback actually promises
Your plugin has two halves that barely know each other. One half is an ordinary program: it starts up, draws a window, reads files, responds to clicks. The other half is a single function that the audio driver calls on a thread you did not start and cannot see, over and over, for as long as the session is open.
That function is `processBlock`. The host calls it with a buffer of samples and a deadline. At 48 kHz with a 128 sample buffer, the deadline is 2.67 milliseconds. If you return in time, the driver copies your output to the hardware. If you do not, the driver plays whatever was already sitting in that memory. The user hears a click.
| Sample rate | Block size | Deadline per call |
|---|---|---|
| 44100 | 64 | 1.45 ms |
| 48000 | 128 | 2.67 ms |
| 48000 | 512 | 10.67 ms |
| 96000 | 128 | 1.33 ms |
Almost nothing else is promised. The block size can change between calls, and some hosts vary it every call. The sample rate can change under you. The thread identity can change. During an offline bounce the same function runs at thirty times real time with no deadline at all, and while the transport is stopped it may not be called for minutes.
So the discipline is not about being fast on average. A function that takes 0.1 ms on nine hundred and ninety-nine calls and 40 ms on the thousandth is a broken function. Average throughput is irrelevant. The only number that matters is the worst case, and the worst case has to be bounded by something you can reason about.
The forbidden list and why it is short
Everything banned from the audio thread is banned for one reason: its worst case is set by something outside your code. The allocator may walk a free list or call into the kernel. A mutex may be held by a thread the scheduler has parked. A write to disk may hit a spinning platter.
| Operation | Allowed on the audio thread | What sets the worst case |
|---|---|---|
| new, malloc, std::vector growth | no | the allocator, possibly the kernel |
| delete, free | no | the allocator |
| std::mutex lock | no | the thread that holds it |
| file or network IO | no | the device driver |
| DBG, printf, logging | no | IO, and usually a lock |
| std::function assignment | no | it may allocate |
| juce::String operations | no | it allocates |
| arithmetic, branches, loads, stores | yes | your own code |
| std::atomic load and store, lock-free | yes | the cache coherence protocol |
The dangerous cases are the ones that do not look like allocation. `buffer.setSize` allocates. Resizing a `juce::AudioBuffer` you own allocates. Assigning to a `std::function` allocates unless the target fits in its small-object buffer, and you do not control that. Growing a `std::vector` allocates even when you have called `reserve`, if you were wrong about the size once.
// Broken. Three unbounded operations in five lines.
void AudioProcessor::processBlock (juce::AudioBuffer<float>& buffer,
juce::MidiBuffer&)
{
std::vector<float> scratch (buffer.getNumSamples()); // allocates
const juce::ScopedLock sl (stateLock); // may block
if (buffer.getMagnitude (0, buffer.getNumSamples()) > 1.0f)
DBG ("clipped"); // IO plus a lock
}
The fix is to move every one of those decisions earlier. `prepareToPlay` is called on the message thread before audio starts, and it is told the sample rate and the maximum block size. That is where you size your buffers, build your filter coefficients and allocate anything the processor will ever need.
void AudioProcessor::prepareToPlay (double sampleRate, int maxBlockSize)
{
scratch.setSize (2, maxBlockSize, false, true, true); // allocate once
delayLine.prepare (sampleRate, maxDelaySeconds);
gain.reset (sampleRate, 0.02); // 20 ms ramp
}
void AudioProcessor::processBlock (juce::AudioBuffer<float>& buffer,
juce::MidiBuffer&)
{
juce::ScopedNoDenormals noDenormals;
const auto n = buffer.getNumSamples();
jassert (n <= scratch.getNumSamples()); // debug only, compiled out
scratch.clear (0, n);
}
Note that `jassert` is a debug-build assertion and compiles to nothing in a release build, so it is safe here. `DBG` is not, because it survives into any build where logging is enabled.
The host decides the maximum, and then breaks itHosts have been known to call processBlock with more samples than the maximum they declared in prepareToPlay, usually when the driver buffer size changes while the transport is running. Handle it by processing in chunks of your prepared size rather than by resizing, which would allocate.
Denormals will cost you 100x
A denormal, or subnormal, is a float so close to zero that the exponent has bottomed out and precision is carried in the mantissa alone. On most x86 CPUs, arithmetic on denormals falls off the fast path and into microcode, and a multiply that took a cycle takes a hundred.
This matters because audio is full of signals decaying toward zero. Every IIR filter, every reverb tail, every delay feedback path walks down through the denormal range after the input stops. A plugin that measures 2 percent CPU while music plays can measure 40 percent in the silence after it, which is exactly when a user notices the meter.
The fix is two bits in the CPU's control register: flush to zero, and denormals are zero. JUCE wraps them.
void AudioProcessor::processBlock (juce::AudioBuffer<float>& buffer,
juce::MidiBuffer& midi)
{
juce::ScopedNoDenormals noDenormals; // first line, every time
render (buffer, midi);
}
The flags are per thread and the scope restores them on exit, so the host's own code is unaffected. If you run DSP on a worker thread of your own, that thread needs its own `ScopedNoDenormals`, because it does not inherit yours.
Getting values in without a lock
A user turns a knob on the message thread. The audio thread needs the new value. You cannot lock, so you publish.
For a single scalar, a lock-free atomic is the whole answer. Check that assumption at compile time rather than assuming it.
static_assert (std::atomic<float>::is_always_lock_free,
"float atomics are not lock free on this target");
std::atomic<float> targetGain { 1.0f };
// Message thread, from the slider callback.
void setGain (float g) { targetGain.store (g, std::memory_order_relaxed); }
Relaxed ordering is correct here because the value stands alone. As soon as two values have to agree with each other, relaxed is wrong and you need either release and acquire ordering or a different structure entirely.
Reading the atomic is not the end of it. A gain that jumps from 0.2 to 0.8 between two adjacent samples is a step discontinuity, and a step discontinuity is broadband energy. You hear it as a click. Every parameter that multiplies or offsets the signal needs a ramp.
gain.setTargetValue (targetGain.load (std::memory_order_relaxed));
if (gain.isSmoothing())
{
for (int i = 0; i < n; ++i)
{
const auto g = gain.getNextValue();
for (int ch = 0; ch < numCh; ++ch)
buffer.getWritePointer (ch)[i] *= g;
}
}
else
{
buffer.applyGain (gain.getCurrentValue()); // vectorised fast path
}
Twenty milliseconds is a reasonable default ramp for gain. Filter cutoff usually wants longer, and delay time wants either a very long ramp or a crossfade, because interpolating a read pointer pitch-shifts the tail.
Getting data out without a lock
Meters, waveforms and spectrum displays all need data to travel the other way. The audio thread produces it and must not wait for anyone to collect it, so the transport is a single-producer single-consumer ring buffer. `juce::AbstractFifo` manages the indices and leaves the storage to you.
class ScopeFifo
{
public:
// Audio thread. Never blocks. Drops samples if the editor is behind.
void write (const float* src, int num)
{
int start1, size1, start2, size2;
fifo.prepareToWrite (num, start1, size1, start2, size2);
if (size1 > 0) std::memcpy (buf.data() + start1, src, sizeof (float) * (size_t) size1);
if (size2 > 0) std::memcpy (buf.data() + start2, src + size1, sizeof (float) * (size_t) size2);
fifo.finishedWrite (size1 + size2);
}
private:
juce::AbstractFifo fifo { 8192 };
std::vector<float> buf = std::vector<float> (8192);
};
Dropping samples is the correct behaviour. The editor is a display, and a display that is 40 milliseconds stale is fine. An audio thread that waited for a repaint is not.
The same rule governs ownership. If the audio thread holds the last reference to an object, releasing that reference calls a destructor, and the destructor frees. So the audio thread never drops the last reference. It hands the old object to a FIFO, and a low priority background thread drains the FIFO and deletes there.
Proving it offline
Listening to a plugin tells you nothing about its worst case, and very little about its correctness. Build a plain console executable that links your DSP without JUCE's plugin wrapper, and test it the way you would test any other library.
Two tests catch most real bugs.
The first is a null test against a stored reference. Render a known input, subtract a reference render committed to the repository, and assert the residual peak is below a threshold. For bit-identical DSP the threshold is zero. Where a change of compiler or a vector intrinsic moves the last bit, minus 120 dBFS is a defensible line.
The second is a block size sweep, and it is the one that finds the bug you would otherwise ship. Render the same input at block sizes 1, 13, 64, 512 and 4096, then null every output against the first. Any state that depends on block size shows up immediately: a filter reset in the wrong place, an LFO advanced once per block instead of once per sample, a smoother stepping per block.
static float renderAndCompare (Engine& engine, const AudioFile& input, int blockSize)
{
engine.prepare (48000.0, blockSize);
AudioFile out (input.numChannels, input.numSamples);
for (int pos = 0; pos < input.numSamples; pos += blockSize)
{
const auto n = std::min (blockSize, input.numSamples - pos);
engine.process (out.view (pos, n), input.view (pos, n));
}
return out.peakDifference (reference); // linear, convert to dB at the call site
}
TEST_CASE ("output is independent of block size")
{
for (int bs : { 1, 13, 64, 512, 4096 })
REQUIRE (juce::Decibels::gainToDecibels (renderAndCompare (engine, input, bs)) < -120.0f);
}
The block size 13 case is there on purpose. Powers of two hide off-by-one errors in any loop that processes in vector-width chunks.
Before the next lessonTake any processBlock you have written, or one from a JUCE example, and mark every line that could allocate, lock or do IO. Then write down what the worst case of the remaining code depends on. If the answer includes a number you cannot see in your own source, that is the next thing to fix.