/* ============================================================================== 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 static void nTimes (int n, Callback&& callback) { for (auto i = 0; i < n; ++i) callback(); } static AudioBuffer makeRamp (int length) { AudioBuffer 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 makeStereoRamp (int length) { AudioBuffer 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& block) { block.clear(); for (size_t channel = 0; channel != block.getNumChannels(); ++channel) block.setSample ((int) channel, 0, 1.0f); } void checkForNans (const AudioBlock& 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& 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 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 void testConvolution (const ProcessSpec& spec, const ConvolutionConfig& config, const AudioBuffer& ir, double irSampleRate, Convolution::Stereo stereo, Convolution::Trim trim, Convolution::Normalise normalise, const AudioBlock& expectedResult, InitSequence initSequence) { AudioBuffer buffer (static_cast (spec.numChannels), static_cast (spec.maximumBlockSize)); AudioBlock block { buffer }; ProcessContextReplacing context { block }; const auto numBlocksPerSecond = (int) std::ceil (spec.sampleRate / spec.maximumBlockSize); const auto numBlocksForImpulse = (int) std::ceil ((double) expectedResult.getNumSamples() / spec.maximumBlockSize); AudioBuffer outBuffer (static_cast (spec.numChannels), numBlocksForImpulse * static_cast (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 (spec.numChannels); ++c) { outBuffer.copyFrom (c, i * static_cast (spec.maximumBlockSize), block.getChannelPointer (static_cast (c)), static_cast (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 (expectedResult.getNumSamples())); // Make sure we get any smoothing out of the way nTimes (numBlocksPerSecond, processBlocksWithDiracImpulse); nTimes (5, [&] { processBlocksWithDiracImpulse(); const auto actualLatency = static_cast (convolution.getLatency()); // The output should be the same as the IR for (size_t c = 0; c != static_cast (expectedResult.getNumChannels()); ++c) { for (size_t i = 0; i != static_cast (expectedResult.getNumSamples()); ++i) { const auto equivalentSample = i + actualLatency; if (static_cast (equivalentSample) >= outBuffer.getNumSamples()) continue; nonAllocatingExpectWithinAbsoluteError (outBuffer.getSample ((int) c, (int) equivalentSample), expectedResult.getSample ((int) c, (int) i), 0.01f); } } }); } template void testConvolution (const ProcessSpec& spec, const ConvolutionConfig& config, const AudioBuffer& ir, double irSampleRate, Convolution::Stereo stereo, Convolution::Trim trim, Convolution::Normalise normalise, const AudioBlock& 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 buffer (static_cast (spec.numChannels), static_cast (spec.maximumBlockSize)); AudioBlock block { buffer }; ProcessContextReplacing context { block }; const auto impulseData = [] { Random random; AudioBuffer 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 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 thisBuffer ((int) thisSpec.numChannels, (int) thisSpec.maximumBlockSize); AudioBlock thisBlock { thisBuffer }; ProcessContextReplacing thisContext { thisBlock }; nTimes (100, [&] { addDiracImpulse (thisBlock); convolution.process (thisContext); checkForNans (thisBlock); checkAllChannelsNonZero (thisBlock); }); } } beginTest ("Short uniform convolutions work"); { const auto ramp = makeRamp (static_cast (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 (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 (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 (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 (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 (channels, numElementsInArray (channels), length)); } beginTest ("IRs with extra silence are trimmed appropriately"); { const auto length = static_cast (spec.maximumBlockSize) * 3; const auto ramp = makeRamp (length); AudioBuffer 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 (spec.maximumBlockSize) * 2; const auto ramp = makeStereoRamp (length); const auto resampled = [&] { AudioBuffer 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 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 (spec.maximumBlockSize) * 8); for (auto headSize : { spec.maximumBlockSize / 2, spec.maximumBlockSize, spec.maximumBlockSize * 9 }) { testConvolution (spec, Convolution::NonUniform { static_cast (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 (spec.maximumBlockSize) * 8); using BlockSize = decltype (spec.maximumBlockSize); for (auto latency : { static_cast (0), spec.maximumBlockSize / 3, spec.maximumBlockSize, spec.maximumBlockSize * 2, static_cast (spec.maximumBlockSize * 2.5) }) { testConvolution (spec, Convolution::Latency { static_cast (latency) }, ramp, spec.sampleRate, Convolution::Stereo::yes, Convolution::Trim::yes, Convolution::Normalise::no, ramp); } } } }; ConvolutionTest convolutionUnitTest; } } } #undef JUCE_FAIL_ON_ALLOCATION_IN_SCOPE