1
0
Fork 0
mirror of https://github.com/juce-framework/JUCE.git synced 2026-01-10 23:44:24 +00:00
JUCE/modules/juce_dsp/frequency/juce_Convolution_test.cpp
reuk 68d30f9c8d Convolution: Compensate for volume changes when resampling IRs
When normalisation is disabled, the Convolution will now adjust the gain
of the IR using the ratio of the source and destination sampling rates.
This should keep the output level constant when the Convolution's
sampling rate is changed.
2021-03-11 15:19:31 +00:00

581 lines
22 KiB
C++

/*
==============================================================================
This file is part of the JUCE library.
Copyright (c) 2020 - Raw Material Software Limited
JUCE is an open source library subject to commercial or open-source
licensing.
By using JUCE, you agree to the terms of both the JUCE 6 End-User License
Agreement and JUCE Privacy Policy (both effective as of the 16th June 2020).
End User License Agreement: www.juce.com/juce-6-licence
Privacy Policy: www.juce.com/juce-privacy-policy
Or: You may also use this code under the terms of the GPL v3 (see
www.gnu.org/licenses).
JUCE IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER
EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE
DISCLAIMED.
==============================================================================
*/
#if JUCE_ENABLE_ALLOCATION_HOOKS
#define JUCE_FAIL_ON_ALLOCATION_IN_SCOPE const UnitTestAllocationChecker checker (*this)
#else
#define JUCE_FAIL_ON_ALLOCATION_IN_SCOPE
#endif
namespace juce
{
namespace dsp
{
namespace
{
class ConvolutionTest : public UnitTest
{
template <typename Callback>
static void nTimes (int n, Callback&& callback)
{
for (auto i = 0; i < n; ++i)
callback();
}
static AudioBuffer<float> makeRamp (int length)
{
AudioBuffer<float> result (1, length);
result.clear();
const auto writePtr = result.getWritePointer (0);
std::fill (writePtr, writePtr + length, 1.0f);
result.applyGainRamp (0, length, 1.0f, 0.0f);
return result;
}
static AudioBuffer<float> makeStereoRamp (int length)
{
AudioBuffer<float> result (2, length);
result.clear();
auto** channels = result.getArrayOfWritePointers();
std::for_each (channels, channels + result.getNumChannels(), [length] (auto* channel)
{
std::fill (channel, channel + length, 1.0f);
});
result.applyGainRamp (0, 0, length, 1.0f, 0.0f);
result.applyGainRamp (1, 0, length, 0.0f, 1.0f);
return result;
}
static void addDiracImpulse (const AudioBlock<float>& block)
{
block.clear();
for (size_t channel = 0; channel != block.getNumChannels(); ++channel)
block.setSample ((int) channel, 0, 1.0f);
}
void checkForNans (const AudioBlock<float>& block)
{
for (size_t channel = 0; channel != block.getNumChannels(); ++channel)
for (size_t sample = 0; sample != block.getNumSamples(); ++sample)
expect (! std::isnan (block.getSample ((int) channel, (int) sample)));
}
void checkAllChannelsNonZero (const AudioBlock<float>& block)
{
for (size_t i = 0; i != block.getNumChannels(); ++i)
{
const auto* channel = block.getChannelPointer (i);
expect (std::any_of (channel, channel + block.getNumSamples(), [] (float sample)
{
return sample != 0.0f;
}));
}
}
template <typename T>
void nonAllocatingExpectWithinAbsoluteError (const T& a, const T& b, const T& error)
{
expect (std::abs (a - b) < error);
}
enum class InitSequence { prepareThenLoad, loadThenPrepare };
void checkLatency (const Convolution& convolution, const Convolution::Latency& latency)
{
const auto reportedLatency = convolution.getLatency();
if (latency.latencyInSamples == 0)
expect (reportedLatency == 0);
expect (reportedLatency >= latency.latencyInSamples);
}
void checkLatency (const Convolution&, const Convolution::NonUniform&) {}
template <typename ConvolutionConfig>
void testConvolution (const ProcessSpec& spec,
const ConvolutionConfig& config,
const AudioBuffer<float>& ir,
double irSampleRate,
Convolution::Stereo stereo,
Convolution::Trim trim,
Convolution::Normalise normalise,
const AudioBlock<const float>& expectedResult,
InitSequence initSequence)
{
AudioBuffer<float> buffer (static_cast<int> (spec.numChannels),
static_cast<int> (spec.maximumBlockSize));
AudioBlock<float> block { buffer };
ProcessContextReplacing<float> context { block };
const auto numBlocksPerSecond = (int) std::ceil (spec.sampleRate / spec.maximumBlockSize);
const auto numBlocksForImpulse = (int) std::ceil ((double) expectedResult.getNumSamples() / spec.maximumBlockSize);
AudioBuffer<float> outBuffer (static_cast<int> (spec.numChannels),
numBlocksForImpulse * static_cast<int> (spec.maximumBlockSize));
Convolution convolution (config);
auto copiedIr = ir;
if (initSequence == InitSequence::loadThenPrepare)
convolution.loadImpulseResponse (std::move (copiedIr), irSampleRate, stereo, trim, normalise);
convolution.prepare (spec);
JUCE_FAIL_ON_ALLOCATION_IN_SCOPE;
if (initSequence == InitSequence::prepareThenLoad)
convolution.loadImpulseResponse (std::move (copiedIr), irSampleRate, stereo, trim, normalise);
checkLatency (convolution, config);
auto processBlocksWithDiracImpulse = [&]
{
for (auto i = 0; i != numBlocksForImpulse; ++i)
{
if (i == 0)
addDiracImpulse (block);
else
block.clear();
convolution.process (context);
for (auto c = 0; c != static_cast<int> (spec.numChannels); ++c)
{
outBuffer.copyFrom (c,
i * static_cast<int> (spec.maximumBlockSize),
block.getChannelPointer (static_cast<size_t> (c)),
static_cast<int> (spec.maximumBlockSize));
}
}
};
// If we load an IR while the convolution is already running, we'll need to wait
// for it to be loaded on a background thread
if (initSequence == InitSequence::prepareThenLoad)
{
const auto time = Time::getMillisecondCounter();
// Wait 10 seconds to load the impulse response
while (Time::getMillisecondCounter() - time < 10'000)
{
processBlocksWithDiracImpulse();
// Check if the impulse response was loaded
if (block.getSample (0, 1) != 0.0f)
break;
}
}
// At this point, our convolution should be loaded and the current IR size should
// match the expected result size
expect (convolution.getCurrentIRSize() == static_cast<int> (expectedResult.getNumSamples()));
// Make sure we get any smoothing out of the way
nTimes (numBlocksPerSecond, processBlocksWithDiracImpulse);
nTimes (5, [&]
{
processBlocksWithDiracImpulse();
const auto actualLatency = static_cast<size_t> (convolution.getLatency());
// The output should be the same as the IR
for (size_t c = 0; c != static_cast<size_t> (expectedResult.getNumChannels()); ++c)
{
for (size_t i = 0; i != static_cast<size_t> (expectedResult.getNumSamples()); ++i)
{
const auto equivalentSample = i + actualLatency;
if (static_cast<int> (equivalentSample) >= outBuffer.getNumSamples())
continue;
nonAllocatingExpectWithinAbsoluteError (outBuffer.getSample ((int) c, (int) equivalentSample),
expectedResult.getSample ((int) c, (int) i),
0.01f);
}
}
});
}
template <typename ConvolutionConfig>
void testConvolution (const ProcessSpec& spec,
const ConvolutionConfig& config,
const AudioBuffer<float>& ir,
double irSampleRate,
Convolution::Stereo stereo,
Convolution::Trim trim,
Convolution::Normalise normalise,
const AudioBlock<const float>& expectedResult)
{
for (const auto sequence : { InitSequence::prepareThenLoad, InitSequence::loadThenPrepare })
testConvolution (spec, config, ir, irSampleRate, stereo, trim, normalise, expectedResult, sequence);
}
public:
ConvolutionTest()
: UnitTest ("Convolution", UnitTestCategories::dsp)
{}
void runTest() override
{
const ProcessSpec spec { 44100.0, 512, 2 };
AudioBuffer<float> buffer (static_cast<int> (spec.numChannels),
static_cast<int> (spec.maximumBlockSize));
AudioBlock<float> block { buffer };
ProcessContextReplacing<float> context { block };
const auto impulseData = []
{
Random random;
AudioBuffer<float> result (2, 1000);
for (auto channel = 0; channel != result.getNumChannels(); ++channel)
for (auto sample = 0; sample != result.getNumSamples(); ++sample)
result.setSample (channel, sample, random.nextFloat());
return result;
}();
beginTest ("Impulse responses can be loaded without allocating on the audio thread");
{
Convolution convolution;
convolution.prepare (spec);
auto copy = impulseData;
JUCE_FAIL_ON_ALLOCATION_IN_SCOPE;
nTimes (100, [&]
{
convolution.loadImpulseResponse (std::move (copy),
1000,
Convolution::Stereo::yes,
Convolution::Trim::yes,
Convolution::Normalise::no);
addDiracImpulse (block);
convolution.process (context);
checkForNans (block);
});
}
beginTest ("Convolution can be reset without allocating on the audio thread");
{
Convolution convolution;
convolution.prepare (spec);
auto copy = impulseData;
convolution.loadImpulseResponse (std::move (copy),
1000,
Convolution::Stereo::yes,
Convolution::Trim::yes,
Convolution::Normalise::yes);
JUCE_FAIL_ON_ALLOCATION_IN_SCOPE;
nTimes (100, [&]
{
addDiracImpulse (block);
convolution.reset();
convolution.process (context);
convolution.reset();
});
checkForNans (block);
}
beginTest ("Completely empty IRs don't crash");
{
AudioBuffer<float> emptyBuffer;
Convolution convolution;
convolution.prepare (spec);
auto copy = impulseData;
convolution.loadImpulseResponse (std::move (copy),
2000,
Convolution::Stereo::yes,
Convolution::Trim::yes,
Convolution::Normalise::yes);
JUCE_FAIL_ON_ALLOCATION_IN_SCOPE;
nTimes (100, [&]
{
addDiracImpulse (block);
convolution.reset();
convolution.process (context);
convolution.reset();
});
checkForNans (block);
}
beginTest ("Convolutions can cope with a change in samplerate and blocksize");
{
Convolution convolution;
auto copy = impulseData;
convolution.loadImpulseResponse (std::move (copy),
2000,
Convolution::Stereo::yes,
Convolution::Trim::no,
Convolution::Normalise::yes);
const dsp::ProcessSpec specs[] = { { 96'000.0, 1024, 2 },
{ 48'000.0, 512, 2 },
{ 44'100.0, 256, 2 } };
for (const auto& thisSpec : specs)
{
convolution.prepare (thisSpec);
expectWithinAbsoluteError ((double) convolution.getCurrentIRSize(),
thisSpec.sampleRate * 0.5,
1.0);
juce::AudioBuffer<float> thisBuffer ((int) thisSpec.numChannels,
(int) thisSpec.maximumBlockSize);
AudioBlock<float> thisBlock { thisBuffer };
ProcessContextReplacing<float> thisContext { thisBlock };
nTimes (100, [&]
{
addDiracImpulse (thisBlock);
convolution.process (thisContext);
checkForNans (thisBlock);
checkAllChannelsNonZero (thisBlock);
});
}
}
beginTest ("Short uniform convolutions work");
{
const auto ramp = makeRamp (static_cast<int> (spec.maximumBlockSize) / 2);
testConvolution (spec,
Convolution::Latency { 0 },
ramp,
spec.sampleRate,
Convolution::Stereo::yes,
Convolution::Trim::yes,
Convolution::Normalise::no,
ramp);
}
beginTest ("Longer uniform convolutions work");
{
const auto ramp = makeRamp (static_cast<int> (spec.maximumBlockSize) * 8);
testConvolution (spec,
Convolution::Latency { 0 },
ramp,
spec.sampleRate,
Convolution::Stereo::yes,
Convolution::Trim::yes,
Convolution::Normalise::no,
ramp);
}
beginTest ("Normalisation works");
{
const auto ramp = makeRamp (static_cast<int> (spec.maximumBlockSize) * 8);
auto copy = ramp;
const auto channels = copy.getArrayOfWritePointers();
const auto numChannels = copy.getNumChannels();
const auto numSamples = copy.getNumSamples();
const auto factor = 0.125f / std::sqrt (std::accumulate (channels, channels + numChannels, 0.0f,
[numSamples] (auto max, auto* channel)
{
return juce::jmax (max, std::accumulate (channel, channel + numSamples, 0.0f,
[] (auto sum, auto sample)
{
return sum + sample * sample;
}));
}));
std::for_each (channels, channels + numChannels, [factor, numSamples] (auto* channel)
{
FloatVectorOperations::multiply (channel, factor, numSamples);
});
testConvolution (spec,
Convolution::Latency { 0 },
ramp,
spec.sampleRate,
Convolution::Stereo::yes,
Convolution::Trim::yes,
Convolution::Normalise::yes,
copy);
}
beginTest ("Stereo convolutions work");
{
const auto ramp = makeStereoRamp (static_cast<int> (spec.maximumBlockSize) * 5);
testConvolution (spec,
Convolution::Latency { 0 },
ramp,
spec.sampleRate,
Convolution::Stereo::yes,
Convolution::Trim::yes,
Convolution::Normalise::no,
ramp);
}
beginTest ("Stereo IRs only use first channel if stereo is disabled");
{
const auto length = static_cast<int> (spec.maximumBlockSize) * 5;
const auto ramp = makeStereoRamp (length);
const float* channels[] { ramp.getReadPointer (0), ramp.getReadPointer (0) };
testConvolution (spec,
Convolution::Latency { 0 },
ramp,
spec.sampleRate,
Convolution::Stereo::no,
Convolution::Trim::yes,
Convolution::Normalise::no,
AudioBlock<const float> (channels, numElementsInArray (channels), length));
}
beginTest ("IRs with extra silence are trimmed appropriately");
{
const auto length = static_cast<int> (spec.maximumBlockSize) * 3;
const auto ramp = makeRamp (length);
AudioBuffer<float> paddedRamp (ramp.getNumChannels(), ramp.getNumSamples() * 2);
paddedRamp.clear();
const auto offset = (paddedRamp.getNumSamples() - ramp.getNumSamples()) / 2;
for (auto channel = 0; channel != ramp.getNumChannels(); ++channel)
paddedRamp.copyFrom (channel, offset, ramp.getReadPointer (channel), length);
testConvolution (spec,
Convolution::Latency { 0 },
paddedRamp,
spec.sampleRate,
Convolution::Stereo::no,
Convolution::Trim::yes,
Convolution::Normalise::no,
ramp);
}
beginTest ("IRs are resampled if their sample rate is different to the playback rate");
{
for (const auto resampleRatio : { 0.1, 0.5, 2.0, 10.0 })
{
const auto length = static_cast<int> (spec.maximumBlockSize) * 2;
const auto ramp = makeStereoRamp (length);
const auto resampled = [&]
{
AudioBuffer<float> original = ramp;
MemoryAudioSource memorySource (original, false);
ResamplingAudioSource resamplingSource (&memorySource, false, original.getNumChannels());
const auto finalSize = roundToInt (original.getNumSamples() / resampleRatio);
resamplingSource.setResamplingRatio (resampleRatio);
resamplingSource.prepareToPlay (finalSize, spec.sampleRate * resampleRatio);
AudioBuffer<float> result (original.getNumChannels(), finalSize);
resamplingSource.getNextAudioBlock ({ &result, 0, result.getNumSamples() });
result.applyGain ((float) resampleRatio);
return result;
}();
testConvolution (spec,
Convolution::Latency { 0 },
ramp,
spec.sampleRate * resampleRatio,
Convolution::Stereo::yes,
Convolution::Trim::yes,
Convolution::Normalise::no,
resampled);
}
}
beginTest ("Non-uniform convolutions work");
{
const auto ramp = makeRamp (static_cast<int> (spec.maximumBlockSize) * 8);
for (auto headSize : { spec.maximumBlockSize / 2, spec.maximumBlockSize, spec.maximumBlockSize * 9 })
{
testConvolution (spec,
Convolution::NonUniform { static_cast<int> (headSize) },
ramp,
spec.sampleRate,
Convolution::Stereo::yes,
Convolution::Trim::yes,
Convolution::Normalise::no,
ramp);
}
}
beginTest ("Convolutions with latency work");
{
const auto ramp = makeRamp (static_cast<int> (spec.maximumBlockSize) * 8);
using BlockSize = decltype (spec.maximumBlockSize);
for (auto latency : { static_cast<BlockSize> (0),
spec.maximumBlockSize / 3,
spec.maximumBlockSize,
spec.maximumBlockSize * 2,
static_cast<BlockSize> (spec.maximumBlockSize * 2.5) })
{
testConvolution (spec,
Convolution::Latency { static_cast<int> (latency) },
ramp,
spec.sampleRate,
Convolution::Stereo::yes,
Convolution::Trim::yes,
Convolution::Normalise::no,
ramp);
}
}
}
};
ConvolutionTest convolutionUnitTest;
}
}
}
#undef JUCE_FAIL_ON_ALLOCATION_IN_SCOPE