From febc00e5ad9f65f5e6a93bc6c649dfab05a62792 Mon Sep 17 00:00:00 2001 From: Yves Vogl Date: Tue, 14 Jul 2026 16:25:50 +0200 Subject: [PATCH 1/6] feat(dsp): add inter-IR phase alignment utility Pure, off-audio-thread onset-detection and time-shift helpers used to align a secondary impulse response's transient onset to a primary one's before the two are blended together, preventing comb-filtering from timing mismatches between independently captured IRs. --- src/dsp/IrAlignment.cpp | 97 +++++++++++++++++++++++++++++++++++++++++ src/dsp/IrAlignment.h | 47 ++++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 src/dsp/IrAlignment.cpp create mode 100644 src/dsp/IrAlignment.h diff --git a/src/dsp/IrAlignment.cpp b/src/dsp/IrAlignment.cpp new file mode 100644 index 0000000..9ae2ae2 --- /dev/null +++ b/src/dsp/IrAlignment.cpp @@ -0,0 +1,97 @@ +#include "IrAlignment.h" + +#include +#include + +namespace IrAlignment +{ + int detectOnsetSample (const juce::AudioBuffer& buffer, float thresholdRelativeToPeak) noexcept + { + const auto numChannels = buffer.getNumChannels(); + const auto numSamples = buffer.getNumSamples(); + + if (numChannels <= 0 || numSamples <= 0) + return 0; + + float peak = 0.0f; + + for (int channel = 0; channel < numChannels; ++channel) + { + const auto* data = buffer.getReadPointer (channel); + + for (int sample = 0; sample < numSamples; ++sample) + peak = juce::jmax (peak, std::abs (data[sample])); + } + + // A silent/all-zero buffer has no meaningful onset; treat it as + // starting at sample 0 (matches the default delta IR's own onset). + if (peak <= std::numeric_limits::epsilon()) + return 0; + + const auto threshold = peak * thresholdRelativeToPeak; + + for (int sample = 0; sample < numSamples; ++sample) + for (int channel = 0; channel < numChannels; ++channel) + if (std::abs (buffer.getSample (channel, sample)) >= threshold) + return sample; + + return 0; + } + + juce::AudioBuffer shiftBySamples (const juce::AudioBuffer& buffer, int shiftSamples) + { + if (shiftSamples == 0) + return buffer; // AudioBuffer's copy constructor deep-copies. + + const auto numChannels = buffer.getNumChannels(); + const auto numSamples = buffer.getNumSamples(); + + if (numChannels <= 0 || numSamples <= 0) + return buffer; + + if (shiftSamples > 0) + { + // Delay: prepend `shiftSamples` zero samples ahead of the + // existing content. + juce::AudioBuffer shifted (numChannels, numSamples + shiftSamples); + shifted.clear(); + + for (int channel = 0; channel < numChannels; ++channel) + shifted.copyFrom (channel, shiftSamples, buffer, channel, 0, numSamples); + + return shifted; + } + + // Advance: drop the leading -shiftSamples samples, permanently + // discarding them - clamped so at least one sample always survives, + // even for a pathological shift larger than the buffer itself. + const auto samplesToDrop = juce::jmin (-shiftSamples, numSamples - 1); + const auto remaining = numSamples - samplesToDrop; + + juce::AudioBuffer shifted (numChannels, remaining); + + for (int channel = 0; channel < numChannels; ++channel) + shifted.copyFrom (channel, 0, buffer, channel, samplesToDrop, remaining); + + return shifted; + } + + juce::AudioBuffer alignOnsetToReference (const juce::AudioBuffer& target, + double targetSampleRate, + int referenceOnsetSample, + double referenceSampleRate) + { + if (targetSampleRate <= 0.0 || referenceSampleRate <= 0.0) + return target; + + const auto targetOnsetSample = detectOnsetSample (target); + + const auto referenceOnsetSeconds = static_cast (referenceOnsetSample) / referenceSampleRate; + const auto targetOnsetSeconds = static_cast (targetOnsetSample) / targetSampleRate; + + const auto shiftSeconds = referenceOnsetSeconds - targetOnsetSeconds; + const auto shiftSamples = static_cast (std::lround (shiftSeconds * targetSampleRate)); + + return shiftBySamples (target, shiftSamples); + } +} diff --git a/src/dsp/IrAlignment.h b/src/dsp/IrAlignment.h new file mode 100644 index 0000000..1deb4d4 --- /dev/null +++ b/src/dsp/IrAlignment.h @@ -0,0 +1,47 @@ +#pragma once + +#include + +// Small, pure (allocation-only-off-the-audio-thread) helper functions used to +// implement inter-IR phase alignment: when a second impulse response (IR B) +// is loaded to be blended against the primary one (IR A), its transient +// onset is shifted in time to line up with IR A's onset, so that blending +// the two convolution outputs doesn't introduce comb-filtering from a timing +// mismatch between them (two IRs with, say, a 3ms offset between their +// direct-sound arrivals will partially cancel across a wide band when +// crossfaded). +// +// None of these functions are real-time safe (they allocate) - callers must +// only invoke them off the audio thread, the same contract as +// CabConvolutionEngine::setImpulseResponse()/setImpulseResponseB(). +namespace IrAlignment +{ + // Returns the index of the first sample (checked across all channels) + // whose absolute value reaches `thresholdRelativeToPeak` of the buffer's + // own peak absolute sample - a standard, simple onset-detection + // heuristic (deliberately not a full cross-correlation search: cabinet + // IRs have a single dominant direct-sound transient, so a relative- + // threshold crossing on the buffer itself is sufficient and much + // cheaper). Returns 0 for an empty, silent, or all-zero buffer. + int detectOnsetSample (const juce::AudioBuffer& buffer, float thresholdRelativeToPeak = 0.2f) noexcept; + + // Shifts `buffer` in time by `shiftSamples`, at the buffer's own sample + // rate: a positive shift delays it (prepends `shiftSamples` zero + // samples, growing the buffer), a negative shift advances it (drops the + // leading `-shiftSamples` samples, shrinking the buffer - clamped so at + // least one sample always remains). A zero shift returns an unmodified + // copy. Always returns a newly allocated buffer; never mutates `buffer`. + juce::AudioBuffer shiftBySamples (const juce::AudioBuffer& buffer, int shiftSamples); + + // Detects `target`'s onset and returns a copy of `target` shifted so + // that onset lands at the same *time* (not raw sample index - the two + // IRs may have been captured, and may be loaded, at different sample + // rates) as `referenceOnsetSample` measured at `referenceSampleRate`. + // This is the entry point CabConvolutionEngine::setImpulseResponseB() + // uses to align a newly loaded IR B against the onset already recorded + // for IR A. + juce::AudioBuffer alignOnsetToReference (const juce::AudioBuffer& target, + double targetSampleRate, + int referenceOnsetSample, + double referenceSampleRate); +} From d04c4bec60e13daec77d2151eed0f93fff91e25b Mon Sep 17 00:00:00 2001 From: Yves Vogl Date: Tue, 14 Jul 2026 16:26:00 +0200 Subject: [PATCH 2/6] feat(dsp): add IR Blend and simulated mic Distance - CabConvolutionEngine now owns a second convolution slot (IR B) and crossfades it against IR A via a new IR Blend parameter (default 0%, bit-identical to the previous single-IR signal path). Loading IR B phase-aligns it against IR A's onset first. - Adds a Distance parameter emulating mic-to-cab distance via a proximity-effect low-shelf and an air-absorption high-shelf, applied post-convolution/pre-LoCut. Defaults to 0% ("off"), using the same bypass-at-the-extreme pattern as LoCut/HiCut so the default state stays a bit-exact passthrough. - PluginProcessor/PluginEditor wire both new parameters end-to-end, including IR B file load/default controls, state save/load, and latency reporting across both convolution slots. --- src/PluginEditor.cpp | 62 ++++++++++- src/PluginEditor.h | 9 ++ src/PluginProcessor.cpp | 71 ++++++++++++- src/PluginProcessor.h | 8 ++ src/dsp/CabConvolutionEngine.cpp | 176 +++++++++++++++++++++++++++++-- src/dsp/CabConvolutionEngine.h | 132 +++++++++++++++++++---- src/params/ParameterIds.h | 16 ++- src/params/ParameterLayout.cpp | 21 ++++ 8 files changed, 455 insertions(+), 40 deletions(-) diff --git a/src/PluginEditor.cpp b/src/PluginEditor.cpp index b4a0e36..c1a16b6 100644 --- a/src/PluginEditor.cpp +++ b/src/PluginEditor.cpp @@ -8,11 +8,11 @@ namespace constexpr int textBoxHeight = 20; constexpr int labelHeight = 20; constexpr int margin = 20; - constexpr int numKnobs = 4; + constexpr int numKnobs = 6; constexpr int irRowHeight = 30; constexpr int buttonWidth = 100; constexpr int editorWidth = margin * 2 + numKnobs * knobSize + (numKnobs - 1) * margin; - constexpr int editorHeight = margin * 3 + irRowHeight + labelHeight + knobSize + textBoxHeight; + constexpr int editorHeight = margin * 4 + irRowHeight * 2 + labelHeight + knobSize + textBoxHeight; } NaveAudioProcessorEditor::NaveAudioProcessorEditor (NaveAudioProcessor& processorToEdit) @@ -21,6 +21,8 @@ NaveAudioProcessorEditor::NaveAudioProcessorEditor (NaveAudioProcessor& processo { configureKnob (loCutKnob, ParamIDs::loCut, "LoCut"); configureKnob (hiCutKnob, ParamIDs::hiCut, "HiCut"); + configureKnob (blendKnob, ParamIDs::irBlend, "IR Blend"); + configureKnob (distanceKnob, ParamIDs::micDistance, "Distance"); configureKnob (mixKnob, ParamIDs::mix, "Mix"); configureKnob (levelKnob, ParamIDs::level, "Level"); @@ -38,6 +40,20 @@ NaveAudioProcessorEditor::NaveAudioProcessorEditor (NaveAudioProcessor& processo }; addAndMakeVisible (defaultIrButton); + irNameLabelB.setJustificationType (juce::Justification::centredLeft); + addAndMakeVisible (irNameLabelB); + updateIrLabelB(); + + loadIrButtonB.onClick = [this] { chooseImpulseResponseFileB(); }; + addAndMakeVisible (loadIrButtonB); + + defaultIrButtonB.onClick = [this] + { + audioProcessor.loadDefaultImpulseResponseB(); + updateIrLabelB(); + }; + addAndMakeVisible (defaultIrButtonB); + setResizable (false, false); setSize (editorWidth, editorHeight); } @@ -65,10 +81,18 @@ void NaveAudioProcessorEditor::updateIrLabel() { const auto irPath = audioProcessor.getCurrentIrFilePath(); - irNameLabel.setText (irPath.isEmpty() ? "Default (no IR loaded)" : juce::File (irPath).getFileName(), + irNameLabel.setText (irPath.isEmpty() ? "IR A: Default (no IR loaded)" : "IR A: " + juce::File (irPath).getFileName(), juce::dontSendNotification); } +void NaveAudioProcessorEditor::updateIrLabelB() +{ + const auto irPath = audioProcessor.getCurrentIrFilePathB(); + + irNameLabelB.setText (irPath.isEmpty() ? "IR B: Default (no IR loaded)" : "IR B: " + juce::File (irPath).getFileName(), + juce::dontSendNotification); +} + void NaveAudioProcessorEditor::chooseImpulseResponseFile() { activeFileChooser = std::make_unique ( @@ -90,6 +114,27 @@ void NaveAudioProcessorEditor::chooseImpulseResponseFile() }); } +void NaveAudioProcessorEditor::chooseImpulseResponseFileB() +{ + activeFileChooserB = std::make_unique ( + "Load a secondary cabinet impulse response (IR B)...", + juce::File(), + "*.wav;*.aiff;*.aif"); + + constexpr auto flags = juce::FileBrowserComponent::openMode | juce::FileBrowserComponent::canSelectFiles; + + activeFileChooserB->launchAsync (flags, [this] (const juce::FileChooser& chooser) + { + const auto file = chooser.getResult(); + + if (file.existsAsFile()) + { + audioProcessor.loadImpulseResponseFromFileB (file); + updateIrLabelB(); + } + }); +} + void NaveAudioProcessorEditor::resized() { auto bounds = getLocalBounds().reduced (margin); @@ -101,11 +146,20 @@ void NaveAudioProcessorEditor::resized() irRow.removeFromRight (margin / 2); irNameLabel.setBounds (irRow); + bounds.removeFromTop (margin / 2); + + auto irRowB = bounds.removeFromTop (irRowHeight); + defaultIrButtonB.setBounds (irRowB.removeFromRight (buttonWidth)); + irRowB.removeFromRight (margin / 2); + loadIrButtonB.setBounds (irRowB.removeFromRight (buttonWidth)); + irRowB.removeFromRight (margin / 2); + irNameLabelB.setBounds (irRowB); + bounds.removeFromTop (margin); bounds.removeFromTop (labelHeight); // room for the attached labels above each knob const auto slotWidth = bounds.getWidth() / numKnobs; - for (auto* knob : { &loCutKnob, &hiCutKnob, &mixKnob, &levelKnob }) + for (auto* knob : { &loCutKnob, &hiCutKnob, &blendKnob, &distanceKnob, &mixKnob, &levelKnob }) knob->slider.setBounds (bounds.removeFromLeft (slotWidth).reduced (margin / 2, 0)); } diff --git a/src/PluginEditor.h b/src/PluginEditor.h index b055154..b394606 100644 --- a/src/PluginEditor.h +++ b/src/PluginEditor.h @@ -30,12 +30,16 @@ class NaveAudioProcessorEditor final : public juce::AudioProcessorEditor void configureKnob (Knob& knob, const juce::String& parameterId, const juce::String& labelText); void updateIrLabel(); + void updateIrLabelB(); void chooseImpulseResponseFile(); + void chooseImpulseResponseFileB(); NaveAudioProcessor& audioProcessor; Knob loCutKnob; Knob hiCutKnob; + Knob blendKnob; + Knob distanceKnob; Knob mixKnob; Knob levelKnob; @@ -43,7 +47,12 @@ class NaveAudioProcessorEditor final : public juce::AudioProcessorEditor juce::TextButton loadIrButton { "Load IR..." }; juce::TextButton defaultIrButton { "Default" }; + juce::Label irNameLabelB; + juce::TextButton loadIrButtonB { "Load IR B..." }; + juce::TextButton defaultIrButtonB { "Default" }; + std::unique_ptr activeFileChooser; + std::unique_ptr activeFileChooserB; JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (NaveAudioProcessorEditor) }; diff --git a/src/PluginProcessor.cpp b/src/PluginProcessor.cpp index dc6d938..7ccb02a 100644 --- a/src/PluginProcessor.cpp +++ b/src/PluginProcessor.cpp @@ -18,11 +18,15 @@ NaveAudioProcessor::NaveAudioProcessor() hiCutHz = apvts.getRawParameterValue (ParamIDs::hiCut); mixPercent = apvts.getRawParameterValue (ParamIDs::mix); levelDb = apvts.getRawParameterValue (ParamIDs::level); + irBlendPercent = apvts.getRawParameterValue (ParamIDs::irBlend); + micDistancePercent = apvts.getRawParameterValue (ParamIDs::micDistance); jassert (loCutHz != nullptr); jassert (hiCutHz != nullptr); jassert (mixPercent != nullptr); jassert (levelDb != nullptr); + jassert (irBlendPercent != nullptr); + jassert (micDistancePercent != nullptr); } NaveAudioProcessor::~NaveAudioProcessor() = default; @@ -98,6 +102,8 @@ void NaveAudioProcessor::prepareToPlay (double sampleRate, int samplesPerBlock) engine.setHiCutHz (hiCutHz->load (std::memory_order_relaxed)); engine.setMixProportion (mixPercent->load (std::memory_order_relaxed) * 0.01f); engine.setLevelDb (levelDb->load (std::memory_order_relaxed)); + engine.setBlendProportion (irBlendPercent->load (std::memory_order_relaxed) * 0.01f); + engine.setDistancePercent (micDistancePercent->load (std::memory_order_relaxed)); engine.prepare (spec); @@ -151,6 +157,8 @@ void NaveAudioProcessor::processBlock (juce::AudioBuffer& buffer, juce::M engine.setHiCutHz (hiCutHz->load (std::memory_order_relaxed)); engine.setMixProportion (mixPercent->load (std::memory_order_relaxed) * 0.01f); engine.setLevelDb (levelDb->load (std::memory_order_relaxed)); + engine.setBlendProportion (irBlendPercent->load (std::memory_order_relaxed) * 0.01f); + engine.setDistancePercent (micDistancePercent->load (std::memory_order_relaxed)); juce::dsp::AudioBlock block (buffer); engine.process (block); @@ -219,6 +227,47 @@ juce::String NaveAudioProcessor::getCurrentIrFilePath() const return apvts.state.getProperty (ParamIDs::irFilePathProperty, juce::String()).toString(); } +bool NaveAudioProcessor::loadImpulseResponseFromFileB (const juce::File& irFile) +{ + if (! irFile.existsAsFile()) + return false; + + juce::AudioFormatManager formatManager; + formatManager.registerBasicFormats(); + + const std::unique_ptr reader (formatManager.createReaderFor (irFile)); + + if (reader == nullptr) + return false; + + const auto numChannels = juce::jlimit (1, 2, static_cast (reader->numChannels)); + const auto numSamples = static_cast (juce::jmin (reader->lengthInSamples, + static_cast (std::numeric_limits::max()))); + + if (numSamples <= 0) + return false; + + juce::AudioBuffer irBuffer (numChannels, numSamples); + reader->read (&irBuffer, 0, numSamples, 0, true, true); + + engine.setImpulseResponseB (std::move (irBuffer), reader->sampleRate); + + apvts.state.setProperty (ParamIDs::irFilePathBProperty, irFile.getFullPathName(), nullptr); + + return true; +} + +void NaveAudioProcessor::loadDefaultImpulseResponseB() +{ + engine.loadDefaultImpulseResponseB(); + apvts.state.setProperty (ParamIDs::irFilePathBProperty, juce::String(), nullptr); +} + +juce::String NaveAudioProcessor::getCurrentIrFilePathB() const +{ + return apvts.state.getProperty (ParamIDs::irFilePathBProperty, juce::String()).toString(); +} + //============================================================================== void NaveAudioProcessor::getStateInformation (juce::MemoryBlock& destData) { @@ -238,7 +287,11 @@ void NaveAudioProcessor::setStateInformation (const void* data, int sizeInBytes) // setStateInformation() is a session/preset-load operation, never called // from the audio thread, so the blocking file I/O in - // loadImpulseResponseFromFile() is safe here. + // loadImpulseResponseFromFile()/loadImpulseResponseFromFileB() is safe + // here. IR A is restored first so it becomes the reference IR B's phase + // alignment is computed against (see CabConvolutionEngine:: + // setImpulseResponseB()), matching how the two are loaded during normal + // interactive use (IR A almost always loaded before IR B). const auto irPath = getCurrentIrFilePath(); if (irPath.isNotEmpty()) @@ -254,6 +307,22 @@ void NaveAudioProcessor::setStateInformation (const void* data, int sizeInBytes) { loadDefaultImpulseResponse(); } + + const auto irPathB = getCurrentIrFilePathB(); + + if (irPathB.isNotEmpty()) + { + const juce::File irFileB (irPathB); + + if (irFileB.existsAsFile()) + loadImpulseResponseFromFileB (irFileB); + else + loadDefaultImpulseResponseB(); // stored IR is missing; fall back cleanly + } + else + { + loadDefaultImpulseResponseB(); + } } //============================================================================== diff --git a/src/PluginProcessor.h b/src/PluginProcessor.h index ba11d32..c3931c5 100644 --- a/src/PluginProcessor.h +++ b/src/PluginProcessor.h @@ -71,6 +71,12 @@ class NaveAudioProcessor final : public juce::AudioProcessor // thread (editor display) at any time. juce::String getCurrentIrFilePath() const; + // Same three operations as above, for the secondary IR slot (IR B) used + // by the IR Blend parameter. + bool loadImpulseResponseFromFileB (const juce::File& irFile); + void loadDefaultImpulseResponseB(); + juce::String getCurrentIrFilePathB() const; + juce::AudioProcessorValueTreeState apvts; private: @@ -83,6 +89,8 @@ class NaveAudioProcessor final : public juce::AudioProcessor std::atomic* hiCutHz = nullptr; std::atomic* mixPercent = nullptr; std::atomic* levelDb = nullptr; + std::atomic* irBlendPercent = nullptr; + std::atomic* micDistancePercent = nullptr; JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (NaveAudioProcessor) }; diff --git a/src/dsp/CabConvolutionEngine.cpp b/src/dsp/CabConvolutionEngine.cpp index c6e4a65..dc9cd92 100644 --- a/src/dsp/CabConvolutionEngine.cpp +++ b/src/dsp/CabConvolutionEngine.cpp @@ -1,4 +1,5 @@ #include "CabConvolutionEngine.h" +#include "IrAlignment.h" namespace { @@ -38,17 +39,30 @@ void CabConvolutionEngine::prepare (const juce::dsp::ProcessSpec& spec) // change, etc.) juce::dsp::Convolution retains and automatically // re-resamples whatever IR was most recently loaded, so nothing further // needs to be done here - see the class-level docs on - // juce::dsp::Convolution::prepare() for this contract. + // juce::dsp::Convolution::prepare() for this contract. Same story for + // slot B. if (! anyImpulseResponseLoaded) loadDefaultImpulseResponse(); + if (! anyImpulseResponseBLoaded) + loadDefaultImpulseResponseB(); + // Per juce::dsp::Convolution's documented contract: loadImpulseResponse() // must be called *before* prepare() for that IR to be guaranteed active // during the very first process() call. convolution.prepare (spec); + convolutionB.prepare (spec); loCutFilter.prepare (spec); hiCutFilter.prepare (spec); + distanceLowShelfFilter.prepare (spec); + distanceHighShelfFilter.prepare (spec); + + // Not real-time safe (allocates) - fine here, prepare() is never called + // from the audio thread. Never resized again in process(). + scratchBuffer.setSize (juce::jmax (1, numChannelsPrepared), + static_cast (spec.maximumBlockSize), + false, false, true); // Prime the target gain from lastLevelDb *before* prepare() (which // internally calls reset(), snapping current == target) - otherwise a @@ -60,7 +74,11 @@ void CabConvolutionEngine::prepare (const juce::dsp::ProcessSpec& spec) dryWetMixer.prepare (spec); - latencySamples = convolution.getLatency(); + // Both convolution slots always use the same (default, zero-latency) + // configuration, so in practice these are always equal - computed + // generically via jmax so the dry path stays correctly compensated even + // if a slot's configuration ever changes independently in future. + latencySamples = juce::jmax (convolution.getLatency(), convolutionB.getLatency()); dryWetMixer.setWetLatency (static_cast (latencySamples)); // juce::dsp::DryWetMixer defaults its internal mix to fully wet (1.0) @@ -83,6 +101,10 @@ void CabConvolutionEngine::prepare (const juce::dsp::ProcessSpec& spec) hiCutFrequencySmoothed.setCurrentAndTargetValue (lastHiCutHz); mixSmoothed.reset (sampleRate, smoothingTimeSeconds); mixSmoothed.setCurrentAndTargetValue (lastMixProportion); + blendSmoothed.reset (sampleRate, smoothingTimeSeconds); + blendSmoothed.setCurrentAndTargetValue (lastBlendProportion); + distanceSmoothed.reset (sampleRate, smoothingTimeSeconds); + distanceSmoothed.setCurrentAndTargetValue (lastDistancePercent); reset(); @@ -95,15 +117,28 @@ void CabConvolutionEngine::prepare (const juce::dsp::ProcessSpec& spec) *hiCutFilter.state = *juce::dsp::IIR::Coefficients::makeLowPass ( sampleRate, clampBelowNyquist (lastHiCutHz, sampleRate), filterQ); + const auto normalisedDistance = (lastDistancePercent - distanceMinPercent) + / (distanceMaxPercent - distanceMinPercent); + *distanceLowShelfFilter.state = *juce::dsp::IIR::Coefficients::makeLowShelf ( + sampleRate, distanceLowShelfFrequencyHz, filterQ, + juce::Decibels::decibelsToGain (normalisedDistance * distanceLowShelfMaxCutDb)); + *distanceHighShelfFilter.state = *juce::dsp::IIR::Coefficients::makeHighShelf ( + sampleRate, distanceHighShelfFrequencyHz, filterQ, + juce::Decibels::decibelsToGain (normalisedDistance * distanceHighShelfMaxCutDb)); + loCutEngagedPreviously = lastLoCutHz > loCutMinHz + bypassEpsilonHz; hiCutEngagedPreviously = lastHiCutHz < hiCutMaxHz - bypassEpsilonHz; + distanceEngagedPreviously = lastDistancePercent > distanceMinPercent + distanceBypassEpsilonPercent; } void CabConvolutionEngine::reset() { convolution.reset(); + convolutionB.reset(); loCutFilter.reset(); hiCutFilter.reset(); + distanceLowShelfFilter.reset(); + distanceHighShelfFilter.reset(); outputLevel.reset(); dryWetMixer.reset(); } @@ -132,8 +167,26 @@ void CabConvolutionEngine::setLevelDb (float newLevelDb) outputLevel.setGainDecibels (newLevelDb); } +void CabConvolutionEngine::setBlendProportion (float newProportion01) +{ + lastBlendProportion = newProportion01; + blendSmoothed.setTargetValue (newProportion01); +} + +void CabConvolutionEngine::setDistancePercent (float newDistancePercent) +{ + lastDistancePercent = newDistancePercent; + distanceSmoothed.setTargetValue (newDistancePercent); +} + void CabConvolutionEngine::setImpulseResponse (juce::AudioBuffer irBuffer, double irSampleRate) { + // Recorded before the buffer is moved from below: this becomes the + // reference onset that a subsequently loaded IR B is phase-aligned + // against (see setImpulseResponseB()). + lastIrAOnsetSample = IrAlignment::detectOnsetSample (irBuffer); + lastIrASampleRate = irSampleRate; + const auto isStereo = (irBuffer.getNumChannels() >= 2 && numChannelsPrepared >= 2) ? juce::dsp::Convolution::Stereo::yes : juce::dsp::Convolution::Stereo::no; @@ -152,6 +205,11 @@ void CabConvolutionEngine::setImpulseResponse (juce::AudioBuffer irBuffer void CabConvolutionEngine::loadDefaultImpulseResponse() { + // The delta IR's onset is trivially sample 0 - reset the phase- + // alignment reference to match, at the engine's current sample rate. + lastIrAOnsetSample = 0; + lastIrASampleRate = sampleRate; + // Normalise::no is essential here: normalising a unit impulse would // rescale it away from exact unity gain (JUCE's normalisation targets a // fixed reference energy, not "leave amplitude 1.0 alone"), which would @@ -165,6 +223,39 @@ void CabConvolutionEngine::loadDefaultImpulseResponse() anyImpulseResponseLoaded = true; } +void CabConvolutionEngine::setImpulseResponseB (juce::AudioBuffer irBuffer, double irSampleRate) +{ + // Inter-IR phase alignment: shift IR B's onset to match IR A's most + // recently recorded onset, so blending the two convolution outputs + // doesn't introduce comb-filtering from a timing mismatch between their + // transients (see IrAlignment.h and docs/architecture.md). + auto alignedIrBuffer = IrAlignment::alignOnsetToReference ( + irBuffer, irSampleRate, lastIrAOnsetSample, lastIrASampleRate); + + const auto isStereo = (alignedIrBuffer.getNumChannels() >= 2 && numChannelsPrepared >= 2) + ? juce::dsp::Convolution::Stereo::yes + : juce::dsp::Convolution::Stereo::no; + + convolutionB.loadImpulseResponse (std::move (alignedIrBuffer), + irSampleRate, + isStereo, + juce::dsp::Convolution::Trim::no, + juce::dsp::Convolution::Normalise::yes); + + anyImpulseResponseBLoaded = true; +} + +void CabConvolutionEngine::loadDefaultImpulseResponseB() +{ + convolutionB.loadImpulseResponse (makeDeltaImpulseResponse(), + sampleRate, + juce::dsp::Convolution::Stereo::no, + juce::dsp::Convolution::Trim::no, + juce::dsp::Convolution::Normalise::no); + + anyImpulseResponseBLoaded = true; +} + void CabConvolutionEngine::process (juce::dsp::AudioBlock& block) { const auto numSamples = block.getNumSamples(); @@ -172,20 +263,34 @@ void CabConvolutionEngine::process (juce::dsp::AudioBlock& block) if (numSamples == 0) return; + const auto numSamplesInt = static_cast (numSamples); + // Coefficient recomputation involves trig calls (tan/cos), so filter // frequencies are smoothed and re-derived once per block rather than // per sample - a standard real-time-safe compromise for IIR filters. - const auto loCutHz = loCutFrequencySmoothed.skip (static_cast (numSamples)); - const auto hiCutHz = hiCutFrequencySmoothed.skip (static_cast (numSamples)); - const auto wetMix = mixSmoothed.skip (static_cast (numSamples)); - - // LoCut at its minimum and HiCut at its maximum are each an explicit - // "off" position: skip the IIR processing entirely (rather than merely - // computing an extreme-but-active cutoff) so the default/wide-open state - // is a true bit-accurate passthrough, not just a filter with negligible - // colouration. This is what tests/EngineTests.cpp's null test relies on. + const auto loCutHz = loCutFrequencySmoothed.skip (numSamplesInt); + const auto hiCutHz = hiCutFrequencySmoothed.skip (numSamplesInt); + const auto wetMix = mixSmoothed.skip (numSamplesInt); + const auto blendProportion = blendSmoothed.skip (numSamplesInt); + const auto distancePercent = distanceSmoothed.skip (numSamplesInt); + + // LoCut at its minimum, HiCut at its maximum, and Distance at its + // minimum are each an explicit "off" position: skip the relevant IIR + // processing entirely (rather than merely computing an extreme-but- + // active cutoff/gain) so the default/wide-open state is a true + // bit-accurate passthrough, not just negligible colouration. This is + // what tests/EngineTests.cpp's null tests rely on. const bool loCutBypassed = loCutHz <= loCutMinHz + bypassEpsilonHz; const bool hiCutBypassed = hiCutHz >= hiCutMaxHz - bypassEpsilonHz; + const bool distanceBypassed = distancePercent <= distanceMinPercent + distanceBypassEpsilonPercent; + + // Defensive fallback: scratchBuffer is sized to maximumBlockSize in + // prepare(), so a host that (against its own promise) sends a larger + // block here would overrun it. Rather than risk that, Blend is simply + // treated as disengaged for that one block (falls back to IR A only) - + // safer than allocating or writing out of bounds on the audio thread. + const bool scratchLargeEnough = numSamples <= static_cast (scratchBuffer.getNumSamples()); + const bool blendEngaged = blendProportion > blendBypassEpsilon && scratchLargeEnough; // Reset a filter's IIR state exactly when it transitions from bypassed // to engaged, so it starts from a clean, predictable state rather than @@ -197,8 +302,15 @@ void CabConvolutionEngine::process (juce::dsp::AudioBlock& block) if (! hiCutBypassed && ! hiCutEngagedPreviously) hiCutFilter.reset(); + if (! distanceBypassed && ! distanceEngagedPreviously) + { + distanceLowShelfFilter.reset(); + distanceHighShelfFilter.reset(); + } + loCutEngagedPreviously = ! loCutBypassed; hiCutEngagedPreviously = ! hiCutBypassed; + distanceEngagedPreviously = ! distanceBypassed; if (! loCutBypassed) *loCutFilter.state = *juce::dsp::IIR::Coefficients::makeHighPass (sampleRate, clampBelowNyquist (loCutHz, sampleRate), filterQ); @@ -206,6 +318,18 @@ void CabConvolutionEngine::process (juce::dsp::AudioBlock& block) if (! hiCutBypassed) *hiCutFilter.state = *juce::dsp::IIR::Coefficients::makeLowPass (sampleRate, clampBelowNyquist (hiCutHz, sampleRate), filterQ); + if (! distanceBypassed) + { + const auto normalisedDistance = (distancePercent - distanceMinPercent) / (distanceMaxPercent - distanceMinPercent); + + *distanceLowShelfFilter.state = *juce::dsp::IIR::Coefficients::makeLowShelf ( + sampleRate, distanceLowShelfFrequencyHz, filterQ, + juce::Decibels::decibelsToGain (normalisedDistance * distanceLowShelfMaxCutDb)); + *distanceHighShelfFilter.state = *juce::dsp::IIR::Coefficients::makeHighShelf ( + sampleRate, distanceHighShelfFrequencyHz, filterQ, + juce::Decibels::decibelsToGain (normalisedDistance * distanceHighShelfMaxCutDb)); + } + dryWetMixer.setWetMixProportion (wetMix); juce::dsp::ProcessContextReplacing context (block); @@ -216,8 +340,38 @@ void CabConvolutionEngine::process (juce::dsp::AudioBlock& block) // time-aligned with the wet path below, whatever that latency is. dryWetMixer.pushDrySamples (block); + // Convolution stage: IR A always runs (needed both standalone and as + // the (1 - blend) component of the crossfade below). IR B only runs + // when Blend is actually engaged, saving the second convolution's CPU + // cost otherwise - the same "skip work that provably can't matter" + // pattern LoCut/HiCut/Distance use above. convolution.process (context); + if (blendEngaged) + { + juce::dsp::AudioBlock scratchBlock (scratchBuffer); + auto scratchSub = scratchBlock.getSubBlock (0, numSamples); + scratchSub.copyFrom (block); + + juce::dsp::ProcessContextReplacing contextB (scratchSub); + convolutionB.process (contextB); + + for (size_t channel = 0; channel < block.getNumChannels(); ++channel) + { + auto* a = block.getChannelPointer (channel); + const auto* b = scratchSub.getChannelPointer (channel); + + for (size_t sample = 0; sample < numSamples; ++sample) + a[sample] = a[sample] * (1.0f - blendProportion) + b[sample] * blendProportion; + } + } + + if (! distanceBypassed) + { + distanceLowShelfFilter.process (context); + distanceHighShelfFilter.process (context); + } + if (! loCutBypassed) loCutFilter.process (context); diff --git a/src/dsp/CabConvolutionEngine.h b/src/dsp/CabConvolutionEngine.h index e227491..02e9232 100644 --- a/src/dsp/CabConvolutionEngine.h +++ b/src/dsp/CabConvolutionEngine.h @@ -12,16 +12,29 @@ // Signal flow (see docs/architecture.md for the full diagram and the // latency-compensation rationale): // -// input -> [convolution with the loaded IR] -> LoCut HPF -> HiCut LPF -// -> Dry/Wet mix -> Level (output trim) -> output +// input -> [convolution: crossfade of IR A and IR B] -> Distance +// -> LoCut HPF -> HiCut LPF -> Dry/Wet mix -> Level (output trim) +// -> output // -// With no user IR loaded, the convolution runs a unit-impulse (delta) IR - -// mathematically a passthrough - so the plugin is a valid, transparent +// With no user IR loaded, both convolution slots run a unit-impulse (delta) +// IR - mathematically a passthrough - so the plugin is a valid, transparent // effect out of the box. juce::dsp::Convolution is constructed with its // default (Latency{0}) configuration, i.e. the zero-latency uniformly // partitioned algorithm, so CabConvolutionEngine reports zero latency // whenever the loaded IR is short enough for that algorithm (true for the // default delta IR and for any IR that fits within one FFT block). +// +// IR Blend crossfades between two independently loadable impulse responses +// (IR A, the original v0.1 slot, and IR B) - e.g. two different cabs, or two +// mic positions on the same cab. Blend defaults to 0% (IR A only), which is +// numerically identical to the v0.1 single-IR signal path. Loading IR B +// applies "inter-IR phase alignment" (see IrAlignment.h) beforehand, so the +// two IRs' transient onsets line up before they're ever summed. +// +// Distance emulates the effect of mic-to-cab distance: a gentle proximity- +// effect low-shelf cut plus a high-shelf "air absorption" cut, both scaling +// with the Distance parameter. Distance defaults to 0% ("off"), the same +// explicit-bypass-at-the-extreme pattern used by LoCut/HiCut below. class CabConvolutionEngine { public: @@ -39,6 +52,13 @@ class CabConvolutionEngine static constexpr float hiCutMinHz = 2000.0f; static constexpr float hiCutMaxHz = 20000.0f; + // Distance range boundaries. Distance's minimum (its default, 0%) is + // treated the same way as LoCut/HiCut's bypass extremes above: an + // explicit "off" position where process() skips the distance-emulation + // filters entirely, so the default state stays a true passthrough. + static constexpr float distanceMinPercent = 0.0f; + static constexpr float distanceMaxPercent = 100.0f; + // Allocates all DSP state. Must be called (and completed) before the // first process() call, and again whenever sample rate/block size/ // channel count change. Not real-time safe - allocates and may take @@ -64,22 +84,48 @@ class CabConvolutionEngine void setMixProportion (float newProportion01); void setLevelDb (float newLevelDb); - // Loads a new impulse response. MUST be called off the audio thread - // (e.g. the message thread, in response to a user picking a file) - - // reading/building `irBuffer` involves file I/O and allocation that has - // already happened by the time this is called, but - // juce::dsp::Convolution::loadImpulseResponse() itself is documented as - // wait-free, so this call is safe even if it were ever invoked from the - // audio thread. `irBuffer` is moved from (Convolution takes ownership to - // avoid an audio-thread copy). + // IR Blend: 0 = IR A only (the v0.1 default signal path, bit-identical), + // 1 = IR B only. Smoothed internally like Mix; safe to call every block + // from the audio thread. + void setBlendProportion (float newProportion01); + + // Distance: 0% (default) = off/bypassed, 100% = maximum simulated + // mic-to-cab distance coloration. Smoothed internally like LoCut/HiCut; + // safe to call every block from the audio thread. + void setDistancePercent (float newDistancePercent); + + // Loads a new impulse response into slot A (the original/primary IR). + // MUST be called off the audio thread (e.g. the message thread, in + // response to a user picking a file) - reading/building `irBuffer` + // involves file I/O and allocation that has already happened by the + // time this is called, but juce::dsp::Convolution::loadImpulseResponse() + // itself is documented as wait-free, so this call is safe even if it + // were ever invoked from the audio thread. `irBuffer` is moved from + // (Convolution takes ownership to avoid an audio-thread copy). Also + // records this IR's onset sample/rate as the reference that a + // subsequently loaded IR B is phase-aligned against (see + // setImpulseResponseB()). void setImpulseResponse (juce::AudioBuffer irBuffer, double irSampleRate); - // Resets the convolution to the default unit-impulse (delta) IR - a - // mathematical passthrough. Used both for the plugin's out-of-the-box - // default and to let the user explicitly clear a loaded IR. Same - // off-audio-thread contract as setImpulseResponse(). + // Resets slot A to the default unit-impulse (delta) IR - a mathematical + // passthrough. Used both for the plugin's out-of-the-box default and to + // let the user explicitly clear a loaded IR. Same off-audio-thread + // contract as setImpulseResponse(). Also resets the phase-alignment + // reference back to the delta IR's trivial (sample 0) onset. void loadDefaultImpulseResponse(); + // Loads a new impulse response into slot B (the secondary IR used for IR + // Blend). Same off-audio-thread contract as setImpulseResponse(). Before + // loading, `irBuffer` is time-shifted ("inter-IR phase alignment", see + // IrAlignment.h) so its detected onset lines up with slot A's most + // recently recorded onset - this prevents comb-filtering when Blend + // crossfades the two convolution outputs together. + void setImpulseResponseB (juce::AudioBuffer irBuffer, double irSampleRate); + + // Resets slot B to the default unit-impulse (delta) IR. Same + // off-audio-thread contract as loadDefaultImpulseResponse(). + void loadDefaultImpulseResponseB(); + // Convolution engine latency in samples, valid after prepare() has run. // Zero for the default zero-latency convolution configuration this // engine always uses. @@ -94,14 +140,32 @@ class CabConvolutionEngine // floating-point rounding from parameter smoothing, comfortably smaller // than any musically meaningful step within the parameter's range. static constexpr float bypassEpsilonHz = 0.5f; + // Same idea for Blend (guards against float noise landing exactly at + // 0%, which would otherwise flip blendEngaged on and off every block) + // and Distance. + static constexpr float blendBypassEpsilon = 0.001f; + static constexpr float distanceBypassEpsilonPercent = 0.5f; + + // Distance emulation: fixed shelf frequencies, gain scaling linearly + // (in dB) with the normalised Distance parameter. Deliberately gentle - + // this approximates the two most audible effects of mic-to-cab distance + // (reduced proximity-effect bass buildup, and high-frequency air + // absorption/off-axis darkening), not a physically exact model. + static constexpr float distanceLowShelfFrequencyHz = 200.0f; + static constexpr float distanceLowShelfMaxCutDb = -6.0f; + static constexpr float distanceHighShelfFrequencyHz = 5000.0f; + static constexpr float distanceHighShelfMaxCutDb = -9.0f; double sampleRate = 44100.0; int numChannelsPrepared = 2; juce::dsp::Convolution convolution; + juce::dsp::Convolution convolutionB; juce::dsp::ProcessorDuplicator, juce::dsp::IIR::Coefficients> loCutFilter; juce::dsp::ProcessorDuplicator, juce::dsp::IIR::Coefficients> hiCutFilter; + juce::dsp::ProcessorDuplicator, juce::dsp::IIR::Coefficients> distanceLowShelfFilter; + juce::dsp::ProcessorDuplicator, juce::dsp::IIR::Coefficients> distanceHighShelfFilter; juce::dsp::Gain outputLevel; @@ -111,9 +175,18 @@ class CabConvolutionEngine // capacity regardless of sample rate. juce::dsp::DryWetMixer dryWetMixer { 1024 }; + // Scratch storage for the IR B convolution branch when Blend is + // engaged, sized to (numChannelsPrepared x maximumBlockSize) in + // prepare() and never resized in process() - see process()'s + // scratchLargeEnough guard for the defensive fallback if a host ever + // sends a block larger than promised. + juce::AudioBuffer scratchBuffer; + juce::SmoothedValue loCutFrequencySmoothed; juce::SmoothedValue hiCutFrequencySmoothed; juce::SmoothedValue mixSmoothed; + juce::SmoothedValue blendSmoothed; + juce::SmoothedValue distanceSmoothed; // Last commanded values (ParameterLayout defaults until a setter is // called), re-applied on every prepare() so re-prepare (sample-rate @@ -122,6 +195,8 @@ class CabConvolutionEngine float lastLoCutHz = loCutMinHz; float lastHiCutHz = hiCutMaxHz; float lastMixProportion = 1.0f; + float lastBlendProportion = 0.0f; + float lastDistancePercent = distanceMinPercent; // juce::dsp::Gain's internal SmoothedValue defaults to a *linear* gain of // 0 (silence) until setGainDecibels() is called at least once - unlike // LoCut/HiCut/Mix above, there is no engine-internal notion of a "neutral" @@ -131,17 +206,28 @@ class CabConvolutionEngine int latencySamples = 0; - // True once setImpulseResponse()/loadDefaultImpulseResponse() has been - // called at least once; guards prepare() from redundantly reloading the - // default IR on every re-prepare (juce::dsp::Convolution automatically - // retains and re-resamples the most recently loaded IR - see prepare()). + // True once setImpulseResponse()/loadDefaultImpulseResponse() (slot A) + // has been called at least once; guards prepare() from redundantly + // reloading the default IR on every re-prepare (juce::dsp::Convolution + // automatically retains and re-resamples the most recently loaded IR - + // see prepare()). anyImpulseResponseBLoaded is the equivalent guard for + // slot B. bool anyImpulseResponseLoaded = false; + bool anyImpulseResponseBLoaded = false; - // Previous block's engaged (i.e. not bypassed) state for LoCut/HiCut, - // used to detect bypassed->engaged transitions so the filter can be - // reset to a clean state exactly then (see process()). + // Previous block's engaged (i.e. not bypassed) state for LoCut/HiCut/ + // Distance, used to detect bypassed->engaged transitions so the + // filter(s) can be reset to a clean state exactly then (see process()). bool loCutEngagedPreviously = false; bool hiCutEngagedPreviously = false; + bool distanceEngagedPreviously = false; + + // IR A's most recently loaded onset sample/rate, recorded by + // setImpulseResponse()/loadDefaultImpulseResponse() and used as the + // reference that setImpulseResponseB() phase-aligns IR B against (see + // IrAlignment.h). + int lastIrAOnsetSample = 0; + double lastIrASampleRate = 44100.0; JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (CabConvolutionEngine) }; diff --git a/src/params/ParameterIds.h b/src/params/ParameterIds.h index 208c7ef..7cc6927 100644 --- a/src/params/ParameterIds.h +++ b/src/params/ParameterIds.h @@ -29,10 +29,24 @@ namespace ParamIDs // Output trim, applied after the dry/wet mix. inline constexpr auto level = "level"; + // IR Blend: crossfades between IR A (the original slot, loaded via + // "Load IR...") and IR B (loaded via "Load IR B..."). Default 0% (IR A + // only) is numerically identical to the v0.1 single-IR signal path, so + // adding this parameter doesn't change any existing preset's sound. + inline constexpr auto irBlend = "irBlend"; + + // Distance: simulated mic-to-cab distance. Default 0% is an explicit + // "off" position (see CabConvolutionEngine's bypass-at-the-extreme + // pattern), so adding this parameter doesn't change any existing + // preset's sound either. + inline constexpr auto micDistance = "micDistance"; + // NOT an APVTS parameter: the currently loaded IR file's absolute path is // stored as a plain property directly on apvts.state (see // PluginProcessor::loadImpulseResponseFromFile/getStateInformation), so // it round-trips through session/preset state without needing a float - // parameter to represent a file path. + // parameter to represent a file path. irFilePathBProperty is the + // equivalent for IR B. inline constexpr auto irFilePathProperty = "irFilePath"; + inline constexpr auto irFilePathBProperty = "irFilePathB"; } diff --git a/src/params/ParameterLayout.cpp b/src/params/ParameterLayout.cpp index 920b2ba..7228413 100644 --- a/src/params/ParameterLayout.cpp +++ b/src/params/ParameterLayout.cpp @@ -50,6 +50,27 @@ namespace nave CabConvolutionEngine::hiCutMaxHz, juce::AudioParameterFloatAttributes().withLabel ("Hz"))); + //====================================================================== + // IR Blend: crossfades between IR A and IR B. Default 0% (IR A only) + // is bit-identical to the v0.1 single-IR signal path. + layout.add (std::make_unique ( + juce::ParameterID { ParamIDs::irBlend, 1 }, + "IR Blend", + juce::NormalisableRange (0.0f, 100.0f, 0.1f), + 0.0f, + juce::AudioParameterFloatAttributes().withLabel ("%"))); + + //====================================================================== + // Distance: simulated mic-to-cab distance. The default (its range + // minimum) is CabConvolutionEngine's explicit "off" position - see + // CabConvolutionEngine.h for the bypass contract. + layout.add (std::make_unique ( + juce::ParameterID { ParamIDs::micDistance, 1 }, + "Distance", + juce::NormalisableRange (CabConvolutionEngine::distanceMinPercent, CabConvolutionEngine::distanceMaxPercent, 0.1f), + CabConvolutionEngine::distanceMinPercent, + juce::AudioParameterFloatAttributes().withLabel ("%"))); + //====================================================================== // Mix: dry/wet. Default 100% (fully wet) - a cabinet IR is normally // run fully in the signal path, not blended with the raw DI. From 1355671ceff879da40dc8639cc748b5f2021362c Mon Sep 17 00:00:00 2001 From: Yves Vogl Date: Tue, 14 Jul 2026 16:26:11 +0200 Subject: [PATCH 3/6] test: broaden Catch2 coverage for M1 - Unit tests for IrAlignment (onset detection, sample shifting, cross-sample-rate alignment). - Engine-level tests for IR Blend (0%/50%/100%) and Distance (passthrough at 0%, measurable attenuation at 100%). - Parameter/state/latency test updates for the two new parameters and the IR B file path. - New CoverageTests.cpp: sample-rate sweep (44.1-192 kHz) null and finite-output tests, mono/stereo/unsupported bus-layout tests, and long-run (2000-block, and 300-block-with-loaded-IRs) NaN/Inf stability soak tests. 50 Catch2 test cases green (up from 22). --- tests/CoverageTests.cpp | 309 +++++++++++++++++++++++++++++++++++++ tests/EngineTests.cpp | 214 +++++++++++++++++++++++++ tests/IrAlignmentTests.cpp | 143 +++++++++++++++++ tests/LatencyTests.cpp | 45 ++++++ tests/ParameterTests.cpp | 19 ++- tests/StateTests.cpp | 86 +++++++++++ 6 files changed, 813 insertions(+), 3 deletions(-) create mode 100644 tests/CoverageTests.cpp create mode 100644 tests/IrAlignmentTests.cpp diff --git a/tests/CoverageTests.cpp b/tests/CoverageTests.cpp new file mode 100644 index 0000000..51c82d7 --- /dev/null +++ b/tests/CoverageTests.cpp @@ -0,0 +1,309 @@ +#include "PluginProcessor.h" +#include "params/ParameterIds.h" +#include "dsp/CabConvolutionEngine.h" +#include "TestHelpers.h" + +#include + +#include +#include +#include + +// Broadened Catch2 coverage for M1: sample-rate sweeps (44.1-192 kHz), +// extreme parameter automation, mono/stereo bus configurations, and a +// long-run NaN/Inf stability soak test. Complements (does not replace) the +// focused null/reference/latency/state tests in the other test files. +namespace +{ + void setParam (NaveAudioProcessor& processor, const char* id, float realValue) + { + auto* param = processor.apvts.getParameter (id); + REQUIRE (param != nullptr); + param->setValueNotifyingHost (param->convertTo0to1 (realValue)); + } + + // The full sample-rate sweep the M1 spec calls for. 44100/48000 are the + // common tracking rates; 88200/96000/176400/192000 cover the + // high-resolution range up to 192 kHz. + constexpr std::array sweepSampleRates { 44100.0, 48000.0, 88200.0, 96000.0, 176400.0, 192000.0 }; +} + +TEST_CASE ("Null test holds across the full 44.1-192 kHz sample-rate sweep", "[coverage][sample-rate][null]") +{ + for (const auto rate : sweepSampleRates) + { + CAPTURE (rate); + + constexpr int blockSize = 1024; + + CabConvolutionEngine engine; + engine.setLoCutHz (CabConvolutionEngine::loCutMinHz); + engine.setHiCutHz (CabConvolutionEngine::hiCutMaxHz); + engine.setMixProportion (1.0f); + engine.setLevelDb (0.0f); + + juce::dsp::ProcessSpec spec; + spec.sampleRate = rate; + spec.maximumBlockSize = static_cast (blockSize); + spec.numChannels = 2; + engine.prepare (spec); + + REQUIRE (engine.getLatencySamples() == 0); + + juce::AudioBuffer reference (2, blockSize); + TestHelpers::fillWithSine (reference, rate, 1000.0, 0.5f); + + juce::AudioBuffer processed; + processed.makeCopyOf (reference); + + juce::dsp::AudioBlock block (processed); + engine.process (block); + + float maxResidual = 0.0f; + + for (int channel = 0; channel < reference.getNumChannels(); ++channel) + { + const auto* refData = reference.getReadPointer (channel); + const auto* outData = processed.getReadPointer (channel); + + for (int i = 0; i < blockSize; ++i) + maxResidual = std::max (maxResidual, std::abs (outData[i] - refData[i])); + } + + CHECK (maxResidual < 1.0e-4f); + } +} + +TEST_CASE ("LoCut/HiCut engaged produce finite, sane output across the sample-rate sweep", "[coverage][sample-rate]") +{ + for (const auto rate : sweepSampleRates) + { + CAPTURE (rate); + + constexpr int blockSize = 512; + + NaveAudioProcessor processor; + processor.prepareToPlay (rate, blockSize); + + setParam (processor, ParamIDs::loCut, 300.0f); + setParam (processor, ParamIDs::hiCut, 5000.0f); + setParam (processor, ParamIDs::mix, 100.0f); + + juce::AudioBuffer buffer (2, blockSize); + juce::MidiBuffer midi; + + for (int block = 0; block < 4; ++block) + { + TestHelpers::fillWithSine (buffer, rate, 440.0, 0.7f); + CHECK_NOTHROW (processor.processBlock (buffer, midi)); + CHECK (TestHelpers::allSamplesFinite (buffer)); + } + + CHECK (processor.getLatencySamples() == 0); + } +} + +TEST_CASE ("Mono bus layout processes without NaN/Inf and stays a passthrough at defaults", "[coverage][bus-layout]") +{ + NaveAudioProcessor processor; + + juce::AudioProcessor::BusesLayout monoLayout; + monoLayout.inputBuses.add (juce::AudioChannelSet::mono()); + monoLayout.outputBuses.add (juce::AudioChannelSet::mono()); + + REQUIRE (processor.checkBusesLayoutSupported (monoLayout)); + REQUIRE (processor.setBusesLayout (monoLayout)); + + processor.prepareToPlay (48000.0, 512); + + juce::AudioBuffer reference (1, 512); + TestHelpers::fillWithSine (reference, 48000.0, 1000.0, 0.5f); + + juce::AudioBuffer processed; + processed.makeCopyOf (reference); + + juce::MidiBuffer midi; + processor.processBlock (processed, midi); + + CHECK (TestHelpers::allSamplesFinite (processed)); + + float maxResidual = 0.0f; + const auto* refData = reference.getReadPointer (0); + const auto* outData = processed.getReadPointer (0); + + for (int i = 0; i < 512; ++i) + maxResidual = std::max (maxResidual, std::abs (outData[i] - refData[i])); + + CHECK (maxResidual < 1.0e-4f); +} + +TEST_CASE ("Mono bus layout with engaged filters and a loaded IR produces no NaN/Inf", "[coverage][bus-layout]") +{ + NaveAudioProcessor processor; + + juce::AudioProcessor::BusesLayout monoLayout; + monoLayout.inputBuses.add (juce::AudioChannelSet::mono()); + monoLayout.outputBuses.add (juce::AudioChannelSet::mono()); + + REQUIRE (processor.setBusesLayout (monoLayout)); + + processor.prepareToPlay (48000.0, 512); + + const auto irFile = juce::File::createTempFile (".wav"); + juce::AudioBuffer ir (1, 64); + + for (int i = 0; i < ir.getNumSamples(); ++i) + ir.setSample (0, i, std::exp (-0.05f * static_cast (i))); + + REQUIRE (TestHelpers::writeWavFile (irFile, ir, 48000.0)); + REQUIRE (processor.loadImpulseResponseFromFile (irFile)); + + setParam (processor, ParamIDs::loCut, 250.0f); + setParam (processor, ParamIDs::hiCut, 6000.0f); + setParam (processor, ParamIDs::micDistance, 50.0f); + setParam (processor, ParamIDs::mix, 80.0f); + + juce::AudioBuffer buffer (1, 512); + juce::MidiBuffer midi; + + for (int block = 0; block < 8; ++block) + { + TestHelpers::fillWithSine (buffer, 48000.0, 300.0 + static_cast (block) * 200.0, 0.8f); + CHECK_NOTHROW (processor.processBlock (buffer, midi)); + CHECK (TestHelpers::allSamplesFinite (buffer)); + } + + irFile.deleteFile(); +} + +TEST_CASE ("Stereo bus layout (explicit re-assertion) processes without NaN/Inf", "[coverage][bus-layout]") +{ + NaveAudioProcessor processor; + + juce::AudioProcessor::BusesLayout stereoLayout; + stereoLayout.inputBuses.add (juce::AudioChannelSet::stereo()); + stereoLayout.outputBuses.add (juce::AudioChannelSet::stereo()); + + REQUIRE (processor.checkBusesLayoutSupported (stereoLayout)); + REQUIRE (processor.setBusesLayout (stereoLayout)); + + processor.prepareToPlay (48000.0, 512); + + setParam (processor, ParamIDs::irBlend, 40.0f); + setParam (processor, ParamIDs::micDistance, 30.0f); + + juce::AudioBuffer buffer (2, 512); + juce::MidiBuffer midi; + + for (int block = 0; block < 4; ++block) + { + TestHelpers::fillWithSine (buffer, 48000.0, 220.0, 0.6f); + CHECK_NOTHROW (processor.processBlock (buffer, midi)); + CHECK (TestHelpers::allSamplesFinite (buffer)); + } +} + +TEST_CASE ("Unsupported bus layouts (3+ channels) are rejected", "[coverage][bus-layout]") +{ + NaveAudioProcessor processor; + + juce::AudioProcessor::BusesLayout surroundLayout; + surroundLayout.inputBuses.add (juce::AudioChannelSet::createLCR()); + surroundLayout.outputBuses.add (juce::AudioChannelSet::createLCR()); + + CHECK_FALSE (processor.checkBusesLayoutSupported (surroundLayout)); +} + +TEST_CASE ("Long-run stability: 2000 blocks of continuous automated processing produce no NaN/Inf", "[coverage][stability]") +{ + NaveAudioProcessor processor; + processor.prepareToPlay (48000.0, 256); + + std::mt19937 rng (42); + std::uniform_real_distribution unit (0.0f, 1.0f); + + juce::AudioBuffer buffer (2, 256); + juce::MidiBuffer midi; + + constexpr int numBlocks = 2000; // ~10.7 s of audio at 48 kHz/256 samples + + for (int block = 0; block < numBlocks; ++block) + { + // Sweep every parameter continuously across its full range so the + // filters/mixer/gain smoothers are perpetually chasing a moving + // target - the scenario most likely to accumulate state drift or + // denormal/NaN issues over a long run. + const auto phase = static_cast (block) / static_cast (numBlocks); + + setParam (processor, ParamIDs::loCut, + CabConvolutionEngine::loCutMinHz + phase * (CabConvolutionEngine::loCutMaxHz - CabConvolutionEngine::loCutMinHz)); + setParam (processor, ParamIDs::hiCut, + CabConvolutionEngine::hiCutMaxHz - phase * (CabConvolutionEngine::hiCutMaxHz - CabConvolutionEngine::hiCutMinHz)); + setParam (processor, ParamIDs::irBlend, unit (rng) * 100.0f); + setParam (processor, ParamIDs::micDistance, unit (rng) * 100.0f); + setParam (processor, ParamIDs::mix, unit (rng) * 100.0f); + setParam (processor, ParamIDs::level, -24.0f + unit (rng) * 48.0f); + + TestHelpers::fillWithSine (buffer, 48000.0, 100.0 + unit (rng) * 8000.0, 0.7f); + + processor.processBlock (buffer, midi); + + if (! TestHelpers::allSamplesFinite (buffer)) + { + FAIL ("Non-finite sample produced at block " << block); + break; + } + } + + CHECK (TestHelpers::allSamplesFinite (buffer)); +} + +TEST_CASE ("Long-run stability with a loaded IR and IR B blend produces no NaN/Inf", "[coverage][stability]") +{ + NaveAudioProcessor processor; + processor.prepareToPlay (44100.0, 512); + + const auto irFileA = juce::File::createTempFile (".wav"); + const auto irFileB = juce::File::createTempFile (".wav"); + + juce::AudioBuffer irA (2, 128); + juce::AudioBuffer irB (2, 96); + + std::mt19937 rng (7); + std::uniform_real_distribution unit (-1.0f, 1.0f); + + for (int channel = 0; channel < 2; ++channel) + { + for (int i = 0; i < irA.getNumSamples(); ++i) + irA.setSample (channel, i, unit (rng) * std::exp (-0.03f * static_cast (i))); + + for (int i = 0; i < irB.getNumSamples(); ++i) + irB.setSample (channel, i, unit (rng) * std::exp (-0.05f * static_cast (i))); + } + + REQUIRE (TestHelpers::writeWavFile (irFileA, irA, 44100.0)); + REQUIRE (TestHelpers::writeWavFile (irFileB, irB, 44100.0)); + REQUIRE (processor.loadImpulseResponseFromFile (irFileA)); + REQUIRE (processor.loadImpulseResponseFromFileB (irFileB)); + + std::uniform_real_distribution unit01 (0.0f, 1.0f); + + juce::AudioBuffer buffer (2, 512); + juce::MidiBuffer midi; + + constexpr int numBlocks = 300; // ~3.5 s of audio at 44.1 kHz/512 samples + + for (int block = 0; block < numBlocks; ++block) + { + setParam (processor, ParamIDs::irBlend, unit01 (rng) * 100.0f); + setParam (processor, ParamIDs::micDistance, unit01 (rng) * 100.0f); + + TestHelpers::fillWithSine (buffer, 44100.0, 150.0 + unit01 (rng) * 6000.0, 0.6f); + + processor.processBlock (buffer, midi); + CHECK (TestHelpers::allSamplesFinite (buffer)); + } + + irFileA.deleteFile(); + irFileB.deleteFile(); +} diff --git a/tests/EngineTests.cpp b/tests/EngineTests.cpp index 72b0e6b..c5b9e24 100644 --- a/tests/EngineTests.cpp +++ b/tests/EngineTests.cpp @@ -40,6 +40,8 @@ TEST_CASE ("Null test: default delta IR, wide-open LoCut/HiCut, Mix 100% nulls a engine.setHiCutHz (CabConvolutionEngine::hiCutMaxHz); engine.setMixProportion (1.0f); engine.setLevelDb (0.0f); + engine.setBlendProportion (0.0f); // IR A only - IR B's default delta never enters the mix + engine.setDistancePercent (CabConvolutionEngine::distanceMinPercent); // "off" const auto spec = makeTestSpec (2); engine.prepare (spec); @@ -226,16 +228,228 @@ TEST_CASE ("A loaded (non-delta) impulse response measurably changes the output" CHECK (maxResidual > nullTestTolerance); } +TEST_CASE ("IR Blend at 0% (default) leaves IR B's loaded IR completely unheard", "[dsp][engine][blend]") +{ + CabConvolutionEngine engine; + engine.setMixProportion (1.0f); + engine.setLevelDb (0.0f); + engine.setBlendProportion (0.0f); + + const auto spec = makeTestSpec (2); + engine.prepare (spec); + + // A drastically different IR loaded into slot B - if Blend = 0% ever let + // any of it through, this would be unmissable in the output. + juce::AudioBuffer irB (1, 4); + irB.setSample (0, 0, 1.0f); + irB.setSample (0, 1, -1.0f); + irB.setSample (0, 2, 1.0f); + irB.setSample (0, 3, -1.0f); + engine.setImpulseResponseB (std::move (irB), testSampleRate); + engine.prepare (spec); // guarantee the async load is drained/active - see docs/architecture.md + + juce::AudioBuffer reference (2, testBlockSize); + TestHelpers::fillWithSine (reference, testSampleRate, testFrequencyHz, 0.5f); + + juce::AudioBuffer processed; + processed.makeCopyOf (reference); + + juce::dsp::AudioBlock block (processed); + engine.process (block); + + CHECK (TestHelpers::allSamplesFinite (processed)); + + for (int channel = 0; channel < reference.getNumChannels(); ++channel) + { + const auto* refData = reference.getReadPointer (channel); + const auto* outData = processed.getReadPointer (channel); + + float maxResidual = 0.0f; + + for (int i = 0; i < testBlockSize; ++i) + maxResidual = std::max (maxResidual, std::abs (outData[i] - refData[i])); + + CHECK (maxResidual < nullTestTolerance); + } +} + +TEST_CASE ("IR Blend at 100% is driven entirely by IR B, not IR A", "[dsp][engine][blend]") +{ + CabConvolutionEngine engine; + engine.setMixProportion (1.0f); + engine.setLevelDb (0.0f); + engine.setBlendProportion (1.0f); + + const auto spec = makeTestSpec (2); + engine.prepare (spec); + + // IR A stays the default delta (identity); IR B is a genuinely + // different, decaying IR - at Blend = 100% the output must match + // processing through IR B alone, not the untouched input. + juce::AudioBuffer irB (1, 4); + irB.setSample (0, 0, 1.0f); + irB.setSample (0, 1, 0.5f); + irB.setSample (0, 2, 0.25f); + irB.setSample (0, 3, 0.125f); + engine.setImpulseResponseB (std::move (irB), testSampleRate); + engine.prepare (spec); + + juce::AudioBuffer reference (2, testBlockSize); + TestHelpers::fillWithSine (reference, testSampleRate, testFrequencyHz, 0.5f); + + juce::AudioBuffer processed; + processed.makeCopyOf (reference); + + juce::dsp::AudioBlock block (processed); + engine.process (block); + + CHECK (TestHelpers::allSamplesFinite (processed)); + + float maxResidual = 0.0f; + + for (int channel = 0; channel < reference.getNumChannels(); ++channel) + { + const auto* refData = reference.getReadPointer (channel); + const auto* outData = processed.getReadPointer (channel); + + for (int i = 0; i < testBlockSize; ++i) + maxResidual = std::max (maxResidual, std::abs (outData[i] - refData[i])); + } + + // A genuinely different IR B must move the output measurably away from + // a pure passthrough of the (untouched, delta-IR-A) input. + CHECK (maxResidual > nullTestTolerance); +} + +TEST_CASE ("IR Blend at 50% sits between IR A alone and IR B alone", "[dsp][engine][blend]") +{ + const auto measurePeak = [] (float blendProportion) + { + CabConvolutionEngine engine; + engine.setMixProportion (1.0f); + engine.setLevelDb (0.0f); + engine.setBlendProportion (blendProportion); + + const auto spec = makeTestSpec (2); + engine.prepare (spec); + + // A short IR with a strong first tap - at Blend = 100% the output's + // peak should track this tap's gain much more closely than at + // Blend = 0% (identity IR A). + juce::AudioBuffer irB (1, 2); + irB.setSample (0, 0, 0.2f); + irB.setSample (0, 1, 0.0f); + engine.setImpulseResponseB (std::move (irB), testSampleRate); + engine.prepare (spec); + + juce::AudioBuffer buffer (2, testBlockSize); + TestHelpers::fillWithSine (buffer, testSampleRate, testFrequencyHz, 0.5f); + + juce::dsp::AudioBlock block (buffer); + engine.process (block); + + CHECK (TestHelpers::allSamplesFinite (buffer)); + return TestHelpers::peakAbsolute (buffer); + }; + + const auto peakAtA = measurePeak (0.0f); + const auto peakAtHalf = measurePeak (0.5f); + const auto peakAtB = measurePeak (1.0f); + + // IR A is the identity (peak == input peak, 0.5); IR B attenuates + // heavily (peak << input peak). The 50% blend must land strictly + // between the two. + REQUIRE (peakAtB < peakAtA); + CHECK (peakAtHalf < peakAtA); + CHECK (peakAtHalf > peakAtB); +} + +TEST_CASE ("Distance at 0% (default) is a bit-exact passthrough", "[dsp][engine][distance]") +{ + CabConvolutionEngine engine; + engine.setMixProportion (1.0f); + engine.setLevelDb (0.0f); + engine.setDistancePercent (CabConvolutionEngine::distanceMinPercent); + + const auto spec = makeTestSpec (2); + engine.prepare (spec); + + juce::AudioBuffer reference (2, testBlockSize); + TestHelpers::fillWithSine (reference, testSampleRate, testFrequencyHz, 0.5f); + + juce::AudioBuffer processed; + processed.makeCopyOf (reference); + + juce::dsp::AudioBlock block (processed); + engine.process (block); + + for (int channel = 0; channel < reference.getNumChannels(); ++channel) + { + const auto* refData = reference.getReadPointer (channel); + const auto* outData = processed.getReadPointer (channel); + + float maxResidual = 0.0f; + + for (int i = 0; i < testBlockSize; ++i) + maxResidual = std::max (maxResidual, std::abs (outData[i] - refData[i])); + + CHECK (maxResidual < nullTestTolerance); + } +} + +TEST_CASE ("Distance at 100% measurably attenuates both low- and high-frequency energy", "[dsp][engine][distance]") +{ + const auto measureRms = [] (float distancePercent, double frequencyHz) + { + CabConvolutionEngine engine; + engine.setMixProportion (1.0f); + engine.setLevelDb (0.0f); + engine.setDistancePercent (distancePercent); + + const auto spec = makeTestSpec (2); + engine.prepare (spec); + + juce::AudioBuffer buffer (2, testBlockSize); + TestHelpers::fillWithSine (buffer, testSampleRate, frequencyHz, 0.5f); + + juce::dsp::AudioBlock block (buffer); + engine.process (block); + + return TestHelpers::rms (buffer); + }; + + constexpr double lowTestFrequencyHz = 100.0; // near the low-shelf frequency + constexpr double highTestFrequencyHz = 15000.0; // well above the high-shelf frequency + + const auto lowRmsOff = measureRms (CabConvolutionEngine::distanceMinPercent, lowTestFrequencyHz); + const auto lowRmsFar = measureRms (CabConvolutionEngine::distanceMaxPercent, lowTestFrequencyHz); + const auto highRmsOff = measureRms (CabConvolutionEngine::distanceMinPercent, highTestFrequencyHz); + const auto highRmsFar = measureRms (CabConvolutionEngine::distanceMaxPercent, highTestFrequencyHz); + + REQUIRE (lowRmsOff > 0.0); + REQUIRE (highRmsOff > 0.0); + CHECK (lowRmsFar < lowRmsOff); + CHECK (highRmsFar < highRmsOff); +} + TEST_CASE ("reset() clears filter/convolution/mixer state without crashing", "[dsp][engine]") { CabConvolutionEngine engine; engine.setLoCutHz (300.0f); engine.setHiCutHz (3000.0f); engine.setMixProportion (1.0f); + engine.setBlendProportion (0.5f); + engine.setDistancePercent (50.0f); const auto spec = makeTestSpec (2); engine.prepare (spec); + juce::AudioBuffer irB (1, 4); + irB.setSample (0, 0, 1.0f); + irB.setSample (0, 1, 0.5f); + engine.setImpulseResponseB (std::move (irB), testSampleRate); + engine.prepare (spec); + juce::AudioBuffer buffer (2, testBlockSize); TestHelpers::fillWithSine (buffer, testSampleRate, testFrequencyHz, 0.9f); diff --git a/tests/IrAlignmentTests.cpp b/tests/IrAlignmentTests.cpp new file mode 100644 index 0000000..869fb58 --- /dev/null +++ b/tests/IrAlignmentTests.cpp @@ -0,0 +1,143 @@ +#include "dsp/IrAlignment.h" + +#include +#include + +#include + +namespace +{ + // A buffer that is silent up to `onsetSample`, then a short decaying + // "transient" - close enough to a real cabinet IR's shape for onset + // detection purposes. + juce::AudioBuffer makeBufferWithOnsetAt (int onsetSample, int totalSamples = 32) + { + juce::AudioBuffer buffer (1, totalSamples); + buffer.clear(); + + for (int i = onsetSample; i < totalSamples; ++i) + buffer.setSample (0, i, std::pow (0.7f, static_cast (i - onsetSample))); + + return buffer; + } +} + +TEST_CASE ("detectOnsetSample finds a delayed transient's start", "[dsp][ir-alignment]") +{ + const auto buffer = makeBufferWithOnsetAt (10); + CHECK (IrAlignment::detectOnsetSample (buffer) == 10); +} + +TEST_CASE ("detectOnsetSample returns 0 for a transient starting at sample 0", "[dsp][ir-alignment]") +{ + const auto buffer = makeBufferWithOnsetAt (0); + CHECK (IrAlignment::detectOnsetSample (buffer) == 0); +} + +TEST_CASE ("detectOnsetSample returns 0 for a silent buffer", "[dsp][ir-alignment]") +{ + juce::AudioBuffer silent (1, 16); + silent.clear(); + + CHECK (IrAlignment::detectOnsetSample (silent) == 0); +} + +TEST_CASE ("detectOnsetSample returns 0 for an empty buffer", "[dsp][ir-alignment]") +{ + juce::AudioBuffer empty (1, 0); + CHECK (IrAlignment::detectOnsetSample (empty) == 0); +} + +TEST_CASE ("shiftBySamples with a positive shift prepends silence and preserves content", "[dsp][ir-alignment]") +{ + juce::AudioBuffer buffer (1, 4); + for (int i = 0; i < 4; ++i) + buffer.setSample (0, i, static_cast (i + 1)); + + const auto shifted = IrAlignment::shiftBySamples (buffer, 3); + + REQUIRE (shifted.getNumSamples() == 7); + CHECK (shifted.getSample (0, 0) == Catch::Approx (0.0f)); + CHECK (shifted.getSample (0, 1) == Catch::Approx (0.0f)); + CHECK (shifted.getSample (0, 2) == Catch::Approx (0.0f)); + CHECK (shifted.getSample (0, 3) == Catch::Approx (1.0f)); + CHECK (shifted.getSample (0, 4) == Catch::Approx (2.0f)); + CHECK (shifted.getSample (0, 5) == Catch::Approx (3.0f)); + CHECK (shifted.getSample (0, 6) == Catch::Approx (4.0f)); +} + +TEST_CASE ("shiftBySamples with a negative shift drops leading samples", "[dsp][ir-alignment]") +{ + juce::AudioBuffer buffer (1, 4); + for (int i = 0; i < 4; ++i) + buffer.setSample (0, i, static_cast (i + 1)); + + const auto shifted = IrAlignment::shiftBySamples (buffer, -2); + + REQUIRE (shifted.getNumSamples() == 2); + CHECK (shifted.getSample (0, 0) == Catch::Approx (3.0f)); + CHECK (shifted.getSample (0, 1) == Catch::Approx (4.0f)); +} + +TEST_CASE ("shiftBySamples with zero shift returns an unmodified copy", "[dsp][ir-alignment]") +{ + juce::AudioBuffer buffer (1, 4); + for (int i = 0; i < 4; ++i) + buffer.setSample (0, i, static_cast (i + 1)); + + const auto shifted = IrAlignment::shiftBySamples (buffer, 0); + + REQUIRE (shifted.getNumSamples() == 4); + for (int i = 0; i < 4; ++i) + CHECK (shifted.getSample (0, i) == Catch::Approx (buffer.getSample (0, i))); +} + +TEST_CASE ("shiftBySamples with an oversized negative shift clamps to at least one sample", "[dsp][ir-alignment]") +{ + juce::AudioBuffer buffer (1, 4); + for (int i = 0; i < 4; ++i) + buffer.setSample (0, i, static_cast (i + 1)); + + const auto shifted = IrAlignment::shiftBySamples (buffer, -100); + + REQUIRE (shifted.getNumSamples() == 1); + CHECK (shifted.getSample (0, 0) == Catch::Approx (4.0f)); // the last surviving sample +} + +TEST_CASE ("alignOnsetToReference shifts a target IR so its onset matches the reference's, same sample rate", "[dsp][ir-alignment]") +{ + constexpr double sampleRate = 48000.0; + + // Reference (IR A) onset: sample 5. Target (IR B) onset: sample 20. + const auto target = makeBufferWithOnsetAt (20, 64); + + const auto aligned = IrAlignment::alignOnsetToReference (target, sampleRate, 5, sampleRate); + + // The target must be advanced by (20 - 5) = 15 samples, so its onset now + // lands at sample 5, matching the reference. + CHECK (IrAlignment::detectOnsetSample (aligned) == 5); +} + +TEST_CASE ("alignOnsetToReference handles differing sample rates by aligning in time, not raw samples", "[dsp][ir-alignment]") +{ + // Reference at 48 kHz, onset at sample 480 (10 ms). Target at 96 kHz, + // onset at sample 480 (5 ms) - the same *sample index* but a different + // *time*, so a naive sample-domain alignment would get this wrong. + const auto target = makeBufferWithOnsetAt (480, 2000); + + const auto aligned = IrAlignment::alignOnsetToReference (target, 96000.0, 480, 48000.0); + + // Target needs to be delayed by 5 ms (10ms - 5ms) = 480 samples at its + // own (96 kHz) rate, landing its onset at sample 960. + CHECK (IrAlignment::detectOnsetSample (aligned) == 960); +} + +TEST_CASE ("alignOnsetToReference is a no-op in onset terms when already aligned", "[dsp][ir-alignment]") +{ + constexpr double sampleRate = 44100.0; + const auto target = makeBufferWithOnsetAt (12, 64); + + const auto aligned = IrAlignment::alignOnsetToReference (target, sampleRate, 12, sampleRate); + + CHECK (IrAlignment::detectOnsetSample (aligned) == 12); +} diff --git a/tests/LatencyTests.cpp b/tests/LatencyTests.cpp index 6576ae9..71f76e3 100644 --- a/tests/LatencyTests.cpp +++ b/tests/LatencyTests.cpp @@ -1,5 +1,6 @@ #include "PluginProcessor.h" #include "dsp/CabConvolutionEngine.h" +#include "params/ParameterIds.h" #include @@ -81,3 +82,47 @@ TEST_CASE ("Latency remains zero after loading a custom (short) IR", "[latency]" CHECK (engine.getLatencySamples() == 0); } + +TEST_CASE ("Latency remains zero with IR B loaded and Blend engaged", "[latency]") +{ + CabConvolutionEngine engine; + juce::dsp::ProcessSpec spec; + spec.sampleRate = 48000.0; + spec.maximumBlockSize = 512; + spec.numChannels = 2; + engine.prepare (spec); + + juce::AudioBuffer irA (1, 16); + juce::AudioBuffer irB (1, 16); + + for (int i = 0; i < 16; ++i) + { + irA.setSample (0, i, i == 0 ? 1.0f : 0.0f); + irB.setSample (0, i, i == 2 ? 0.5f : 0.0f); + } + + engine.setImpulseResponse (std::move (irA), 48000.0); + engine.setImpulseResponseB (std::move (irB), 48000.0); + engine.setBlendProportion (0.5f); + + // Re-prepare so both loads are guaranteed active. + engine.prepare (spec); + + CHECK (engine.getLatencySamples() == 0); +} + +TEST_CASE ("Latency remains zero with Distance engaged", "[latency]") +{ + NaveAudioProcessor processor; + processor.prepareToPlay (48000.0, 512); + + auto* distanceParam = processor.apvts.getParameter (ParamIDs::micDistance); + REQUIRE (distanceParam != nullptr); + distanceParam->setValueNotifyingHost (distanceParam->convertTo0to1 (CabConvolutionEngine::distanceMaxPercent)); + + juce::AudioBuffer buffer (2, 512); + juce::MidiBuffer midi; + processor.processBlock (buffer, midi); + + CHECK (processor.getLatencySamples() == 0); +} diff --git a/tests/ParameterTests.cpp b/tests/ParameterTests.cpp index 678e74f..7d36900 100644 --- a/tests/ParameterTests.cpp +++ b/tests/ParameterTests.cpp @@ -57,16 +57,28 @@ TEST_CASE ("Processor instantiates with the expected parameters", "[processor][p SECTION ("all documented parameter IDs resolve") { static constexpr const char* allIds[] = { - ParamIDs::loCut, ParamIDs::hiCut, ParamIDs::mix, ParamIDs::level, + ParamIDs::loCut, ParamIDs::hiCut, ParamIDs::irBlend, ParamIDs::micDistance, ParamIDs::mix, ParamIDs::level, }; for (const auto* id : allIds) CHECK (apvts.getParameter (id) != nullptr); } - SECTION ("total parameter count matches the v0.1 layout") + SECTION ("total parameter count matches the current layout") { - CHECK (apvts.processor.getParameters().size() == 4); + CHECK (apvts.processor.getParameters().size() == 6); + } + + SECTION ("IR Blend: defaults to IR A only (0%) and covers its documented range") + { + checkFloatDefault (apvts, ParamIDs::irBlend, 0.0f); + checkFloatRange (apvts, ParamIDs::irBlend, 0.0f, 100.0f); + } + + SECTION ("Distance: defaults to its minimum (the bypassed/off position) and covers its documented range") + { + checkFloatDefault (apvts, ParamIDs::micDistance, CabConvolutionEngine::distanceMinPercent); + checkFloatRange (apvts, ParamIDs::micDistance, CabConvolutionEngine::distanceMinPercent, CabConvolutionEngine::distanceMaxPercent); } SECTION ("LoCut: defaults to its minimum (the bypassed/off position) and covers its documented range") @@ -96,5 +108,6 @@ TEST_CASE ("Processor instantiates with the expected parameters", "[processor][p SECTION ("No IR file path is set on a freshly constructed processor") { CHECK (processor.getCurrentIrFilePath().isEmpty()); + CHECK (processor.getCurrentIrFilePathB().isEmpty()); } } diff --git a/tests/StateTests.cpp b/tests/StateTests.cpp index bf1e0ce..3127dd1 100644 --- a/tests/StateTests.cpp +++ b/tests/StateTests.cpp @@ -14,21 +14,29 @@ TEST_CASE ("State round-trip preserves non-default values of every parameter", " auto* hiCutParam = processor.apvts.getParameter (ParamIDs::hiCut); auto* mixParam = processor.apvts.getParameter (ParamIDs::mix); auto* levelParam = processor.apvts.getParameter (ParamIDs::level); + auto* blendParam = processor.apvts.getParameter (ParamIDs::irBlend); + auto* distanceParam = processor.apvts.getParameter (ParamIDs::micDistance); REQUIRE (loCutParam != nullptr); REQUIRE (hiCutParam != nullptr); REQUIRE (mixParam != nullptr); REQUIRE (levelParam != nullptr); + REQUIRE (blendParam != nullptr); + REQUIRE (distanceParam != nullptr); loCutParam->setValueNotifyingHost (loCutParam->convertTo0to1 (250.0f)); hiCutParam->setValueNotifyingHost (hiCutParam->convertTo0to1 (3500.0f)); mixParam->setValueNotifyingHost (mixParam->convertTo0to1 (42.0f)); levelParam->setValueNotifyingHost (levelParam->convertTo0to1 (-6.5f)); + blendParam->setValueNotifyingHost (blendParam->convertTo0to1 (35.0f)); + distanceParam->setValueNotifyingHost (distanceParam->convertTo0to1 (60.0f)); const auto savedLoCut = loCutParam->getValue(); const auto savedHiCut = hiCutParam->getValue(); const auto savedMix = mixParam->getValue(); const auto savedLevel = levelParam->getValue(); + const auto savedBlend = blendParam->getValue(); + const auto savedDistance = distanceParam->getValue(); juce::MemoryBlock savedState; processor.getStateInformation (savedState); @@ -40,11 +48,15 @@ TEST_CASE ("State round-trip preserves non-default values of every parameter", " hiCutParam->setValueNotifyingHost (hiCutParam->getDefaultValue()); mixParam->setValueNotifyingHost (mixParam->getDefaultValue()); levelParam->setValueNotifyingHost (levelParam->getDefaultValue()); + blendParam->setValueNotifyingHost (blendParam->getDefaultValue()); + distanceParam->setValueNotifyingHost (distanceParam->getDefaultValue()); REQUIRE (loCutParam->getValue() != Catch::Approx (savedLoCut)); REQUIRE (hiCutParam->getValue() != Catch::Approx (savedHiCut)); REQUIRE (mixParam->getValue() != Catch::Approx (savedMix)); REQUIRE (levelParam->getValue() != Catch::Approx (savedLevel)); + REQUIRE (blendParam->getValue() != Catch::Approx (savedBlend)); + REQUIRE (distanceParam->getValue() != Catch::Approx (savedDistance)); processor.setStateInformation (savedState.getData(), static_cast (savedState.getSize())); @@ -52,6 +64,80 @@ TEST_CASE ("State round-trip preserves non-default values of every parameter", " CHECK (hiCutParam->getValue() == Catch::Approx (savedHiCut).margin (1e-6)); CHECK (mixParam->getValue() == Catch::Approx (savedMix).margin (1e-6)); CHECK (levelParam->getValue() == Catch::Approx (savedLevel).margin (1e-6)); + CHECK (blendParam->getValue() == Catch::Approx (savedBlend).margin (1e-6)); + CHECK (distanceParam->getValue() == Catch::Approx (savedDistance).margin (1e-6)); +} + +TEST_CASE ("State round-trip preserves the loaded IR B file path", "[state][ir]") +{ + NaveAudioProcessor processor; + processor.prepareToPlay (48000.0, 512); + + CHECK (processor.getCurrentIrFilePathB().isEmpty()); + + const auto irFile = juce::File::createTempFile (".wav"); + + juce::AudioBuffer ir (1, 8); + for (int i = 0; i < ir.getNumSamples(); ++i) + ir.setSample (0, i, 1.0f / static_cast (i + 1)); + + REQUIRE (TestHelpers::writeWavFile (irFile, ir, 48000.0)); + REQUIRE (processor.loadImpulseResponseFromFileB (irFile)); + + CHECK (processor.getCurrentIrFilePathB() == irFile.getFullPathName()); + + juce::MemoryBlock savedState; + processor.getStateInformation (savedState); + REQUIRE (savedState.getSize() > 0); + + processor.loadDefaultImpulseResponseB(); + CHECK (processor.getCurrentIrFilePathB().isEmpty()); + + processor.setStateInformation (savedState.getData(), static_cast (savedState.getSize())); + + CHECK (processor.getCurrentIrFilePathB() == irFile.getFullPathName()); + + irFile.deleteFile(); +} + +TEST_CASE ("State round-trip with both IR A and IR B loaded restores both independently", "[state][ir]") +{ + NaveAudioProcessor processor; + processor.prepareToPlay (48000.0, 512); + + const auto irFileA = juce::File::createTempFile (".wav"); + const auto irFileB = juce::File::createTempFile (".wav"); + + juce::AudioBuffer irA (1, 8); + juce::AudioBuffer irB (1, 8); + + for (int i = 0; i < 8; ++i) + { + irA.setSample (0, i, 1.0f / static_cast (i + 1)); + irB.setSample (0, i, -1.0f / static_cast (i + 1)); + } + + REQUIRE (TestHelpers::writeWavFile (irFileA, irA, 48000.0)); + REQUIRE (TestHelpers::writeWavFile (irFileB, irB, 48000.0)); + REQUIRE (processor.loadImpulseResponseFromFile (irFileA)); + REQUIRE (processor.loadImpulseResponseFromFileB (irFileB)); + + juce::MemoryBlock savedState; + processor.getStateInformation (savedState); + REQUIRE (savedState.getSize() > 0); + + processor.loadDefaultImpulseResponse(); + processor.loadDefaultImpulseResponseB(); + CHECK (processor.getCurrentIrFilePath().isEmpty()); + CHECK (processor.getCurrentIrFilePathB().isEmpty()); + + processor.setStateInformation (savedState.getData(), static_cast (savedState.getSize())); + + CHECK (processor.getCurrentIrFilePath() == irFileA.getFullPathName()); + CHECK (processor.getCurrentIrFilePathB() == irFileB.getFullPathName()); + + irFileA.deleteFile(); + irFileB.deleteFile(); } TEST_CASE ("State round-trip preserves the loaded IR file path", "[state][ir]") From 85f71e45a1e8a9c98b3320efb918e3b657d7dcbf Mon Sep 17 00:00:00 2001 From: Yves Vogl Date: Tue, 14 Jul 2026 16:26:22 +0200 Subject: [PATCH 4/6] docs: add user manual and update docs for IR Blend/Distance, prep v0.1.0 - New docs/manual.md: full user manual (what Nave is, signal flow, complete parameter reference, usage tips). - README.md: updated feature list, signal-flow diagram, parameter table, and roadmap to match the actual GitHub milestones (M1-M4). - docs/architecture.md: updated signal-flow diagram and added sections on IR Blend/inter-IR phase alignment and Distance emulation; latency/parameter-smoothing/real-time-safety sections updated for the two-slot convolution engine. - CLAUDE.md: Status/DSP sections updated to reflect the M1 DSP work and the deferred "bundled IR library" scope. - CHANGELOG.md: moved Unreleased content into a 0.1.0 section and documented everything added in this pass, including what was deferred and why. --- CHANGELOG.md | 14 +++++++- CLAUDE.md | 6 ++-- README.md | 40 ++++++++++++++------- docs/architecture.md | 58 ++++++++++++++++++++----------- docs/manual.md | 83 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 165 insertions(+), 36 deletions(-) create mode 100644 docs/manual.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bf62df..2ad5f36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.0] - 2026-07-14 + ### Added - Project bootstrap: README, license, contributing guide, architecture and build docs, ADRs, and CI workflow. -- DSP core: initial working Nave signal path with unit tests. +- DSP core: initial working Nave signal path (Convolution -> LoCut -> HiCut -> Dry/Wet Mix -> Level) with unit tests. +- **IR Blend**: a second, independently loadable impulse response slot (IR B) and an `IR Blend` parameter that crossfades between IR A and IR B (e.g. two cabs, or two mic positions on the same cab). Defaults to 0% (IR A only), bit-identical to the v0.1 single-IR signal path. +- **Inter-IR phase alignment**: loading IR B automatically time-shifts it so its transient onset lines up with IR A's, preventing comb-filtering when the two are blended together (`src/dsp/IrAlignment.{h,cpp}`). +- **Distance**: a simulated mic-to-cab distance control (post-convolution, pre-LoCut/HiCut) combining a proximity-effect low-shelf cut and a high-frequency "air absorption" high-shelf cut, both scaling with the parameter. Defaults to 0% ("off"), the same explicit-bypass-at-the-extreme pattern used by LoCut/HiCut, so the default state stays a true passthrough. +- Editor: "Load IR B..."/"Default" controls and an IR B file-name label alongside the existing IR A controls, plus IR Blend and Distance knobs. +- Broadened Catch2 test coverage: sample-rate sweep (44.1-192 kHz) null and finite-output tests, mono/stereo/unsupported bus-layout tests, long-run (2000-block and 300-block-with-loaded-IRs) NaN/Inf stability soak tests, and full unit coverage for IR Blend, Distance, and IR-onset-alignment behaviour. +- `docs/manual.md`: a full user manual (signal flow, parameter reference, usage tips). + +### Deferred + +- **IR browser + bundled IR library** (tracked in issue #1, left open): shipping a curated, bundled set of cabinet IRs requires either licensed real-world captures (an asset-sourcing/licensing task, not a DSP task) or synthetic placeholder IRs that could be mistaken for real captures - neither was implemented in this pass. IR Blend, Distance emulation, and inter-IR phase alignment - the DSP-engineering parts of that issue - are implemented; see the issue comment for the full rationale. diff --git a/CLAUDE.md b/CLAUDE.md index ce6e2c6..2dbd408 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,11 +5,11 @@ Per-repo working memory for Claude Code sessions on this plugin. Part of the **M ## What this is Nave is the "cabinet IR loader / convolution (guitar & bass)" member of the suite. AU / VST3 / Standalone, JUCE 8. -## Status (v0.1 — bootstrap complete) -Core DSP working, **22 Catch2 tests green**, CI (macOS + Windows, pluginval strictness 10 + auval) green. GUI is a functional v0.1 slider editor (custom LookAndFeel is roadmap M3). No signing yet (roadmap M4). Open work is tracked in this repo's GitHub **milestones/issues**. +## Status (v0.1.0 — M1 DSP completion & test coverage done) +Core DSP working, **50 Catch2 tests green**, CI (macOS + Windows, pluginval strictness 10 + auval) green. GUI is a functional v0.1 slider editor (custom LookAndFeel is roadmap M3). No signing yet (roadmap M4). M1 added IR Blend (dual-IR crossfade with inter-IR phase alignment) and simulated mic Distance; the "bundled IR library" part of M1's DSP issue is deferred (asset-sourcing/licensing, not a DSP task — see the issue comment on #1). Open work is tracked in this repo's GitHub **milestones/issues**. ## DSP -Nave is a cabinet IR loader built around juce::dsp::Convolution constructed with its default zero-latency, uniformly-partitioned configuration (Convolution::Latency{0}), chosen because reamping IRs are short and the workflow is latency-sensitive. Signal chain: Convolution (loaded IR or a default 1-sample unit-impulse) -> LoCut HPF -> HiCut LPF -> juce::dsp::DryWetMixer (delay-compensated, currently 0 samples) -> Level trim, matching the spec's stated order. LoCut's minimum (20 Hz) and HiCut's maximum (20 kHz) are treated as explicit "off"/bypass positions where the engine skips that filter's IIR processing entirely (not just an extreme cutoff) — this was necessary because even a 2nd-order Butterworth many octaves outside a test tone still imposes enough phase shift to fail a strict -80 dBFS sample-domain null; skipping it entirely gives a true bit-accurate passthrough at the default state. The IR file's absolute path is persisted as a plain ValueTree property on apvts.state (not an APVTS float parameter), round-tripping automatically through copyState()/replaceState(); file I/O only ever happens off the audio thread (editor FileChooser callback or setStateInformation, both message-thread/session-load contexts). +Nave is a cabinet IR loader built around two independent juce::dsp::Convolution slots (IR A, IR B), each constructed with the default zero-latency, uniformly-partitioned configuration (Convolution::Latency{0}), chosen because reamping IRs are short and the workflow is latency-sensitive. Signal chain: Convolution (crossfade of IR A/IR B via the IR Blend parameter) -> Distance (simulated mic-to-cab distance: proximity low-shelf + air-absorption high-shelf) -> LoCut HPF -> HiCut LPF -> juce::dsp::DryWetMixer (delay-compensated, currently 0 samples) -> Level trim. LoCut's minimum (20 Hz), HiCut's maximum (20 kHz), and Distance's minimum (0%) are each treated as explicit "off"/bypass positions where the engine skips that filter's IIR processing entirely (not just an extreme cutoff/gain) — this was necessary because even a 2nd-order Butterworth many octaves outside a test tone still imposes enough phase shift to fail a strict -80 dBFS sample-domain null; skipping it entirely gives a true bit-accurate passthrough at the default state (IR Blend defaults to 0%, i.e. IR A only, which is bit-identical to the pre-M1 single-IR path). Loading IR B applies inter-IR phase alignment (src/dsp/IrAlignment.{h,cpp}: onset detection via a relative-threshold crossing, then a time-domain shift) against IR A's most recently loaded onset, so blending the two never introduces comb-filtering from a timing mismatch. Both IR files' absolute paths are persisted as plain ValueTree properties on apvts.state (not APVTS float parameters), round-tripping automatically through copyState()/replaceState(); file I/O only ever happens off the audio thread (editor FileChooser callbacks or setStateInformation, both message-thread/session-load contexts). ## Build & test ```sh diff --git a/README.md b/README.md index 42d4119..4c121eb 100644 --- a/README.md +++ b/README.md @@ -10,39 +10,55 @@ ## What it is -Nave is a cabinet impulse-response (IR) loader built on JUCE 8, aimed at reamping guitar and bass DI tracks: load a cab (or full-rig) IR captured from a real speaker/mic setup and Nave convolves it with your DI signal using a zero-latency partitioned convolution engine, then shapes the result with a pair of post-convolution filters, a dry/wet mix, and an output trim. With no IR loaded, Nave runs a unit-impulse (delta) IR - mathematically a passthrough - so it is a valid, transparent effect straight out of the box. +Nave is a cabinet impulse-response (IR) loader built on JUCE 8, aimed at reamping guitar and bass DI tracks: load a cab (or full-rig) IR captured from a real speaker/mic setup and Nave convolves it with your DI signal using a zero-latency partitioned convolution engine, then shapes the result with a simulated mic-distance control, a pair of post-convolution filters, a dry/wet mix, and an output trim. With no IR loaded, Nave runs a unit-impulse (delta) IR - mathematically a passthrough - so it is a valid, transparent effect straight out of the box. See [`docs/manual.md`](docs/manual.md) for the full user manual. -## Features (v0.1 scope) +## Features -- **IR loading** - load any WAV/AIFF impulse response via a file chooser; loading happens off the audio thread and never blocks or allocates during playback -- **Zero-latency convolution** - `juce::dsp::Convolution`'s zero-latency uniformly partitioned algorithm, so Nave never adds plugin delay compensation overhead +- **IR loading, two independent slots** - load any WAV/AIFF impulse response into IR A and/or IR B via file choosers; loading happens off the audio thread and never blocks or allocates during playback +- **IR Blend** - crossfades between IR A and IR B (e.g. two cabs, or two mic positions on the same cab), with automatic inter-IR phase alignment so blending never introduces comb-filtering from a timing mismatch between the two IRs +- **Zero-latency convolution** - `juce::dsp::Convolution`'s zero-latency uniformly partitioned algorithm for both IR slots, so Nave never adds plugin delay compensation overhead +- **Distance** - simulated mic-to-cab distance (reduced proximity-effect bass + high-frequency air-absorption darkening as the value increases); an explicit "off" position at its default - **LoCut** - post-convolution high-pass, 20 Hz - 800 Hz (default 20 Hz, an explicit "off"/bypassed position), removes low-end mud - **HiCut** - post-convolution low-pass, 2 kHz - 20 kHz (default 20 kHz, also an explicit "off" position), tames fizz - **Mix** - dry/wet, default 100% (fully wet) - a cabinet IR is normally run fully in the signal path - **Level** - output trim, -24 dB to +24 dB -- Full state save/recall via `AudioProcessorValueTreeState`, including the loaded IR's file path +- Full state save/recall via `AudioProcessorValueTreeState`, including both loaded IRs' file paths ## Signal flow ``` -Input --> Convolution (loaded IR or default delta) --> LoCut (HPF, 20-800 Hz) --> HiCut (LPF, 2-20 kHz) - | - Output <-- Level (output trim) <-- Mix <--------------+ +Input --> Convolution (crossfade of IR A / IR B) --> Distance --> LoCut (HPF, 20-800 Hz) --> HiCut (LPF, 2-20 kHz) + | + Output <-- Level (output trim) <-- Mix <------------------------ + ^ | delay-compensated dry path ``` -See [`docs/architecture.md`](docs/architecture.md) for the full breakdown, including the convolution/latency strategy, the filter-bypass-at-range-extremes design, and IR file state handling. +See [`docs/architecture.md`](docs/architecture.md) for the full breakdown, including the convolution/latency strategy, the filter-bypass-at-range-extremes design, inter-IR phase alignment, and IR file state handling. + +## Parameters + +| Parameter | Range | Default | Unit | Description | +|---|---|---|---|---| +| LoCut | 20 – 800 | 20 (off) | Hz | Post-convolution high-pass filter; bypassed entirely at its minimum. | +| HiCut | 2000 – 20000 | 20000 (off) | Hz | Post-convolution low-pass filter; bypassed entirely at its maximum. | +| IR Blend | 0 – 100 | 0 (IR A only) | % | Crossfades between IR A and IR B. | +| Distance | 0 – 100 | 0 (off) | % | Simulated mic-to-cab distance coloration; bypassed entirely at its minimum. | +| Mix | 0 – 100 | 100 (fully wet) | % | Dry/wet blend against the original input. | +| Level | -24 – +24 | 0 | dB | Output trim, applied last. | + +Full musical context and usage tips: [`docs/manual.md`](docs/manual.md). ## Roadmap | Milestone | Description | Status | |---|---|---| | M0 | Bootstrap - project skeleton, CI, docs | Done | -| M1 | DSP core - IR loading, convolution, LoCut/HiCut/Mix/Level signal path, latency reporting, unit tests | Done | -| M2 | Custom GUI | Planned | -| M3 | Release engineering - signing, notarization, installers, v1.0.0 | Planned | +| M1 | DSP completion & test coverage - IR Blend, Distance emulation, inter-IR phase alignment, broadened Catch2 suite | Done (IR browser + bundled IR library deferred - see issue tracker) | +| M2 | Presets & state recall - preset system, factory presets | Planned | +| M3 | GUI & accessibility - custom LookAndFeel, accessibility pass | Planned | +| M4 | Release - code signing, notarization, installers, v1.0.0 | Planned | ## Installation diff --git a/docs/architecture.md b/docs/architecture.md index ece2b21..3569725 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,8 +4,12 @@ ```mermaid flowchart LR - IN[Input] --> CONV[Convolution
loaded IR or default delta] - CONV --> LOCUT[LoCut
HPF, 20-800 Hz] + IN[Input] --> CONVA[Convolution IR A] + IN --> CONVB[Convolution IR B] + CONVA --> BLEND[IR Blend
crossfade] + CONVB --> BLEND + BLEND --> DIST[Distance
proximity + air-absorption shelves] + DIST --> LOCUT[LoCut
HPF, 20-800 Hz] LOCUT --> HICUT[HiCut
LPF, 2-20 kHz] HICUT --> MIX[Dry/Wet Mix] IN -.->|delay-compensated dry path| MIX @@ -19,22 +23,34 @@ Everything from the convolution through HiCut is the "wet" path, owned by `CabCo | Directory | Responsibility | |---|---| -| `src/dsp` | All audio-thread DSP: `CabConvolutionEngine` (convolution, LoCut/HiCut filters, dry/wet mix, output level). No allocation, locks, or file I/O once `prepare()` has run. Independent of `juce::AudioProcessor` so it is directly unit-testable (see `tests/EngineTests.cpp`). | -| `src/params` | Parameter layout and `AudioProcessorValueTreeState` definitions - parameter IDs, ranges, defaults. Single source of truth for what a preset captures (aside from the IR file path, which is not an APVTS parameter - see [IR file loading and state](#ir-file-loading-and-state)). | -| `src/PluginProcessor.*` | Host plumbing: APVTS construction, `prepareToPlay`/`processBlock`/`reset`, latency reporting, state save/load, and IR file I/O (`loadImpulseResponseFromFile`/`loadDefaultImpulseResponse`). Reads APVTS values and pushes them into `CabConvolutionEngine` every block; does not implement any DSP itself. | -| `src/PluginEditor.*` | A simple, functional v0.1 GUI: one rotary slider per parameter bound via `SliderAttachment`, plus "Load IR..."/"Default" buttons and a label showing the currently loaded IR's file name. A custom vector-drawn GUI is a later milestone. | +| `src/dsp` | All audio-thread DSP: `CabConvolutionEngine` (two convolution slots + IR Blend crossfade, Distance shelving filters, LoCut/HiCut filters, dry/wet mix, output level) and `IrAlignment` (pure, off-audio-thread helper functions for inter-IR phase alignment). No allocation, locks, or file I/O once `prepare()` has run. Independent of `juce::AudioProcessor` so it is directly unit-testable (see `tests/EngineTests.cpp`, `tests/IrAlignmentTests.cpp`). | +| `src/params` | Parameter layout and `AudioProcessorValueTreeState` definitions - parameter IDs, ranges, defaults. Single source of truth for what a preset captures (aside from the IR file paths, which are not APVTS parameters - see [IR file loading and state](#ir-file-loading-and-state)). | +| `src/PluginProcessor.*` | Host plumbing: APVTS construction, `prepareToPlay`/`processBlock`/`reset`, latency reporting, state save/load, and IR file I/O for both slots (`loadImpulseResponseFromFile[B]`/`loadDefaultImpulseResponse[B]`). Reads APVTS values and pushes them into `CabConvolutionEngine` every block; does not implement any DSP itself. | +| `src/PluginEditor.*` | A simple, functional v0.1 GUI: one rotary slider per parameter bound via `SliderAttachment`, plus "Load IR.../Default" and "Load IR B.../Default" button pairs and labels showing each loaded IR's file name. A custom vector-drawn GUI is a later milestone. | -Dependency direction is one-way: `PluginEditor` -> `params` (via attachments) and `PluginProcessor` -> `params` + `dsp`. `src/params` depends on `src/dsp` only for its `CabConvolutionEngine::loCutMinHz`/`loCutMaxHz`/`hiCutMinHz`/`hiCutMaxHz` range constants, so the parameter ranges and the engine's own bypass-threshold logic can never drift out of sync. `src/dsp` itself has no upward dependency on the processor, params, or UI, which is what keeps `CabConvolutionEngine` testable in isolation and free of any file-I/O concerns. +Dependency direction is one-way: `PluginEditor` -> `params` (via attachments) and `PluginProcessor` -> `params` + `dsp`. `src/params` depends on `src/dsp` only for its `CabConvolutionEngine::loCutMinHz`/`loCutMaxHz`/`hiCutMinHz`/`hiCutMaxHz`/`distanceMinPercent`/`distanceMaxPercent` range constants, so the parameter ranges and the engine's own bypass-threshold logic can never drift out of sync. `src/dsp` itself has no upward dependency on the processor, params, or UI, which is what keeps `CabConvolutionEngine` testable in isolation and free of any file-I/O concerns. ## Filter bypass at the range extremes -LoCut's default (its range minimum, 20 Hz) and HiCut's default (its range maximum, 20 kHz) are each an explicit "off" position: rather than merely computing an extreme-but-still-active filter cutoff, `CabConvolutionEngine::process()` skips that filter's IIR processing entirely whenever the smoothed frequency is within `bypassEpsilonHz` of its bypass extreme. This is a deliberate design choice, not an incidental optimisation - even a 2nd-order Butterworth filter with a cutoff many octaves outside a test tone's frequency still imposes a small, real phase shift (asymptotically proportional to the inverse of the frequency ratio), which is enough to defeat a strict sample-domain null test long before the ratio becomes impractically large for a plugin's real parameter range. Skipping the filter entirely at the extremes guarantees the plugin's default state - and any explicit "LoCut/HiCut wide open" setting - is a true, bit-accurate passthrough (down to floating-point precision), which is exactly what `tests/EngineTests.cpp`'s null tests verify. +LoCut's default (its range minimum, 20 Hz), HiCut's default (its range maximum, 20 kHz), and Distance's default (its range minimum, 0%) are each an explicit "off" position: rather than merely computing an extreme-but-still-active filter cutoff/gain, `CabConvolutionEngine::process()` skips that filter's IIR processing entirely whenever the smoothed value is within its bypass epsilon of the extreme. This is a deliberate design choice, not an incidental optimisation - even a 2nd-order Butterworth filter with a cutoff many octaves outside a test tone's frequency still imposes a small, real phase shift (asymptotically proportional to the inverse of the frequency ratio), which is enough to defeat a strict sample-domain null test long before the ratio becomes impractically large for a plugin's real parameter range. Skipping the filter entirely at the extremes guarantees the plugin's default state - and any explicit "LoCut/HiCut wide open, Distance off" setting - is a true, bit-accurate passthrough (down to floating-point precision), which is exactly what `tests/EngineTests.cpp`'s and `tests/CoverageTests.cpp`'s null tests verify. -When a filter transitions from bypassed to engaged, `CabConvolutionEngine::process()` resets that filter's IIR state first, so it always starts from a clean, predictable state rather than reusing whatever memory it was left in an arbitrary number of blocks ago. This is a deliberate simplification: it produces a normal filter turn-on transient (the same kind any IIR filter has starting from silence) rather than attempting to avoid it, which would require cross-fading between bypassed and engaged paths. +When a filter transitions from bypassed to engaged, `CabConvolutionEngine::process()` resets that filter's IIR state first (Distance's two shelving filters are reset together, since they share a single bypass gate driven by one parameter), so it always starts from a clean, predictable state rather than reusing whatever memory it was left in an arbitrary number of blocks ago. This is a deliberate simplification: it produces a normal filter turn-on transient (the same kind any IIR filter has starting from silence) rather than attempting to avoid it, which would require cross-fading between bypassed and engaged paths. + +IR Blend uses an analogous, but *value-driven rather than range-extreme*, optimisation: `convolutionB.process()` only runs when Blend is above a small epsilon above 0%, since IR A alone is the entire signal at Blend = 0%. Unlike LoCut/HiCut/Distance, Blend = 0% is not a special-cased "different code path" for correctness reasons - it falls out naturally from the crossfade math (`a * (1 - blend) + b * blend`) - skipping IR B's convolution there is purely a CPU optimisation, verified by `tests/EngineTests.cpp`'s "IR Blend at 0%" test. + +## IR Blend and inter-IR phase alignment + +`CabConvolutionEngine` owns two independent `juce::dsp::Convolution` instances ("IR A" and "IR B"), each with its own default (zero-latency) configuration and its own default delta IR. `process()` always runs IR A; it additionally runs IR B (into a pre-allocated scratch buffer sized in `prepare()`, never resized on the audio thread) and crossfades the two per-block whenever the smoothed IR Blend value is above a small epsilon - see [Filter bypass at the range extremes](#filter-bypass-at-the-range-extremes) above. If a host ever sends a block larger than `prepare()` promised, the scratch buffer's capacity is checked before writing into it; if the block wouldn't fit, Blend is simply treated as disengaged for that one block (falling back to IR A only) rather than risking an out-of-bounds write. + +Two independently-captured impulse responses rarely share the same "time zero" - different mic distances, different capture/measurement setups - so naively crossfading them would partially cancel a wide band of frequencies (comb filtering) wherever their transients don't line up. `CabConvolutionEngine::setImpulseResponseB()` addresses this with **inter-IR phase alignment**: before IR B is loaded into `convolutionB`, `IrAlignment::alignOnsetToReference()` (`src/dsp/IrAlignment.{h,cpp}`) time-shifts it so its detected onset lines up with IR A's most recently recorded onset (`lastIrAOnsetSample`/`lastIrASampleRate`, updated by `setImpulseResponse()`/`loadDefaultImpulseResponse()`). Onset detection is a simple, cheap relative-threshold crossing (the first sample, across all channels, whose magnitude reaches 20% of the buffer's own peak) - deliberate given cabinet IRs have one dominant direct-sound transient, rather than a full cross-correlation search. Alignment is computed in *time* (seconds), not raw sample index, so it stays correct even when IR A and IR B are captured at different sample rates. All of this happens off the audio thread (`setImpulseResponseB()`'s documented contract, same as `setImpulseResponse()`) - see `tests/IrAlignmentTests.cpp` for direct coverage of the alignment math, and `tests/EngineTests.cpp`'s IR Blend tests for the end-to-end engine behaviour. + +## Distance emulation + +The Distance parameter is a deliberately simplified, musically-motivated approximation of moving a mic further from a cabinet, not a physically exact model - it applies no timing/pre-delay change, only two shelving filters (`distanceLowShelfFilter`, `distanceHighShelfFilter`) whose gain scales linearly (in dB) with the normalised Distance value: a low-shelf cut around 200 Hz (reduced proximity-effect bass buildup as the simulated mic moves away) and a high-shelf cut around 5 kHz (high-frequency air absorption / off-axis darkening). Both are driven by a single parameter and share one bypass gate (see above), applied post-convolution/post-Blend and pre-LoCut/HiCut, so a user's own tone-shaping filters always act on top of whatever Distance coloration is dialled in, not the other way around. ## Latency and the convolution engine -`CabConvolutionEngine` constructs `juce::dsp::Convolution` with its default configuration (`Latency{0}`), which selects the zero-latency uniformly partitioned convolution algorithm - the shortest-latency option JUCE offers, at the cost of higher CPU use than the fixed-latency or non-uniform alternatives (both of which trade some added, fixed delay for lower CPU). This is the right trade-off for a cabinet IR loader: cab IRs used for reamping are short (typically well under a second, often just a few hundred milliseconds), and reamping workflows are latency-sensitive (the plugin sits directly in a tracking chain), so keeping latency at zero is worth the CPU cost. `CabConvolutionEngine::getLatencySamples()` returns `juce::dsp::Convolution::getLatency()` directly, and `NaveAudioProcessor::prepareToPlay()` reports it to the host via `setLatencySamples()`, so host-side plugin delay compensation (PDC) accounts for the whole chain - though in practice this is always zero for this engine's configuration. +`CabConvolutionEngine` constructs both `juce::dsp::Convolution` instances (IR A and IR B) with their default configuration (`Latency{0}`), which selects the zero-latency uniformly partitioned convolution algorithm - the shortest-latency option JUCE offers, at the cost of higher CPU use than the fixed-latency or non-uniform alternatives (both of which trade some added, fixed delay for lower CPU). This is the right trade-off for a cabinet IR loader: cab IRs used for reamping are short (typically well under a second, often just a few hundred milliseconds), and reamping workflows are latency-sensitive (the plugin sits directly in a tracking chain), so keeping latency at zero is worth the CPU cost. `CabConvolutionEngine::getLatencySamples()` returns `juce::jmax(convolution.getLatency(), convolutionB.getLatency())` - in practice always zero, since both slots always use the same zero-latency configuration, but computed generically so the dry path stays correctly compensated if a slot's configuration ever changes independently in future. `NaveAudioProcessor::prepareToPlay()` reports this to the host via `setLatencySamples()`, so host-side plugin delay compensation (PDC) accounts for the whole chain. The dry path used by the Mix control still needs to stay time-aligned with the wet path in general (in case a future milestone ever changes the convolution configuration), so `CabConvolutionEngine` uses `juce::dsp::DryWetMixer` rather than a hand-rolled delay line: the pre-processing signal is captured via `pushDrySamples()` before the convolution or filters touch the buffer, and `setWetLatency(getLatencySamples())` configures the mixer's internal delay line to match (currently always 0). `mixWetSamples()` then blends the two back together, so at Mix = 100% the output is (once the filters are bypassed, per above) a sample-accurate passthrough of the input - the exact scenario `tests/EngineTests.cpp`'s null tests verify, to well under -80 dBFS residual. @@ -44,33 +60,35 @@ One JUCE 8.0.14 behaviour worth calling out because it cost real debugging time `juce::dsp::Convolution::loadImpulseResponse()` is documented as wait-free (the call itself never blocks or allocates on the calling thread), but the actual work of building the new convolution engine from the loaded IR happens **asynchronously**, on a background thread owned by the `Convolution`'s internal `ConvolutionMessageQueue`. Immediately after `loadImpulseResponse()` returns, `process()` may still be running the *previous* IR for some number of blocks - this is by design, and lets a live IR swap mid-playback cross-fade smoothly rather than glitching. -The one point at which a newly loaded IR is *guaranteed* to be synchronously installed is the next call to `Convolution::prepare()`: internally, `prepare()` first drains (and synchronously executes) any pending load command before rebuilding the active engine from whatever IR is now current. `CabConvolutionEngine` relies on this in two places: `prepare()` itself always loads before preparing (per the class's own documented contract), and any test or caller that needs a freshly loaded IR to be active *before the very next `process()` call* (rather than fading in over a live session) must call `prepare()` again after `setImpulseResponse()`/`loadDefaultImpulseResponse()` - see `tests/EngineTests.cpp`'s convolution-change test. In normal plugin use this doesn't matter: a user loading a new IR via the editor while the host is playing gets the intended smooth, glitch-free cross-fade instead. +The one point at which a newly loaded IR is *guaranteed* to be synchronously installed is the next call to `Convolution::prepare()`: internally, `prepare()` first drains (and synchronously executes) any pending load command before rebuilding the active engine from whatever IR is now current. `CabConvolutionEngine` relies on this in two places: `prepare()` itself always loads before preparing both slots (per the class's own documented contract), and any test or caller that needs a freshly loaded IR to be active *before the very next `process()` call* (rather than fading in over a live session) must call `prepare()` again after `setImpulseResponse[B]()`/`loadDefaultImpulseResponse[B]()` - see `tests/EngineTests.cpp`'s convolution-change and IR Blend tests. In normal plugin use this doesn't matter: a user loading a new IR via the editor while the host is playing gets the intended smooth, glitch-free cross-fade instead. ## Convolution engine retention across `prepare()` -`juce::dsp::Convolution` internally retains the most recently loaded impulse response (and its original sample rate) across calls to `prepare()`, automatically re-resampling it against whatever new `ProcessSpec::sampleRate` is supplied. `CabConvolutionEngine::prepare()` relies on this: it only calls `loadDefaultImpulseResponse()` the first time it is ever prepared (tracked via `anyImpulseResponseLoaded`), not on every re-prepare (sample-rate change, etc.) - a previously loaded user IR survives a sample-rate change without needing to be manually reloaded. +`juce::dsp::Convolution` internally retains the most recently loaded impulse response (and its original sample rate) across calls to `prepare()`, automatically re-resampling it against whatever new `ProcessSpec::sampleRate` is supplied. `CabConvolutionEngine::prepare()` relies on this for both slots: it only calls `loadDefaultImpulseResponse()`/`loadDefaultImpulseResponseB()` the first time each slot is ever prepared (tracked via `anyImpulseResponseLoaded`/`anyImpulseResponseBLoaded`), not on every re-prepare (sample-rate change, etc.) - a previously loaded user IR survives a sample-rate change without needing to be manually reloaded. ## IR file loading and state -The currently loaded IR file's absolute path is **not** an `AudioProcessorValueTreeState` parameter - a file path has no meaningful float representation, and APVTS parameters are designed for continuously automatable values. Instead, `NaveAudioProcessor::loadImpulseResponseFromFile()` stores it directly as a plain property (`ParamIDs::irFilePathProperty`) on the live `apvts.state` `ValueTree`. Because `AudioProcessorValueTreeState::copyState()`/`replaceState()` preserve arbitrary tree properties (not just the parameter child nodes they manage), this path round-trips through the normal `getStateInformation()`/`setStateInformation()` flow without any extra serialisation code. +The currently loaded IR files' absolute paths are **not** `AudioProcessorValueTreeState` parameters - a file path has no meaningful float representation, and APVTS parameters are designed for continuously automatable values. Instead, `NaveAudioProcessor::loadImpulseResponseFromFile()`/`loadImpulseResponseFromFileB()` store them directly as plain properties (`ParamIDs::irFilePathProperty`/`irFilePathBProperty`) on the live `apvts.state` `ValueTree`. Because `AudioProcessorValueTreeState::copyState()`/`replaceState()` preserve arbitrary tree properties (not just the parameter child nodes they manage), these paths round-trip through the normal `getStateInformation()`/`setStateInformation()` flow without any extra serialisation code. -`loadImpulseResponseFromFile()` performs blocking file I/O (via `juce::AudioFormatManager`/`AudioFormatReader`) and must only be called off the audio thread - from the editor's `juce::FileChooser` callback (message thread) or from `setStateInformation()` (a session/preset-load operation, which JUCE guarantees is never called from the audio thread). The resulting `juce::AudioBuffer` is then moved into `CabConvolutionEngine::setImpulseResponse()`, which forwards it to `juce::dsp::Convolution::loadImpulseResponse()` - a call documented by JUCE as wait-free, so it is safe regardless of which thread ultimately invokes it, even though this plugin only ever calls it from the message thread. +`loadImpulseResponseFromFile[B]()` perform blocking file I/O (via `juce::AudioFormatManager`/`AudioFormatReader`) and must only be called off the audio thread - from the editor's `juce::FileChooser` callbacks (message thread) or from `setStateInformation()` (a session/preset-load operation, which JUCE guarantees is never called from the audio thread). The resulting `juce::AudioBuffer` is then moved into `CabConvolutionEngine::setImpulseResponse()`/`setImpulseResponseB()`, which forward it (after phase alignment, for slot B - see [IR Blend and inter-IR phase alignment](#ir-blend-and-inter-ir-phase-alignment)) to `juce::dsp::Convolution::loadImpulseResponse()` - a call documented by JUCE as wait-free, so it is safe regardless of which thread ultimately invokes it, even though this plugin only ever calls it from the message thread. -`setStateInformation()` reads the restored `irFilePathProperty` after `apvts.replaceState()` and, if it points at a file that still exists, calls `loadImpulseResponseFromFile()` again to bring the convolution engine's loaded IR back in sync with the restored state. If the stored path is empty, or points at a file that no longer exists, the engine falls back to the default delta IR via `loadDefaultImpulseResponse()` (which also clears the stored path in the latter case) rather than silently keeping whatever IR happened to be loaded beforehand. +`setStateInformation()` reads the restored `irFilePathProperty` after `apvts.replaceState()` and, if it points at a file that still exists, calls `loadImpulseResponseFromFile()` again to bring IR A's loaded IR back in sync with the restored state (falling back to `loadDefaultImpulseResponse()` if the stored path is empty or the file no longer exists); it then does the same for IR B via `irFilePathBProperty`/`loadImpulseResponseFromFileB()`/`loadDefaultImpulseResponseB()`. IR A is always restored first, so it becomes the phase-alignment reference IR B is loaded against - matching how the two are loaded during normal interactive use. ## Parameter smoothing - **LoCut** and **HiCut** are filter cutoff frequencies. Recomputing IIR coefficients involves trig calls, so these are not cheap to interpolate per sample; instead, each is smoothed with a `juce::SmoothedValue` (multiplicative smoothing suits frequencies, which are perceived logarithmically) and the filter coefficients (when not bypassed) are recomputed once per block from the smoothed value - a standard real-time-safe compromise. +- **Distance** is smoothed with a `juce::SmoothedValue` (it's a percentage, not a frequency); the two shelving filters' coefficients (when not bypassed) are likewise recomputed once per block from the smoothed value. +- **IR Blend** is smoothed with its own `juce::SmoothedValue` and applied as a simple per-block scalar crossfade (`a * (1 - blend) + b * blend`) - the same once-per-block granularity as the filter coefficients above, not a per-sample ramp, which is an acceptable trade-off given Blend is a slow, occasional tonal adjustment rather than a fast-moving control. - **Level** is a plain gain stage (`juce::dsp::Gain`), which ramps sample-accurately via its own internal `SmoothedValue` (`setRampDurationSeconds`). - **Mix** is smoothed both by the engine's own `juce::SmoothedValue` (feeding `DryWetMixer::setWetMixProportion()` once per block) and by `DryWetMixer`'s own internal ~50 ms ramp on top of that. -- All smoothers are seeded to their real starting value in `CabConvolutionEngine::prepare()` (see `lastLoCutHz`/`lastHiCutHz`/`lastMixProportion`), so re-preparing (sample-rate change, etc.) never resets a live parameter back to a built-in default or lets a smoother ramp from an invalid 0 Hz/0.0 starting point. +- All smoothers are seeded to their real starting value in `CabConvolutionEngine::prepare()` (see `lastLoCutHz`/`lastHiCutHz`/`lastMixProportion`/`lastBlendProportion`/`lastDistancePercent`), so re-preparing (sample-rate change, etc.) never resets a live parameter back to a built-in default or lets a smoother ramp from an invalid starting point. ## Real-time safety - `NaveAudioProcessor::processBlock()` starts with `juce::ScopedNoDenormals`. -- All DSP state (the convolution engine, filters, the dry/wet delay line) is allocated in `prepare()`/`prepareToPlay()` and never reallocated on the audio thread. +- All DSP state (both convolution engines, filters, the dry/wet delay line, the IR Blend scratch buffer) is allocated in `prepare()`/`prepareToPlay()` and never reallocated on the audio thread. - `reset()` clears all filter/convolution/delay-line state without deallocating (`CabConvolutionEngine::reset()`, called from both `AudioProcessor::reset()` and internally from `prepare()`). - Parameter values are read via `apvts.getRawParameterValue()` atomics in `processBlock()`, never via `apvts.getParameter()->getValue()` (which is not guaranteed lock/allocation-free) and never via `String`-keyed lookups on the audio thread. -- `CabConvolutionEngine::process()` treats a zero-sample block as a safe no-op before touching any filter/convolution state. -- IR file loading (`loadImpulseResponseFromFile`) is only ever invoked from the message thread (editor) or from `setStateInformation()` (session/preset load) - never from `processBlock()`. The actual `juce::dsp::Convolution::loadImpulseResponse()` call it makes is documented as wait-free regardless. -- Filter cutoff frequencies passed to `IIR::Coefficients::makeHighPass`/`makeLowPass` are clamped below Nyquist (`clampBelowNyquist`, in `CabConvolutionEngine.cpp`) as defensive insurance against invalid coefficients if the plugin is ever prepared at an unusually low sample rate. +- `CabConvolutionEngine::process()` treats a zero-sample block as a safe no-op before touching any filter/convolution state, and defensively falls back to treating IR Blend as disengaged (rather than risking an out-of-bounds write) if a block ever arrives larger than the scratch buffer `prepare()` sized for it. +- IR file loading (`loadImpulseResponseFromFile[B]`) is only ever invoked from the message thread (editor) or from `setStateInformation()` (session/preset load) - never from `processBlock()`. The actual `juce::dsp::Convolution::loadImpulseResponse()` call it makes is documented as wait-free regardless. The inter-IR phase-alignment functions it depends on (`IrAlignment::*`) allocate and are likewise only ever called from those same off-audio-thread contexts. +- Filter/shelf cutoff frequencies passed to `IIR::Coefficients::makeHighPass`/`makeLowPass`/`makeLowShelf`/`makeHighShelf` are clamped below Nyquist where applicable (`clampBelowNyquist`, in `CabConvolutionEngine.cpp`) as defensive insurance against invalid coefficients if the plugin is ever prepared at an unusually low sample rate. diff --git a/docs/manual.md b/docs/manual.md new file mode 100644 index 0000000..8625287 --- /dev/null +++ b/docs/manual.md @@ -0,0 +1,83 @@ +# Nave user manual + +*Cabinet impulse-response loader for guitar and bass reamping.* + +## What Nave is + +Nave takes a dry, un-amped instrument signal (a DI guitar or bass track, or the pre-cab output of an amp sim) and convolves it with the impulse response ("IR") of a real (or emulated) speaker cabinet and microphone. In other words: Nave is where a dry, buzzy DI signal becomes something that sounds like it was mic'd off a real cab in a room. + +In a symphonic-metal production chain, Nave typically sits **after** distortion/amp-sim processing and **before** EQ/bus processing: + +``` +DI guitar/bass -> amp sim / preamp distortion -> Nave (cab IR) -> EQ / compression -> mix bus +``` + +It's equally at home reamping a recorded DI track after the fact, or running live in a monitoring chain while tracking. + +## Signal flow + +``` +Input --> Convolution (crossfade of IR A / IR B) --> Distance --> LoCut (HPF) --> HiCut (LPF) + | + Output <-- Level (output trim) <-- Mix <--------------+ + ^ + | + delay-compensated dry path +``` + +1. **Convolution.** Your instrument signal is convolved with the loaded impulse response(s). With no IR loaded, Nave runs a mathematically transparent unit-impulse ("delta") IR — it's a valid, silent-by-default effect out of the box, not a placeholder that colours your sound until you load something. +2. **Distance.** An optional, simulated mic-distance coloration (see [Distance](#distance-simulated-mic-distance) below). Off by default. +3. **LoCut / HiCut.** Two general-purpose tone-shaping filters for cleaning up the convolved signal — a high-pass to tighten the low end, a low-pass to tame fizz/harshness. Both are off by default (wide open). +4. **Mix.** Blends the fully-processed ("wet") signal back with your original dry input. Defaults to 100% wet — a cab IR is normally run fully in the chain, not blended with the raw DI. +5. **Level.** A final output trim, so switching cabs/settings doesn't also throw off your downstream gain staging. + +See [`architecture.md`](architecture.md) for the implementation-level details (latency handling, filter-bypass semantics, IR file state). + +## Loading impulse responses + +Nave has **two independent IR slots**, A and B: + +- **IR A** — the primary/original slot. Use the **Load IR...** button to pick a `.wav`/`.aiff` cabinet IR file; **Default** clears it back to the built-in transparent delta IR. +- **IR B** — a secondary slot, loaded and cleared the same way via **Load IR B...** / **Default**. On its own it does nothing (see [IR Blend](#ir-blend) below) — it only matters once you dial in some Blend. + +Both slots' file paths are saved with your session/preset, so a project reopens with the same cabs loaded. + +### IR Blend + +The **IR Blend** knob crossfades between IR A (0%) and IR B (100%). Typical uses: + +- **Two different cabs** — blend a tight 4x12 with a boomier 2x12 to taste, without needing a separate blending plugin. +- **Two mic positions on the same cab** — e.g. an on-axis close mic (IR A) blended with a room/ambient mic (IR B) for more dimension. + +When you load IR B, Nave automatically **phase-aligns** it to IR A's transient onset before the two are ever mixed together. Two real-world IR captures rarely start at exactly the same moment (different mic distances, different capture setups), and blending misaligned IRs directly would partially cancel a wide band of frequencies (comb filtering) — the alignment step prevents that, so IR Blend sounds like a genuine tonal blend rather than a phasey mess. + +Blend defaults to 0% (IR A only) — loading an IR B and leaving Blend at 0% has no audible effect until you turn the knob up. + +### Distance (simulated mic distance) + +The **Distance** knob is a simplified emulation of moving the mic further from the cab: at higher settings it gently reduces low-end proximity buildup and dulls the top end slightly (simulating high-frequency air absorption and off-axis darkening). It is *not* a physically exact distance model — no pre-delay/timing change is applied — just a musically useful tonal shift for pushing a too-close/too-bright IR back in the mix, without reaching for a separate EQ. + +Distance defaults to 0% ("off" — no coloration applied at all, a true passthrough at this stage of the chain). + +## Parameter reference + +| Parameter | Range | Default | Unit | What it does | +|---|---|---|---|---| +| **LoCut** | 20 – 800 | 20 (off) | Hz | Post-convolution high-pass filter. At its minimum (20 Hz, the default) it's fully bypassed — a true passthrough, not just an inaudible cutoff. Raise it to tighten a boomy cab IR or tame low-end mud before the low end hits your amp/bus processing. | +| **HiCut** | 2000 – 20000 | 20000 (off) | Hz | Post-convolution low-pass filter. At its maximum (20 kHz, the default) it's fully bypassed. Lower it to tame fizz, harshness, or excessive top-end from a bright IR — a classic move on high-gain metal guitar tones. | +| **IR Blend** | 0 – 100 | 0 (IR A only) | % | Crossfades between IR A (0%) and IR B (100%). See [IR Blend](#ir-blend). Has no audible effect unless an IR is loaded into slot B. | +| **Distance** | 0 – 100 | 0 (off) | % | Simulated mic-to-cab distance: reduces proximity-effect bass and adds high-frequency darkening as the value increases. See [Distance](#distance-simulated-mic-distance). | +| **Mix** | 0 – 100 | 100 (fully wet) | % | Dry/wet blend of the fully-processed signal against your original input. Lower it for a parallel/blended cab tone, or to taste-test how much of the IR's character you actually want. | +| **Level** | -24 – +24 | 0 | dB | Output trim, applied last. Use it to match gain staging after swapping IRs or dialling in Mix/Blend/Distance, all of which can shift the overall level. | + +## Latency + +Nave uses JUCE's zero-latency convolution algorithm by design — cab IRs used for reamping are short, and reamping/tracking workflows are latency-sensitive, so Nave never reports plugin delay compensation to the host. This holds regardless of how many of the above features (IR Blend, Distance, LoCut/HiCut) are engaged. + +## Tips + +- **Start with LoCut/HiCut at their defaults (off)** and only bring them in if the raw IR needs shaping — a well-captured cab IR often doesn't need much, if any, extra filtering, and adding filters you don't need just costs headroom and CPU for no benefit. +- **For a punchier metal rhythm tone**, try blending a tight, close-mic'd 4x12 IR (IR A) with a small amount of a slightly darker/roomier IR B (10-25% Blend) rather than reaching for a second cab-sim plugin. +- **Distance is a finishing touch, not a tone-shaping tool** — if you need a specific frequency response, use LoCut/HiCut (or your EQ downstream) instead; Distance is meant for a light "push it back in the room" adjustment. +- **If a loaded IR sounds thin or boxy after Blend/Distance changes, check Level** — none of Mix, Blend, or Distance are gain-compensated against each other, by design (so you always know exactly what you're hearing), which means Level is your one-stop place to correct any resulting level mismatch before it hits your mix bus. +- **Null-test your default settings** if you're ever unsure whether Nave is coloring your signal: with no IR loaded (or IR A left at its default) and LoCut/HiCut/Distance all at their defaults, Nave is a certified bit-accurate passthrough (see the project's own null tests in `tests/EngineTests.cpp` and `tests/CoverageTests.cpp`). From 52b5399055613ea6baed4da64fc76f1ae8d92323 Mon Sep 17 00:00:00 2001 From: Yves Vogl Date: Tue, 14 Jul 2026 17:31:00 +0200 Subject: [PATCH 5/6] fix(dsp): capture dry input before IR A convolution for IR Blend CabConvolutionEngine::process() copied the scratch buffer for IR B's convolution *after* convolution.process() (IR A) had already mutated `block` in place, so the "B" side of the crossfade convolved IR A's already-processed output instead of the original dry input - IR_B(IR_A(input)) instead of the intended IR_B(input). This only stayed hidden while IR A was the untouched identity/delta IR, exactly the setup every prior IR Blend test used. Copy the pre-convolution samples into scratchBuffer before convolution.process() runs, so both convolution branches process the same original input. Add a regression test that loads two distinct, non-identity IRs into both slots (the plugin's headline use case) and verifies the blended output against references rendered independently of the blend code path - a naive reference derived from the buggy blend branch itself would stay linear in Blend and mask the defect. --- src/dsp/CabConvolutionEngine.cpp | 15 +++- tests/EngineTests.cpp | 122 +++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 1 deletion(-) diff --git a/src/dsp/CabConvolutionEngine.cpp b/src/dsp/CabConvolutionEngine.cpp index dc9cd92..82cc2a8 100644 --- a/src/dsp/CabConvolutionEngine.cpp +++ b/src/dsp/CabConvolutionEngine.cpp @@ -345,13 +345,26 @@ void CabConvolutionEngine::process (juce::dsp::AudioBlock& block) // when Blend is actually engaged, saving the second convolution's CPU // cost otherwise - the same "skip work that provably can't matter" // pattern LoCut/HiCut/Distance use above. + // + // IR B must convolve the same original (dry) input as IR A, not IR A's + // already-convolved output - so the pre-convolution samples are copied + // into scratchBuffer *before* convolution.process() mutates `block` in + // place. Getting this ordering wrong would silently turn the "B" + // component of the crossfade into IR_B(IR_A(input)), a cascaded double + // convolution, instead of the intended IR_B(input). + if (blendEngaged) + { + juce::dsp::AudioBlock scratchBlock (scratchBuffer); + auto scratchSub = scratchBlock.getSubBlock (0, numSamples); + scratchSub.copyFrom (block); + } + convolution.process (context); if (blendEngaged) { juce::dsp::AudioBlock scratchBlock (scratchBuffer); auto scratchSub = scratchBlock.getSubBlock (0, numSamples); - scratchSub.copyFrom (block); juce::dsp::ProcessContextReplacing contextB (scratchSub); convolutionB.process (contextB); diff --git a/tests/EngineTests.cpp b/tests/EngineTests.cpp index c5b9e24..348a5fd 100644 --- a/tests/EngineTests.cpp +++ b/tests/EngineTests.cpp @@ -4,6 +4,7 @@ #include #include +#include namespace { @@ -364,6 +365,127 @@ TEST_CASE ("IR Blend at 50% sits between IR A alone and IR B alone", "[dsp][engi CHECK (peakAtHalf > peakAtB); } +TEST_CASE ("IR Blend with two distinct, non-identity IRs in both slots blends them in parallel, not in series", + "[dsp][engine][blend]") +{ + // Regression coverage for the plugin's headline IR Blend use case: two + // independently-captured, non-identity cab IRs (e.g. "a tight 4x12 with + // a boomier 2x12"), unlike every other blend test above, which leaves + // IR A at the default identity/delta IR and so cannot distinguish a + // correct parallel blend from an erroneous cascaded one (IR B applied + // on top of IR A's already-convolved output instead of the original dry + // input) - both slots below carry real, decaying, non-delta taps. + const auto makeIrA = [] + { + juce::AudioBuffer ir (1, 4); + ir.setSample (0, 0, 1.0f); + ir.setSample (0, 1, 0.6f); + ir.setSample (0, 2, 0.3f); + ir.setSample (0, 3, 0.1f); + return ir; + }; + + const auto makeIrB = [] + { + juce::AudioBuffer ir (1, 3); + ir.setSample (0, 0, 0.8f); + ir.setSample (0, 1, -0.4f); + ir.setSample (0, 2, 0.2f); + return ir; + }; + + // Ground truth for IR_A(input) and IR_B(input): each rendered by a + // *separate* engine with the candidate IR loaded into slot A only and + // Blend left at 0%, so process() never enters the blendEngaged branch at + // all. This is deliberate and load-bearing - if these references were + // instead captured via the dual-slot engine at Blend = 0%/100% (as the + // other blend tests above do), a cascaded implementation (IR B applied + // to IR A's already-convolved output) would still be perfectly linear in + // Blend once engaged, so an intermediate blend would match a linear + // interpolation of *those* (equally cascaded) references - silently + // passing despite the defect. Only comparing against references that + // never touch the blend branch actually exercises whether IR B receives + // the original dry input or IR A's output. + const auto renderIrAlone = [&] (const std::function()>& makeIr) + { + CabConvolutionEngine engine; + engine.setMixProportion (1.0f); + engine.setLevelDb (0.0f); + engine.setBlendProportion (0.0f); + + const auto spec = makeTestSpec (2); + engine.prepare (spec); + + engine.setImpulseResponse (makeIr(), testSampleRate); + engine.prepare (spec); // guarantee the async load is drained/active + + juce::AudioBuffer buffer (2, testBlockSize); + TestHelpers::fillWithSine (buffer, testSampleRate, testFrequencyHz, 0.5f); + + juce::dsp::AudioBlock block (buffer); + engine.process (block); + + CHECK (TestHelpers::allSamplesFinite (buffer)); + return buffer; + }; + + const auto renderBlended = [&] (float blendProportion) + { + CabConvolutionEngine engine; + engine.setMixProportion (1.0f); + engine.setLevelDb (0.0f); + engine.setBlendProportion (blendProportion); + + const auto spec = makeTestSpec (2); + engine.prepare (spec); + + engine.setImpulseResponse (makeIrA(), testSampleRate); + engine.setImpulseResponseB (makeIrB(), testSampleRate); + engine.prepare (spec); // guarantee both async loads are drained/active + + juce::AudioBuffer buffer (2, testBlockSize); + TestHelpers::fillWithSine (buffer, testSampleRate, testFrequencyHz, 0.5f); + + juce::dsp::AudioBlock block (buffer); + engine.process (block); + + CHECK (TestHelpers::allSamplesFinite (buffer)); + return buffer; + }; + + const auto pureA = renderIrAlone (makeIrA); + const auto pureB = renderIrAlone (makeIrB); + const auto blendedQuarter = renderBlended (0.25f); + + // Sanity: the two references must actually differ, otherwise the check + // below would be vacuous. + REQUIRE (std::abs (TestHelpers::rms (pureA) - TestHelpers::rms (pureB)) > 1.0e-3); + + constexpr float parallelBlendTolerance = 1.0e-4f; + + for (int channel = 0; channel < blendedQuarter.getNumChannels(); ++channel) + { + const auto* aData = pureA.getReadPointer (channel); + const auto* bData = pureB.getReadPointer (channel); + const auto* mixedData = blendedQuarter.getReadPointer (channel); + + float maxResidual = 0.0f; + + for (int i = 0; i < testBlockSize; ++i) + { + // The correct, parallel crossfade: (1 - blend) * IR_A(input) + + // blend * IR_B(input). A cascaded implementation (IR B applied + // to IR A's output) would diverge from this by far more than + // floating-point noise, since both IRs here are genuinely + // different, non-identity filters. + const auto expected = 0.75f * aData[i] + 0.25f * bData[i]; + maxResidual = std::max (maxResidual, std::abs (mixedData[i] - expected)); + } + + CHECK (maxResidual < parallelBlendTolerance); + } +} + TEST_CASE ("Distance at 0% (default) is a bit-exact passthrough", "[dsp][engine][distance]") { CabConvolutionEngine engine; From 288eb7f6a8eefdca040e3f60cf49eca823bcfc8f Mon Sep 17 00:00:00 2001 From: Yves Vogl Date: Tue, 14 Jul 2026 17:31:05 +0200 Subject: [PATCH 6/6] docs(architecture): document the IR Blend parallel-input invariant Add an explicit note that both convolution branches process the same original dry input rather than being chained, and reference the regression test coverage - the docs already described this (correct) intended behaviour, but didn't call out the ordering that makes it true, which is exactly what the CabConvolutionEngine.cpp fix in the previous commit restores. --- docs/architecture.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/architecture.md b/docs/architecture.md index 3569725..94f6074 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -42,6 +42,8 @@ IR Blend uses an analogous, but *value-driven rather than range-extreme*, optimi `CabConvolutionEngine` owns two independent `juce::dsp::Convolution` instances ("IR A" and "IR B"), each with its own default (zero-latency) configuration and its own default delta IR. `process()` always runs IR A; it additionally runs IR B (into a pre-allocated scratch buffer sized in `prepare()`, never resized on the audio thread) and crossfades the two per-block whenever the smoothed IR Blend value is above a small epsilon - see [Filter bypass at the range extremes](#filter-bypass-at-the-range-extremes) above. If a host ever sends a block larger than `prepare()` promised, the scratch buffer's capacity is checked before writing into it; if the block wouldn't fit, Blend is simply treated as disengaged for that one block (falling back to IR A only) rather than risking an out-of-bounds write. +Both branches are independently convolved from the *same* original (dry) input, not chained: when Blend is engaged, `process()` copies the pre-convolution samples into the scratch buffer *before* `convolution.process()` (IR A) mutates `block` in place, so `convolutionB.process()` (IR B) always sees the untouched dry signal rather than IR A's already-convolved output. Getting this ordering wrong would silently turn the "B" side of the crossfade into `IR_B(IR_A(input))` - a cascaded double convolution - instead of the intended `IR_B(input)`; this is exactly the symmetric parallel A/B blend the diagram above depicts, and is covered by `tests/EngineTests.cpp`'s IR Blend tests using two distinct (non-identity) IRs in both slots. + Two independently-captured impulse responses rarely share the same "time zero" - different mic distances, different capture/measurement setups - so naively crossfading them would partially cancel a wide band of frequencies (comb filtering) wherever their transients don't line up. `CabConvolutionEngine::setImpulseResponseB()` addresses this with **inter-IR phase alignment**: before IR B is loaded into `convolutionB`, `IrAlignment::alignOnsetToReference()` (`src/dsp/IrAlignment.{h,cpp}`) time-shifts it so its detected onset lines up with IR A's most recently recorded onset (`lastIrAOnsetSample`/`lastIrASampleRate`, updated by `setImpulseResponse()`/`loadDefaultImpulseResponse()`). Onset detection is a simple, cheap relative-threshold crossing (the first sample, across all channels, whose magnitude reaches 20% of the buffer's own peak) - deliberate given cabinet IRs have one dominant direct-sound transient, rather than a full cross-correlation search. Alignment is computed in *time* (seconds), not raw sample index, so it stays correct even when IR A and IR B are captured at different sample rates. All of this happens off the audio thread (`setImpulseResponseB()`'s documented contract, same as `setImpulseResponse()`) - see `tests/IrAlignmentTests.cpp` for direct coverage of the alignment math, and `tests/EngineTests.cpp`'s IR Blend tests for the end-to-end engine behaviour. ## Distance emulation