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..94f6074 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,36 @@ 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.
+
+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
+
+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 +62,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`).
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..82cc2a8 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,51 @@ 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.
+ //
+ // 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);
+
+ 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/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);
+}
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.
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..348a5fd 100644
--- a/tests/EngineTests.cpp
+++ b/tests/EngineTests.cpp
@@ -4,6 +4,7 @@
#include
#include
+#include
namespace
{
@@ -40,6 +41,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 +229,349 @@ 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 ("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;
+ 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]")