Modifying & extending juce::Reverb

Hey all,
I’m modifying the built in juce::Reverb class and I have some questions about it’s current design and performance. Here’s the docs↗ and the source↗ for reference. Please understand that I’m more of a “programmer” than an “audio programmer” or “C++ programmer” so I might be asking some rookie questions here.

  1. On line 140 of the source, you see this code in processStereo:
            const float input = (left[i] + right[i]) * gain;

Why are the left and right channels added together before the reverb is applied? Doesn’t this preclude the possibility of a meaningful width parameter, which is supposed to introduce stereo bleed in the reverb at low width, while maintaining distinct left/right reverberations at high width? See the source at lines 76-77, when the left/right gains are calculated in setParameters:

        wetGain1.setTargetValue (0.5f * wet * (1.0f + newParams.width));
        wetGain2.setTargetValue (0.5f * wet * (1.0f - newParams.width));

and 159-163, in processStereo when the reverb is mixed back into the source buffer:

            const float wet1 = wetGain1.getNextValue();
            const float wet2 = wetGain2.getNextValue();

            left[i]  = outL * wet1 + outR * wet2 + left[i]  * dry;
            right[i] = outR * wet1 + outL * wet2 + right[i] * dry;

All this effort to create separate gains for the left and right channel reverb, isn’t it wasted if we mix the inputs together before calculating the reverb?


  1. The built in module uses eight comb filters which accumulate in series, and four all-pass filters which also act in series. I’m experimenting right now with using eight all-pass filters which are applied to the individual outputs of the comb filters. I’ve read online that both approaches are valid, but can anyone with more experience designing reverb clue me into the pros and cons of each approach and how to tune them for the best sound?

  1. What is the pattern in how the lengths of the filters were decided? Lines 92-93 of the source:
        static const short combTunings[] = { 1116, 1188, 1277, 1356, 1422, 1491, 1557, 1617 }; // (at 44100Hz)
        static const short allPassTunings[] = { 556, 441, 341, 225 };

My client was surprised to learn about both the all-passes processing the accumulated comb outputs instead of each individually as well as lengths of their buffers. He expected the individual approach and for the all-pass filter lengths, when summed with the lengths of their associated comb filters, to equal a a fixed number. This would help align the the outputs in time.

I did a dirty little calculation with python, associating each all-pass filter with two comb filters, to see how they added up:

>>> cT = [556, 441, 341, 225]
>>> apT = [ 1116, 1188, 1277, 1356, 1422, 1491, 1557, 1617 ]
>>> sum = [ 0, 0, 0, 0, 0, 0, 0, 0 ]
>>> for i in range (0, 8):
		# Add the comb filter values to the sum
...     sum[i] += cT[i]
...
>>> for i in range (0, 4):
		# Add the all-pass filter values to sum at i and i+1 to surf the ramp
...     sum[i*2] += apT[i]
...     sum[(i*2) + 1] += apT[i]
...
>>> sum
[1672, 1744, 1718, 1797, 1763, 1832, 1782, 1842]

What does that translate to in the total time?

>>> max = max(sum)
>>> min = min(sum)
>>> timeShift = sampleRate * (max-min)
>>> timeShift
0.0035416666666666665

At a sampleRate of 48000, if its true that the filter buffer length determines the delay time of the samples, there could be up a 3.5ms mismatch between when samples are coming out of the different filters (note; I’m not if this is relevant in the case where the all-pass filters act on the accumulation, as in the provided source). Can anyone tell me what the pattern was for deciding the comb and all pass filter buffer lengths?

as far as i know you typically use values that don’t have much to do with each other to avoid resonances. you know, like multiples or so. i checked if these values are prime numbers and to my surprise none of them is

1 Like

Thanks Mrugalla. I might try changing them to prime numbers and see how that affects the sound.

I just found this blog post from @valhalladsp, I will have to give a read through of some of these to better understand the techniques.

It would seem the built in module is a Schroeder type reverb. From the descriptions they are robust but overall limited in the ability to replicate a natural sound.

My client is quite an audio freqk (in the best way) and isn’t satisfied with the mechanical overtones in the reverb output. We may have to move towards a feedback delay network, Dattarro reverb, or another model. Lots of learning to do!

It would be helpful if the comments in the Reverb class mentioned it is a Schroeder type, so newbies know what to look for to help them understand it better…

The docs mention it is the old FreeVerb algo, which means it’s not going to be particularly good.

1 Like

Thank you so much, I should have looked deeper into that. Now that I’m researching freeverb, I’m finding a lot of great resources including block diagrams, transfer functions for the allpass and comb filters, and an explanation of stereo spread. Much appreciated!

1 Like

To enrich the sound we are going to add clustered echoes on the comb filters. I.e. sampling not just at the tap number, but the tap number + 31, + 17, +7 for four clustered echoes at or around the tap. Additionally, we need to be able to enable or disable them on the fly.

I’m getting to work on implementing this. I haven’t used boolean parameters before so I was looking into them, and they seem like a mess:

Bool parameters With AudioProcessorValueTreeState - gives a best practice snip, imho it’s quite messy
Parameters - Best Practice - discussion
Behaviour of AudioProcessorValueTreeState Parameter and ButtonAttachment - unanswered
APVTS::ParameterChanged not being called for toggle buttons - unanswered

Since this is a standalone app, I’m just going to use the xml settings file and skip the value tree.

Hello all, I am trying to allow smoothly resizing comb filter sizes.

In the original Juce reverb it clears the buffer when setting the new size. There also isn’t any way included to modify the size at runtime.

I found that clearing the buffer introduces clipping/saturating that gradually decays into wobbling and finally, the normal reverb profile.

I tried calling realloc instead of malloc and found that only worsened the problem.

I’m thinking now that I’ll just have a second buffer and cross-fade between the two.

Instead of using realloc and malloc, You will want to pre-allocate all of your memory before starting the audio thread, ergo in the prepare() function(s). I don’t know exactly what comb filters you are using, but either way this means setting the size of the delay lines inside them beforehand to the maximum value that is possible according to how you set your parameters.

Even then, changing the parameter will cause audio glitches. This can be avoided in most cases by using juce::SmoothedValue (but you may already know this).

If you have a more complex parameter that affects many different delay lines within your algorithm, you may even have to do something like “Parameter Change → silence input → execute parameter change → reactivate input” but I found this only to be necessary in extreme cases where the parameter fundamentally changes the reverb.

1 Like

Thanks so much @philiphugo. I straight up forgot that the parameter changed callbacks could be called from the audio thread. Been a crazy holiday season so I’m late to reply, but you pointed me in the right direction and I’ve worked out most of the kinks in the memory management using a state enum, spin lock, and async updating! We’re no longer seeing crashes, blowups, or dropouts in the audio.

I am not using SmoothedValues as there isn’t any automation or curve to the transitions. My “Room Type” knob selects the reverb profile, and the switchover is instant. I haven’t experienced glitches from this.

Silencing was one option that we discussed, but this would be pretty subpar for the end user experience. What I’ve done is added a reserve buffer which is resized on a background thread and once fully initialized, flip-flops with the primary buffer.

Here are the most relevant code sections, any beginner who can understand the documentation I linked should be able to put it all together with this. Again, this is all based on juce::Reverb; I’ve made many changes, which I’m not going to fully describe. Just trying to demonstrate how to use a mutex to beginners here.


Reverb Module

- Parameter changed callback

//==============================================================================
void Hooverb::setCombDelayLength(int channel, int index, int room, float newValue) {
    comb[channel][index].setDelayLength(room, newValue);

    // Set comb state and call triggerAsyncUpdate -KGK v1.6.15
    comb[channel][index].setBufferState(CombFilter::BufferState::ResizeRequested);
    triggerAsyncUpdate();
}

This method is called by the processor when the delay length slider is adjusted. It changes the delay length and state of a comb filter member and triggers the async update, which will actually execute the changes (allocate memory) indicated by the state and delay length.

- Async method to resize buffers

//==============================================================================
void Hooverb::handleAsyncUpdate() {
    // Resize comb buffers if needed -KGK v1.6.15
    for (int c = 0; c < Constants::Reverb::numChannels; ++c) {
        for (int i = 0; i < Constants::Reverb::filterCount; ++i) {
            if (comb[c][i].getBufferState() == CombFilter::BufferState::ResizeRequested) {
                // Resize buffer -KGK v1.6.15
				comb[c][i].resizeBuffer(comb[c][i].getDelayLength(comb[c][i].getRoom()));
			}
		}
	}
}

Comb Filter modules

The comb filters are members of the reverb module.

- Spinlocked getter and setter

//==============================================================================
void CombFilter::setBufferState(BufferState newState) {
    juce::SpinLock::ScopedLockType lock(bufferStateLock);
    bufferState = newState;
}

//==============================================================================
CombFilter::BufferState CombFilter::getBufferState() {
    juce::SpinLock::ScopedLockType lock(bufferStateLock);
    return bufferState;
}

These methods use a spin lock to protect the state variable from memory violations while reading or writing. I chose a spin lock instead of a scoped lock because the comb filter delay length needs to be resized asap for a reactive audio profile.

- Buffer resize methods

//==============================================================================
void CombFilter::resizeBuffer(const int newSize) {
    if (newSize == 0) {
        setEnable(false);
    }
    else {
        setEnable(true);
        // Tap numbers are tuned for 44.1kHz -KGK v1.5.8
        int size = (newSize * sampleRate) / Constants::BaseSampleRate;
        resizeSecondaryBuffer(size);
    }
}

void CombFilter::resizeSecondaryBuffer(const int newSize) {
    if (newSize != bufferSize) {
        // Acquire mutex and change state to "Resizing" -KGK v1.5.15
        // Note: Waits on bufferStateLock
        setBufferState(BufferState::Resizing);

        // Allocate the reserve buffer -KGK v1.5.15
        // * Initialize primaryBuffer with one channel and newSize samples
        reserveBuffer.reset(new AudioBuffer<float>(1, newSize));
        // * Record the size
        reserveBufferSize = newSize;
        // * Clear old data
        reserveBuffer.get()->clear();
        // * Debuge message
        DBG(String::formatted("comb[%i][%i].reserveBuffer sized at: %i", channel, index, bufferSize));

        // Acquire mutex and change state to "Copy Requested" -KGK v1.5.15
        // Note: Waits on bufferStateLock
        setBufferState(BufferState::CopyRequested);
    }
}

First: there is no real reason for this to be two methods. It just is.
Anyways, as you can see it resets the reserveBuffer (a unique pointer). This is bookended by the setBufferState methods. Being spinlocked, this ensures the process method/audio thread and the background thread reallocating the reserve buffer will not try to access the state enum at the same time. This brings us to…

- Process method

//==============================================================================
float CombFilter::process(const float input) noexcept {
    if (enable) {
        // Return input if damping and feedback equal zero; otherwise return output --KGK & Copilot v1.6.14
        if (scaledDamping == 0.0f && scaledFeedback == 0.0f) {
            return input;
        }
        else {
            // Acquire buffer state lock for the duration of processing -KGK v1.5.15
            //juce::SpinLock::ScopedLockType lock(bufferStateLock);

            // Assign the element at bufferIndex in the buffers array at bufferSelect to a float, output -KGK v1.5.15
            float output = primaryBuffer.get()->getSample(0, bufferIndex);

            // Removed const to allow for cluster echoing, will this cause a slowdown? -KGK v1.5.10
            //float output = buffer[bufferIndex];

            // Add cluster echoes -KGK v.1.5.10
            if (enClusterEchoes) {
                output *= 0.25f;
                for (int i = 0; i < 3; i++) {
                    int offsetIndex = (bufferIndex + Constants::Reverb::clusterEchoOffsets[index][i]) % bufferSize;
                    output += primaryBuffer.get()->getSample(0, offsetIndex) * 0.25f;
                }
            }

            // Internal damping only -KGK v1.5.8
            last = output * (1.0f - scaledDamping) + (last * scaledDamping);
            JUCE_UNDENORMALISE(last);

            // Internal feedback only -KGK v1.5.8
            float temp = input + (last * scaledFeedback);
            JUCE_UNDENORMALISE(temp);

            primaryBuffer.get()->setSample(0, bufferIndex, temp);
            bufferIndex = (bufferIndex + 1) % bufferSize;

            if (getBufferState() == BufferState::CopyRequested) {
                // Zero index and set state to "Copying" -KGK v1.6.15
                reserveBufferIndex = 0;
                setBufferState(BufferState::Copying);
            }

            if (getBufferState() == BufferState::Copying) {
                reserveBuffer.get()->setSample(0, reserveBufferIndex, temp);
                reserveBufferIndex = (reserveBufferIndex + 1);
                // Swap buffers once we've filled the reserve buffer -KGK v1.6.15
                if (reserveBufferIndex == reserveBufferSize) {
                    // Set state to "Normal" -KGK v1.5.15
					setBufferState(BufferState::Normal);
					// Swap reserve buffer to primary -KGK v1.6.15
                    std::swap(primaryBuffer, reserveBuffer);
                    // Set size to swapped size -KGK v1.6.15
                    bufferSize = reserveBufferSize;
                    // Reset index to the start of the new primary buffer -KGK v1.6.15
                    bufferIndex = 0;
					// Set state to "Normal" -KGK v1.6.15
					setBufferState(BufferState::Normal);
                }
            }

			return output;
		}
    }
    else {
        return input;
    }
}

The guts are similar to the juce framework’s reverb module, but the bones are different. The latter half handles checking the state. It also populates the reserve buffer before swapping the pointer with the primary buffer.


Basic stuff for most on here, but this is the first time I implemented mutexes outside of an embedded system so I wanted to share! I hope it helps someone. If anyone sees a problem with my implementation, pls lmk. Cheers and Happy New Year!

1 Like

So how does it sound like? Better than the stock reverb?

Is your “audio freqk” satisfied?

Thanks for asking!

Audio freqks and casual listeners agree, it definitely sounds a lot better than the stock reverb. Moving the all pass filters to be in series with each individual comb filter, rather than applied to the sum of the comb filters, was the key change. I’m calling this “Hooverb” pattern reverb - named after my client, Robert Hoover, who instructed the change. I’m very grateful to be learning so much about audio theory from such an experienced engineer.

We are still tuning the parameters (delay length, damping, feedback, room width) for the ideal audio profile.

Currently, the release date is planned for January 11th and I’ll share a link then!

In the meantime I’ll share a Python script to go through the parameters created by Bob in the debug version of the software, which exposes the parameters named above as sliders. The release version will have them all baked in.

gist: Formats a subset of named parameters from a JUCE application settings file and prints them as C++ arrays.

Nice to hear and thanks for the code. I will test it later when I get some time over.

1 Like

You’re welcome!
One thing to note is that saved Juce parameters are not completely well-formed, with some garbage characters like this at the start of the file:

VC2!Ž;  

You’ll have to delete those before it works (and of course, use parameter names present in your settings).