FDN Reverb

DSP, Plugin and Host development discussion.
Post Reply New Topic
RELATED
PRODUCTS

Post

AUTO-ADMIN: Non-MP3, WAV, OGG, SoundCloud, YouTube, Vimeo, Twitter and Facebook links in this post have been protected automatically. Once the member reaches 5 posts the links will function as normal.
Hello! I’m following this blog https://signalsmith-audio.co.uk/writing ... -a-reverb/ (https://signalsmith-audio.co.uk/writing/2021/lets-write-a-reverb/) to make a diffusion step for a reverb. My code is the following:

Code: Select all (#)

std::vector<float> DiffusionStep::process(const std::vector<float>& inputs, const int& totalNumOutputChannels, bool doUpmix, bool doDownmix)
{
   std::vector<float> dsInputs = inputs;
    if (doUpmix) {
        dsInputs = setInputs(inputs);
    }
    std::vector<float> outputs = delay.readOutputs();
    ShuffleChannels::process(outputs);
    PolarityInverter::process(outputs);
    outputs = Hadamard::process(outputs);
    delay.writeInputs(dsInputs);


    if (doDownmix) {
        outputs = setOutputs(outputs, totalNumOutputChannels);
    }
    return outputs;
}
And this is my process function.

Code: Select all (#)

    for (int sample = 0; sample < buffer.getNumSamples(); ++sample) {
        for (int channel = 0; channel < totalNumInputChannels; ++channel)
        {
            auto* channelData = buffer.getReadPointer(channel);
            inputs[channel] = channelData[sample];
        }
        outputs = ds1.process(inputs, totalNumInputChannels, true, false);
        outputs = ds2.process(outputs, totalNumInputChannels, false, false);
        outputs = ds3.process(outputs, totalNumInputChannels, false, false);
        outputs = ds4.process(outputs, totalNumInputChannels, false, true);
        for (int channel = 0; channel < totalNumInputChannels; ++channel)
        {
            auto* channelData = buffer.getWritePointer(channel);
            channelData[sample] =  outputs[channel];
        }
    }
The problem is: it doesn’t sound very diffused. The result still feels echo-y or sparse, rather than smooth or dense. The math (Hadamard, shuffle, inversion) and delay wiring all seem correct.

Any thoughts? Is 4 steps too few? Am I missing something obvious?

Post

How many channels?

Basically if we put the input signal on first channel and then we shuffle across N channels (with NxN Hadamard) then we'll get N echoes (assuming all the delays are unequal length). Do another shuffle step and you have N^2 echoes and so on (ie. K shuffle steps you have N^K echoes, still assuming all delays everywhere are unequal length).

Beyond that the delay distribution matters, because if any two echoes end up too close to each other, then effectively we'll perceive them as a single echo, so ideally every path through the delays ends up significantly different length. With large enough density this is no longer that critical, but at relatively low densities the perceived result can vary a lot depending on how the delays are chosen.

Post

There are 16 channels for each of the diffusion steps. And these are the 4 sets of 16 delay times.

std::vector<float> delayTimes1 = {
0.0113f, 0.0127f, 0.0136f, 0.0149f,
0.0152f, 0.0158f, 0.0169f, 0.0174f,
0.0171f, 0.0181f, 0.0187f, 0.0193f,
0.0101f, 0.0122f, 0.0133f, 0.0142f
};

std::vector<float> delayTimes2 = {
0.0211f, 0.0229f, 0.0237f, 0.0253f,
0.0264f, 0.0278f, 0.0291f, 0.0306f,
0.0319f, 0.0333f, 0.0347f, 0.0358f,
0.0364f, 0.0376f, 0.0385f, 0.0392f
};

std::vector<float> delayTimes3 = {
0.0423f, 0.0451f, 0.0477f, 0.0498f,
0.0529f, 0.0557f, 0.0581f, 0.0612f,
0.0633f, 0.0658f, 0.0674f, 0.0701f,
0.0726f, 0.0753f, 0.0775f, 0.0799f
};

std::vector<float> delayTimes4 = {
0.0832f, 0.0879f, 0.0921f, 0.0967f,
0.1023f, 0.1079f, 0.1127f, 0.1182f,
0.1231f, 0.1297f, 0.1353f, 0.1394f,
0.1436f, 0.1487f, 0.1529f, 0.1583f
};

Post

First, I want to observe that it is pointless to delay ALL signals, you can always have one "dry path" since it's not the total delay that matters here, rather the difference between delays. Delaying every signal just means you're adding bulk delay.

The next observation is related to that, namely all your delays in a given "shuffle set" are fairly similar (so the actual differences in times between the echoes aren't that much). You'll get better results if you spread them over the whole delay range from 0 to whatever you choose as maximum. Whether you want to use the same maximum for each "shuffle set" sort of "depends" but you'll get better intuitive idea of what this thing is doing if you play around feeding your code with just a single impulse and then look at what it outputs on each channel.

Post

This doesn't really answer your question but it's a thing that immediately caught my attention, so let me give you some unsolicited code review. If this code is supposed to be used in a typical realtime plugin, you really shouldn't do any heap allocations in the realtime audio thread:

Code: Select all

std::vector<float> DiffusionStep::process(const std::vector<float>& inputs, const int& totalNumOutputChannels, bool doUpmix, bool doDownmix)
{
   std::vector<float> dsInputs = inputs;               // !!! Heap allocation !!!
    
   // ...
    
   std::vector<float> outputs = delay.readOutputs();   // !!! Heap allocation !!!
    
   // ...
}
Instead, the common practice is to pre-allocate all your required buffers before the realtime processing begins. Plugin APIs typically provide some sort of "prepareToPlay" function like this:

https://docs.juce.com/master/classAudio ... 94d814389e

for such resource allocation purposes. If I'm not mistaken, it's called "activate" in CLAP. See:

https://github.com/free-audio/clap/blob ... p/plugin.h

The reason being that heap allocations may take a non-deterministic amount of time before they return. This may cause audio glitches due to output buffers not being produced on time when the allocation takes longer than usual (supposedly because the memory manager decides to do some additional work before giving you back your summoned memory). Most of the time, it will all be fine and you don't notice anything. It's a problem that manifests itself only on rather rare occasions but if it does, it does so in a way that is considered totally unacceptable such that the rarity of occurrence makes it even more pernicious.
My website: rs-met.com, My presences on: YouTube, GitHub, Facebook

Post

Music Engineer wrote: Sat May 31, 2025 9:48 am The reason being that heap allocations may take a non-deterministic amount of time before they return. This may cause audio glitches due to output buffers not being produced on time when the allocation takes longer than usual (supposedly because the memory manager decides to do some additional work before giving you back your summoned memory). Most of the time, it will all be fine and you don't notice anything. It's a problem that manifests itself only on rather rare occasions but if it does, it does so in a way that is considered totally unacceptable such that the rarity of occurrence makes it even more pernicious.
Actually in this case where I guess we're allocating a per-sample basis, even the "best-case" allocation time is enough that it'll probably be a non-negligible part of the processing time.

It's perhaps also worthwhile to point out that it's not just allocation, but also deallocation that's equally suspect. This comes up in some schemes, where it'd occasionally be really handy to allocate an std::shared_ptr or similar in the GUI thread (that's fine) and then pass it to the audio-thread, which is actually still fine, except if the GUI thread drops the reference before the audio thread does, then it's the audio thread now responsible for deallocating and that's a potential glitch.

Besides pre-allocating everything (or declaring things as old fashioned arrays as part of your plugin/effect class if the size is known), if the arrays in question are known to be small (eg. 16 floats = 64 bytes which classifies as small) then simply using stack allocs is an option too (eg. old fashioned local array, or std::array if you prefer a vector-like interface).

Post

Depending on your use case, just grabbing and keeping one large allocation to use as a memory arena will solve your problems. You can allocate from that on each call and deallocation is just resetting a pointer. As ED said, putting the values on the stack will work as well as it works similarly, if you don't blow the stack.

I'm currently running into this issue on Mac, in the graphics realm. I need to make two small alocations per frame buffer in Cocoa just to blit a new buffer to the screen. Unfortunately, the OS has to make those alocations from the heap and I cannot use an arena. Thus, the program will run for about 250k frames (60 seconds) before it starts stuttering horribly when the heap gets shredded. Curse you Apple!
I started on Logic 5 with a PowerBook G4 550Mhz. I now have a MacBook Air M1 and it's ~165x faster! So, why is my music not proportionally better? :(

Post

tonitonitoni wrote:Am I missing something obvious?
After a superficial look ...

The signal flow you want:
(A) input -> diffuse1 -> diffuse2 -> diffuse3 -> diffuse4 -> output

The signal flow you probably have:
(B) input -> diffuse4 -> output

You can verify my assumption by setting all delay times in the last diffusion step (delayTimes4) to the same, long value like 0.5 sec. If you get a single, distinct 0.5 sec delayed echo or silence (depends on the mixing matrix), you most likely have (B).

And that's a very good advice:
mystran wrote:First, I want to observe that it is pointless to delay ALL signals, you can always have one "dry path" since it's not the total delay that matters here, rather the difference between delays. Delaying every signal just means you're adding bulk delay.
Typically the Pre-Delay parameter in a reverb controls overall delay.

Post Reply

Return to “DSP and Plugin Development”