I am working on a tiny VST which allows to switch between different impulse responses, ideally as fast as possible while still avoiding clicks.
This is a study project and my first time working with JUCE. As such, my current implementation to achieve this is very straightforward brute-force convolution in the time domain that I perform on whole buffers, where the convolution ‘tail’ is added to the start of the next buffer.
I’ve read that, with an approach like this, switching the impulse response at the start of a new buffer should not produce any (or at least not that many) clicks, yet they are still there.
I would appreciate some advice on what other approaches could be taken to resolve this. At the moment, I am trying to put some crossfading in place, which helps, but the clicks are still audible. I imagine this would be easier to handle if I was working on a sample-per-sample basis, but that would require reworking my custom filters, which I would really like to avoid.
Edit 1: The impulse response is changed from the GUI. I have experimented both on changing it whenever the UI is interacted with, and delaying the use of new IR until a new block processing starts.
It’s not clear from your post what is changing the impulse response. Does the change come from the GUI or is the audio thread changing it itself? If it’s the GUI making the changes, you have to ensure it is thread safe.
A few years ago, parts of my bachelor thesis dealt with seamless switching between impulse responses. If your native language is by chance german I could send you a copy of the relevant chapter, describing various approaches. A short summary:
Hard switching of impulse responses will create artifacts, no matter if computed in time or frequency domain
A first smoothing approach in the time domain is to not change all IR coefficients at once but exchanging them coefficient by coefficient on a per-sample basis
An even better sounding smoothing approach is to compute e.g. 128 intermediate IRs that are interpolated between the old and new one and use each of these for only one sample
For convolution in the frequency domain, a straightforward approach is to compute two convolutions in parallel, one with the old and one with the new input signal and fade between their outputs in the time domain
A fancy trick to avoid two IFFTs in the approach above is to perform a “time domain-style convolution” of both spectra with a 3-element impulse response, representing the spectrum of a cos^2 / sin^2 function to perform the crossfading in the frequency domain before performing the inverse transform. This is a bit difficult to explain in a few lines, as it involves some math, but if you are interested I could look for my original resources describing this efficient approach
Thank you so much for your response!
I had a feeling just the IRs could be interpolated without the need of crossfading two different convolutions running in parallel, but did not quite know how to go about it. Computing the intermediate IRs seems to be the way to go as it works really well (jumped into implementing it as soon as I saw your post), at least given the current convolution implementation I am using now.
I don’t understand much German, sadly, but I’ll go through the resources you’ve provided, it all seems very interesting.
Hi, thank you for the ideas!
Does anyone know how to implement this idea with JUCE 6 convolution ?
I have two dsp::Convolution instances running in parallel, but don’t know how to work with two dsp::ProcessContextReplacing neither how can I fade the outputs.
The JUCE 6 convolution already does internal crossfading between impulse responses. If you load a new IR during playback, a new convolution engine will be created on a background thread. Once the engine has been constructed, the two engines will be run in parallel for a short time, with a crossfade. Once the crossfade has completed, the old engine will be destroyed (again on a background thread). Note that to ensure threadsafety, the call to Convolution::loadImpulseResponse must be synchronised with Convolution::process. It is an error to call Convolution::loadImpulseResponse in one thread while Convolution::process is being called in another thread. There’s an example of threadsafe IR loading in the Convolution Demo in the JUCE repo.
I’m not sure whether this functionality is sufficient for you. If not, there may be some ideas in the JUCE 6 Convolution implementation that might be useful to implement a more specialised transition between IRs.
Another way of saying that would be “must not be called simultaneously from multiple threads”.
The new dsp::DryWetMixer class might be useful in that case. You could use render your convolutions using two separate Contexts (constructed from different AudioBlocks), and then use the DryWetMixer class to mix the two blocks.
Using the JUCE dsp building blocks like @reuk suggested is probably a good idea if you want to get a reliable and well performing solution ready with not too much work. I probably would start with such a solution to get something up and running first and then later, if you want to go for maximum performance, you could also set up your own implementation.
I recently implemented an in-houe uniformly partitioned convolution class with built-in crossfading. But beware, building a reliably & efficiently working fast convolution algorithm is a really complicated task. You should start with some well designed unit tests and running some profiling sessions once you have something working set up to see if you really gained as much performance as you assumed
In our case, under some circumstances continuous updates of the IR can occur, so rebuilding a new convolution instance and destroying the old one after fading would have been an unacceptable bottleneck and furthermore, running two full convolution instances in parallel would duplicate the work on the input side, e.g. both instances perform the FFT on the same input data and will shift their internal frequency domain delay lines in the same way. In this case, splitting up processing after the FDL stage is more efficient and re-using pre allocated buffers to write updated transfer function partitions to also showed a big performance gain. As a last optimization step, you can even try if there are more efficient spectral data layouts the fft library you use can exploit, which was the case for our implementation.
Now, even if your two or more impulse responses do not change, you can still enhance performance by sharing as much input processing steps as possible before splitting up the paths.
I was able to implement the two parallel convolutions and mix them using the DryWetMixer.
It is fully working, the only problem that I’m having is a jassert that I was not able to resolve.
JUCE Assertion failure in juce_DelayLine.h:165
I would really appreciate if you can help me on correcting my implementation.
As long as I understand is a problem of the mixer or the way I use it. I have two copies of the buffer and I do the following in the processBlock:
mixer.setWetLatency(convolver2.getLatency()-convolver1.getLatency()); //this results in zero latency because both convolvers have the same latency, verified
// process inBuffer1 with convolver1
juce::dsp::AudioBlock<float> inBlock1 (inBuffer1);
juce::dsp::ProcessContextReplacing<float> context1 (inBlock1);
// process inBuffer2 with convolver2
juce::dsp::AudioBlock<float> inBlock2 (inBuffer2);
juce::dsp::ProcessContextReplacing<float> context2 (inBlock2);
outBuffer = inBuffer2;
Does the Convolution::loadImpulseResponse do the job for rapid switching of IRs, e.g. selecting HRIRs with a rotary knob on the GUI thread?
I’m still hearing zipper noise when I did a implementation similar to this example
The Convolution does do internal crossfading between IRs, but it may not be suitable for very rapid IR changes. Once the crossfade starts, it can’t be interrupted - instead, it will wait until the end of the current crossfade to check for a new engine.
Even so, the Convolution shouldn’t produce zipper noise. Have you ruled out all possible sources of the zipper noise? For example, ensure you’re running a Release-configuration build, and ensure that there’s no memory allocation or waiting on locks on the audio thread.