From 89be01bc59aaa2fc04d520c2a41e61b81e8e76e2 Mon Sep 17 00:00:00 2001 From: Yves Vogl Date: Thu, 16 Jul 2026 04:03:39 +0200 Subject: [PATCH 1/5] fix(dsp): override reset() to flush per-stage DSP state on transport stop/loop CryptaAudioProcessor never overrode juce::AudioProcessor::reset(), so a host transport-stop/loop/rewind (which calls reset(), not prepareToPlay()) never flushed the LR4 crossover's filter memory, gate/compressor envelopes, the high-band voicing's oversampling/mid/tone filter state, the low-band latency-compensation delay line, EQ biquad history, or the IR convolution engine - all of which already exposed their own real-time-safe reset() but were never wired into a processor-level override. Added tests/ResetTests.cpp ("reset(): clears DSP stage state so silence right after a loud signal is actually silent"), confirmed red before the fix (rms 0.46 with the base-class no-op reset() vs a margin of 1e-4), green after wiring every stage's reset() into the new override. Fixes #56. --- src/PluginProcessor.cpp | 29 ++++++++++++++++++++++ src/PluginProcessor.h | 9 +++++++ tests/ResetTests.cpp | 54 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+) create mode 100644 tests/ResetTests.cpp diff --git a/src/PluginProcessor.cpp b/src/PluginProcessor.cpp index fe1b3ce..b6ad1ee 100644 --- a/src/PluginProcessor.cpp +++ b/src/PluginProcessor.cpp @@ -257,6 +257,35 @@ void CryptaAudioProcessor::releaseResources() { } +//============================================================================== +void CryptaAudioProcessor::reset() +{ + // Issue #56: clears every per-stage DSP class's own state (each already + // exposes its own real-time-safe reset() for exactly this purpose - see + // src/dsp/*.h) so a host transport stop/loop/rewind doesn't leave a + // decaying tail (crossover filter memory, gate/compressor envelopes, + // the high-band voicing's oversampling FIR/mid/tone filter state, the + // low-band latency-compensation delay line, EQ biquad history, or the + // IR convolution engine) ringing into whatever plays next. No + // allocation: every stage's reset() only clears already-allocated + // storage. + inputGainProcessor.reset(); + outputGainProcessor.reset(); + + gate.reset(); + crossover.reset(); + lowCompressor.reset(); + highVoicing.reset(); + + lowGainProcessor.reset(); + highGainProcessor.reset(); + + eq.reset(); + irLoader.reset(); + + lowBandLatencyDelay.reset(); +} + bool CryptaAudioProcessor::isBusesLayoutSupported (const BusesLayout& layouts) const { const auto mono = juce::AudioChannelSet::mono(); diff --git a/src/PluginProcessor.h b/src/PluginProcessor.h index 97c48d7..b1e1f20 100644 --- a/src/PluginProcessor.h +++ b/src/PluginProcessor.h @@ -27,6 +27,15 @@ class CryptaAudioProcessor final : public juce::AudioProcessor void prepareToPlay (double sampleRate, int samplesPerBlock) override; void releaseResources() override; + // Issue #56: flushes every per-stage DSP class's own state (filter + // memory, envelope followers, oversampling FIR history, the low-band + // latency-compensation delay line, ...) without deallocating anything. + // Hosts call this on transport stop/loop/rewind - unlike + // prepareToPlay(), which only fires on sample-rate/block-size changes - + // so relying on prepareToPlay() alone leaves stale state ringing into + // whatever plays next. + void reset() override; + bool isBusesLayoutSupported (const BusesLayout& layouts) const override; void processBlock (juce::AudioBuffer&, juce::MidiBuffer&) override; diff --git a/tests/ResetTests.cpp b/tests/ResetTests.cpp new file mode 100644 index 0000000..89c1ee9 --- /dev/null +++ b/tests/ResetTests.cpp @@ -0,0 +1,54 @@ +#include "PluginProcessor.h" +#include "TestHelpers.h" + +#include + +// Issue #56: CryptaAudioProcessor did not override juce::AudioProcessor:: +// reset(), so a host transport-stop/loop/rewind (which calls reset(), not +// prepareToPlay()) never flushed the per-stage DSP state below. Every stage +// in src/dsp/ exposes its own reset() specifically for this purpose, but +// nothing wired them into the processor-level override before this fix - +// most audibly, the LR4 crossover's own filter memory (plus the low-band +// compensation delay line, gate/compressor envelopes, and the high-band +// voicing's oversampling/mid/tone filter state) would otherwise keep +// ringing a decaying tail into what should be dead silence after a loop +// point or stop-and-rewind. +TEST_CASE ("reset(): clears DSP stage state so silence right after a loud signal is actually silent", "[state][reset]") +{ + constexpr double sampleRate = 48000.0; + constexpr int blockSize = 512; + + CryptaAudioProcessor processor; + processor.prepareToPlay (sampleRate, blockSize); + + juce::MidiBuffer midi; + + // Build up substantial filter/envelope/oversampling state across every + // always-active stage (crossover, low-band compressor, high-band + // voicing, plus the low-band latency-compensation delay line) with a + // loud, low-frequency-rich signal run long enough to reach a settled + // state, not just a single transient block. + juce::AudioBuffer excite (2, blockSize); + + for (int i = 0; i < 24; ++i) + { + TestHelpers::fillWithSine (excite, sampleRate, 80.0, 0.8f, static_cast (i) * blockSize); + processor.processBlock (excite, midi); + } + + // Simulates a host transport stop/loop/rewind: JUCE calls reset() (not + // prepareToPlay()) to tell the plugin to flush any tails/state left + // running from the region that just played. + processor.reset(); + + // Silence in, right after reset(), must produce (near) silence out - any + // measurable output here can only be unflushed state ringing on its own, + // since there is no input to drive it. + juce::AudioBuffer silence (2, blockSize); + silence.clear(); + + processor.processBlock (silence, midi); + + CHECK (TestHelpers::allSamplesFinite (silence)); + CHECK (TestHelpers::rms (silence) < 1.0e-4); +} From 6150a916ec7e8bed29f709f66a38cf103ca4c12f Mon Sep 17 00:00:00 2001 From: Yves Vogl Date: Thu, 16 Jul 2026 04:03:50 +0200 Subject: [PATCH 2/5] fix(dsp): reset Voicing's midFilter state on voicing change Voicing::setVoicing() recomputed midFilter's coefficients on every voicing change but, unlike preHighPass's explicit Razor-switch handling, never reset its state - a stale biquad delay-line combined with a freshly-swapped coefficient set (e.g. Wool's -6dB scoop jumping to Razor's +5dB hump) rings a spurious transient that a plain "silence right after the switch" magnitude check can't isolate: the oversampling stage's own FIR flush tail and the boost/cut gain difference between voicings both legitimately leave residual regardless of the fix. Added tests/VoicingTests.cpp's on-resonance/off-resonance ratio test, which isolates midFilter's own stale-state contribution from those confounds by comparing the post-switch residual when the excite signal sits on Wool's own mid-filter frequency (500Hz, maximises midFilter's stored state) against off-resonance (20Hz, minimises it, leaving only the shared FIR/gain confound). Confirmed red before the fix (ratio 2.459 against a 2.35 threshold), green after wiring in midFilter.reset(). Fixes #57. --- src/dsp/Voicing.cpp | 7 ++++ tests/VoicingTests.cpp | 87 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/src/dsp/Voicing.cpp b/src/dsp/Voicing.cpp index 775f235..3f3519d 100644 --- a/src/dsp/Voicing.cpp +++ b/src/dsp/Voicing.cpp @@ -97,6 +97,13 @@ namespace cryp updateMidFilterCoefficients(); + // Issue #57: avoid carrying over stale filter state from the + // previous voicing into the freshly-swapped coefficients above - the + // same reasoning as the Razor-only preHighPass reset just below, but + // unconditional since midFilter is live for every voicing (not just + // Razor). + midFilter.reset(); + if (voicing == VoicingType::razor) { updatePreHighPassCoefficients(); diff --git a/tests/VoicingTests.cpp b/tests/VoicingTests.cpp index d798aba..97d3435 100644 --- a/tests/VoicingTests.cpp +++ b/tests/VoicingTests.cpp @@ -169,6 +169,93 @@ TEST_CASE ("Voicing: no NaN/Inf across a denormal-range sweep for every voicing" } } +namespace +{ + // Excites Wool's mid-scoop filter (500Hz/-6dB/Q0.9) at `exciteFreqHz`, + // switches to Razor (900Hz/+5dB/Q1.0 - the largest mid-filter + // coefficient jump available in the voicing set) and returns the RMS of + // a short window of the following silence, skipping past the + // oversampling stage's own (finite, unrelated) FIR flush tail. + // + // Processes in `numSamples`-sized chunks (the block size makeSpec() + // prepared) rather than one oversized block, since + // Oversampling::processSamplesUp() asserts on blocks larger than what + // initProcessing() was prepared for. + double exciteThenMeasurePostSwitchSilence (double exciteFreqHz) + { + cryp::Voicing voicing; + voicing.prepare (makeSpec (1), 1.0f); // fully wet, so midFilter fully reaches the output + voicing.setVoicing (cryp::VoicingType::wool); + voicing.setDrive (0.0f); // minimal waveshaping so the mid filter dominates the response + voicing.setTone (1.0f); // tone filter's pole sits near Nyquist, so its own state decays near-instantly + + constexpr int numExciteBlocks = 4; + + for (int i = 0; i < numExciteBlocks; ++i) + { + juce::AudioBuffer excite (1, numSamples); + TestHelpers::fillWithSine (excite, testSampleRate, exciteFreqHz, 0.9f, static_cast (i) * numSamples); + juce::dsp::AudioBlock exciteBlock (excite); + voicing.process (exciteBlock); + } + + // Switch to Razor: a substantial mid-filter coefficient jump under + // whatever mid-filter state the excite phase built up in Wool. + voicing.setVoicing (cryp::VoicingType::razor); + + juce::AudioBuffer silence (1, numSamples); + silence.clear(); + juce::dsp::AudioBlock silenceBlock (silence); + voicing.process (silenceBlock); + + const auto latency = juce::jmax (1, voicing.getLatencySamples()); + const auto skipSamples = juce::jlimit (0, numSamples / 2, 3 * latency); + const auto windowLength = juce::jmin (numSamples - skipSamples, 4 * latency); + + juce::AudioBuffer measured (1, windowLength); + measured.copyFrom (0, 0, silence, 0, skipSamples, windowLength); + + return TestHelpers::rms (measured); + } +} + +TEST_CASE ("Voicing: setVoicing() resets midFilter state instead of ringing a coefficient-jump transient into silence", "[voicing][dsp]") +{ + // Issue #57: setVoicing() unconditionally recomputes midFilter's + // coefficients on every voicing change but (unlike preHighPass's + // explicit Razor-switch handling) never reset its state - a stale + // biquad delay-line combined with freshly-swapped coefficients rings a + // spurious transient. + // + // A raw "silence right after the switch" magnitude check can't isolate + // this cleanly: the oversampling stage's own FIR flush tail from the + // excite-to-silence discontinuity, combined with Razor's mid filter + // being a +5dB *boost* (vs Wool's -6dB *cut*), both legitimately and + // unavoidably still leave some residual regardless of whether midFilter + // itself is reset - that residual would swamp a fixed threshold either + // way. Instead, this compares the residual left by exciting Wool's mid + // filter *on resonance* (500Hz, its own center frequency - maximises + // energy stored in midFilter's state specifically) against the residual + // left by exciting *off resonance* (20Hz, far below any voicing's mid + // filter - minimises midFilter's own contribution while leaving the + // shared oversampling-FIR-tail/gain-difference confound roughly the + // same, since that part depends mostly on excite amplitude, not + // frequency). The on/off-resonance *ratio* isolates midFilter's own + // stale-state contribution from those confounds: with midFilter + // properly reset on every switch (this fix), that ratio is small and + // stable; left unreset, the on-resonance case rings measurably harder + // than the off-resonance case, inflating the ratio. + const auto onResonanceRms = exciteThenMeasurePostSwitchSilence (500.0); // Wool's own mid-filter center frequency + const auto offResonanceRms = exciteThenMeasurePostSwitchSilence (20.0); // far below every voicing's mid filter + + REQUIRE (offResonanceRms > 0.0); + + const auto ratio = onResonanceRms / offResonanceRms; + + INFO ("onResonanceRms = " << onResonanceRms << ", offResonanceRms = " << offResonanceRms << ", ratio = " << ratio); + CHECK (ratio < 2.35); +} + TEST_CASE ("Voicing: switching voicing mid-stream never produces NaN/Inf", "[voicing][dsp][robustness]") { cryp::Voicing voicing; From 38e0447ddb9ca164e7f8836aa211dd3d369879f2 Mon Sep 17 00:00:00 2001 From: Yves Vogl Date: Thu, 16 Jul 2026 04:03:59 +0200 Subject: [PATCH 3/5] fix(dsp): report IRLoader's loaded IR length via getTailLengthSeconds() CryptaAudioProcessor::getTailLengthSeconds() unconditionally returned 0.0, correct only while IRLoader has its single-sample identity IR installed. loadImpulseResponse() is a live, callable seam into a real juce::dsp::Convolution with no length cap, so once a real IR is loaded the plugin's actual output tail grows to that IR's length while getTailLengthSeconds() kept reporting 0.0 - a host trusting that value for bounce/freeze/render-tail decisions could truncate the convolution tail. IRLoader now tracks the loaded IR's own duration (numSamples / irSampleRate at load time - invariant under Convolution's internal resampling to the session rate), exposed via IRLoader::getTailLengthSeconds() and wired into the processor's override. Added tests/TailLengthTests.cpp, confirmed red before the fix (both the "reflects loaded IR length" and "independent of session sample rate" cases failed with the hardcoded 0.0), green after wiring the tracked duration through. Fixes #58. --- src/PluginProcessor.cpp | 8 +++- src/dsp/IRLoader.cpp | 16 ++++++++ src/dsp/IRLoader.h | 17 +++++++++ tests/TailLengthTests.cpp | 77 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 tests/TailLengthTests.cpp diff --git a/src/PluginProcessor.cpp b/src/PluginProcessor.cpp index b6ad1ee..249f21d 100644 --- a/src/PluginProcessor.cpp +++ b/src/PluginProcessor.cpp @@ -135,7 +135,13 @@ bool CryptaAudioProcessor::isMidiEffect() const double CryptaAudioProcessor::getTailLengthSeconds() const { - return 0.0; + // Issue #58: report the IR loader's actual loaded-IR duration instead of + // a hardcoded 0 - 0.0 while only the safe-by-default identity IR is + // installed (matching the previous, correct-for-that-state behaviour), + // but the real convolution tail length once loadImpulseResponse() has + // installed a real cab IR, so hosts trusting this value for bounce/ + // freeze/render-tail decisions don't truncate it. + return irLoader.getTailLengthSeconds(); } int CryptaAudioProcessor::getNumPrograms() diff --git a/src/dsp/IRLoader.cpp b/src/dsp/IRLoader.cpp index f172672..2a12f15 100644 --- a/src/dsp/IRLoader.cpp +++ b/src/dsp/IRLoader.cpp @@ -35,6 +35,13 @@ namespace cryp juce::dsp::Convolution::Trim::no, juce::dsp::Convolution::Normalise::no); + // Issue #58: the single-sample identity IR has a negligible/zero + // tail by design (see the class-level comment above), so re-arm the + // reported tail length back to 0 whenever (re)prepared - matching + // the freshly-installed identity IR, not whatever was loaded before + // a sample-rate/block-size change. + loadedIrTailSeconds = 0.0; + convolution.prepare (spec); // Prime the mix *before* prepare() so the mixer's internal reset() @@ -58,6 +65,15 @@ namespace cryp void IRLoader::loadImpulseResponse (juce::AudioBuffer irBuffer, double irSampleRate) { + // Issue #58: captured before the buffer is moved into Convolution + // below. A duration in seconds is invariant under Convolution's + // internal resampling to the session rate, so this is the IR's real + // tail length regardless of what sample rate the session itself + // runs at. + loadedIrTailSeconds = irSampleRate > 0.0 + ? static_cast (irBuffer.getNumSamples()) / irSampleRate + : 0.0; + convolution.loadImpulseResponse (std::move (irBuffer), irSampleRate, juce::dsp::Convolution::Stereo::yes, diff --git a/src/dsp/IRLoader.h b/src/dsp/IRLoader.h index 3e2d72a..1e3f4b0 100644 --- a/src/dsp/IRLoader.h +++ b/src/dsp/IRLoader.h @@ -57,6 +57,18 @@ namespace cryp // resamples internally to match the session's sample rate. void loadImpulseResponse (juce::AudioBuffer irBuffer, double irSampleRate); + // Issue #58: the currently-loaded IR's own duration in seconds + // (numSamples / irSampleRate at the time it was loaded - a duration + // is invariant under Convolution's internal resampling to the + // session rate, so this doesn't need to track the session sample + // rate separately). 0.0 while only the safe-by-default single-sample + // identity IR is installed (i.e. before any real IR has been + // loaded). CryptaAudioProcessor::getTailLengthSeconds() reports this + // to the host so bounce/freeze/render-tail decisions account for the + // actual convolution tail once a real cab IR is loaded, rather than + // a hardcoded 0. + double getTailLengthSeconds() const noexcept { return loadedIrTailSeconds; } + // In-place: convolution + irMix dry/wet blend. Callers should skip // calling this entirely when irEnabled is off, for a guaranteed // bit-exact bypass rather than relying on mix==0. @@ -73,5 +85,10 @@ namespace cryp private: juce::dsp::Convolution convolution; juce::dsp::DryWetMixer mixer; + + // See getTailLengthSeconds() above. 0.0 until a real IR is loaded + // (the identity IR installed by prepare() has a negligible/zero + // tail by design). + double loadedIrTailSeconds = 0.0; }; } diff --git a/tests/TailLengthTests.cpp b/tests/TailLengthTests.cpp new file mode 100644 index 0000000..1fcef2a --- /dev/null +++ b/tests/TailLengthTests.cpp @@ -0,0 +1,77 @@ +#include "PluginProcessor.h" + +#include +#include + +#include + +// Issue #58: getTailLengthSeconds() unconditionally returned 0.0, correct +// only while IRLoader has its single-sample identity IR installed (the +// safe-by-default state before any cab IR is loaded). loadImpulseResponse() +// is already a live, callable seam into a real juce::dsp::Convolution with +// no length cap, so once a real IR is loaded the plugin's actual output tail +// grows to that IR's length while getTailLengthSeconds() kept reporting 0.0 +// - a host trusting that value for bounce/freeze/render-tail decisions could +// truncate the convolution tail. +namespace +{ + // A short synthetic "IR" of a known, precise duration, so the assertion + // below can check getTailLengthSeconds() against an exact expected value + // rather than just ">0". + juce::AudioBuffer makeImpulseResponseOfLength (int numIrSamples) + { + juce::AudioBuffer ir (2, numIrSamples); + + for (int channel = 0; channel < ir.getNumChannels(); ++channel) + { + auto* data = ir.getWritePointer (channel); + + for (int sample = 0; sample < numIrSamples; ++sample) + data[sample] = std::exp (-static_cast (sample) / 8.0f); + } + + return ir; + } +} + +TEST_CASE ("getTailLengthSeconds(): reports 0 with no IR loaded (safe-by-default identity IR)", "[tail][ir]") +{ + CryptaAudioProcessor processor; + processor.prepareToPlay (48000.0, 512); + + CHECK (processor.getTailLengthSeconds() == Catch::Approx (0.0).margin (1.0e-9)); +} + +TEST_CASE ("getTailLengthSeconds(): reflects the loaded IR's actual length, not a hardcoded 0", "[tail][ir]") +{ + CryptaAudioProcessor processor; + processor.prepareToPlay (48000.0, 512); + + constexpr int irSampleRate = 48000; + constexpr int numIrSamples = 4800; // exactly 0.1s at 48kHz + + processor.loadImpulseResponse (makeImpulseResponseOfLength (numIrSamples), static_cast (irSampleRate)); + + const auto expectedTailSeconds = static_cast (numIrSamples) / static_cast (irSampleRate); + + CHECK (processor.getTailLengthSeconds() > 0.0); + CHECK (processor.getTailLengthSeconds() == Catch::Approx (expectedTailSeconds).margin (1.0e-6)); +} + +TEST_CASE ("getTailLengthSeconds(): tracks the IR's own duration independent of the session sample rate", "[tail][ir]") +{ + CryptaAudioProcessor processor; + // Session runs at 96kHz; the IR itself was captured/generated at 44.1kHz + // - the reported tail must reflect the IR's real-world duration, not get + // conflated with the session's sample rate. + processor.prepareToPlay (96000.0, 512); + + constexpr int irSampleRate = 44100; + constexpr int numIrSamples = 2205; // exactly 0.05s at 44.1kHz + + processor.loadImpulseResponse (makeImpulseResponseOfLength (numIrSamples), static_cast (irSampleRate)); + + const auto expectedTailSeconds = static_cast (numIrSamples) / static_cast (irSampleRate); + + CHECK (processor.getTailLengthSeconds() == Catch::Approx (expectedTailSeconds).margin (1.0e-6)); +} From 6a3dce03d4c0b507789e42200ae165fcebee9645 Mon Sep 17 00:00:00 2001 From: Yves Vogl Date: Thu, 16 Jul 2026 04:04:05 +0200 Subject: [PATCH 4/5] test(processblock): add regression coverage for oversized-block chunking processBlock()'s chunking logic (splitting a host block into sub-blocks of at most prepareToPlay()'s samplesPerBlock, to avoid overrunning lowBandBuffer/highBandBuffer and Voicing's fixed-size Oversampling/ DryWetMixer buffers) was never exercised by any test: every existing test only ever calls processBlock() with a buffer sized at or below what prepareToPlay() promised, so the multi-iteration chunking branch never ran under CI. Added tests/ChunkingTests.cpp, comparing a single oversized (and non-multiple-of-block-size) processBlock() call against the same signal fed through in properly host-sized sub-blocks. This test passed immediately (no red state): the chunking loop in PluginProcessor.cpp was already correct, just untested - this closes that coverage gap rather than fixing a functional bug, and is a Release-safe path (no jassert-only branching in the code under test). Fixes #59. --- tests/ChunkingTests.cpp | 103 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 tests/ChunkingTests.cpp diff --git a/tests/ChunkingTests.cpp b/tests/ChunkingTests.cpp new file mode 100644 index 0000000..f9613a4 --- /dev/null +++ b/tests/ChunkingTests.cpp @@ -0,0 +1,103 @@ +#include "PluginProcessor.h" +#include "params/ParameterIds.h" +#include "TestHelpers.h" + +#include +#include + +// Issue #59: processBlock()'s chunking logic (PluginProcessor.cpp) splits a +// host block into sub-blocks of at most `preparedBlockSize` samples +// specifically to avoid overrunning lowBandBuffer/highBandBuffer and the +// fixed-size internal buffers of juce::dsp::Oversampling/DryWetMixer inside +// Voicing, all sized via prepare(spec.maximumBlockSize). Every other test in +// this suite only ever calls processBlock() with a buffer sized at or below +// what prepareToPlay() promised, so the multi-iteration chunking branch +// (executing more than once per processBlock() call) was never exercised by +// CI. This test deliberately hands processBlock() a single host block larger +// than promised - and not a whole multiple of preparedBlockSize, so the +// final short remainder chunk is exercised too - and checks that the result +// is sample-identical (within floating-point tolerance) to feeding the exact +// same signal through in properly host-sized sub-blocks, which is the +// documented/already-covered path. This is a Release-safe path (no +// jassert-only branching in the code under test), so the comparison is valid +// in both Debug and Release builds. +namespace +{ + constexpr double testSampleRate = 48000.0; + constexpr int preparedBlockSize = 256; + + // Not a whole multiple of preparedBlockSize, and spans more than two + // full chunks (256 + 256 + 256 + 37), so the chunking loop executes + // multiple times including one final short remainder chunk. + constexpr int oversizedBlockSamples = preparedBlockSize * 3 + 37; + + void enableEveryStage (CryptaAudioProcessor& processor) + { + auto setParam = [&] (const char* id, float value01) + { + auto* parameter = processor.apvts.getParameter (id); + REQUIRE (parameter != nullptr); + parameter->setValueNotifyingHost (value01); + }; + + // Full chain (gate, crossover, low/high bands, EQ, IR loader) all + // participating, not just the always-on stages. + setParam (ParamIDs::gateEnabled, 1.0f); + setParam (ParamIDs::eqEnabled, 1.0f); + setParam (ParamIDs::irEnabled, 1.0f); + } +} + +TEST_CASE ("processBlock(): a host block larger than prepareToPlay's samplesPerBlock chunks identically to feeding the same signal in properly-sized sub-blocks", "[processblock][chunking]") +{ + CryptaAudioProcessor chunkedProcessor; + chunkedProcessor.prepareToPlay (testSampleRate, preparedBlockSize); + enableEveryStage (chunkedProcessor); + + CryptaAudioProcessor referenceProcessor; + referenceProcessor.prepareToPlay (testSampleRate, preparedBlockSize); + enableEveryStage (referenceProcessor); + + juce::MidiBuffer midi; + + // Reference: the exact same continuous signal (phase-continuous across + // sub-block boundaries via the absolute sample offset), fed through in + // separate processBlock() calls each sized at or below + // preparedBlockSize - the host-behaviour-as-documented path every other + // test in this suite already covers. + juce::AudioBuffer referenceOutput (2, oversizedBlockSamples); + + for (int offset = 0; offset < oversizedBlockSamples; offset += preparedBlockSize) + { + const auto chunkLength = juce::jmin (preparedBlockSize, oversizedBlockSamples - offset); + juce::AudioBuffer chunk (2, chunkLength); + TestHelpers::fillWithSine (chunk, testSampleRate, 220.0, 0.6f, offset); + + referenceProcessor.processBlock (chunk, midi); + + for (int channel = 0; channel < 2; ++channel) + referenceOutput.copyFrom (channel, offset, chunk, channel, 0, chunkLength); + } + + // Under test: the exact same signal, handed to processBlock() as a + // single oversized call - only safe/correct if the internal chunking + // loop (processBlock()/processChunk() in PluginProcessor.cpp) does its + // job. + juce::AudioBuffer chunkedOutput (2, oversizedBlockSamples); + TestHelpers::fillWithSine (chunkedOutput, testSampleRate, 220.0, 0.6f); + + CHECK_NOTHROW (chunkedProcessor.processBlock (chunkedOutput, midi)); + CHECK (TestHelpers::allSamplesFinite (chunkedOutput)); + + for (int channel = 0; channel < 2; ++channel) + { + const auto* referenceData = referenceOutput.getReadPointer (channel); + const auto* chunkedData = chunkedOutput.getReadPointer (channel); + + for (int sample = 0; sample < oversizedBlockSamples; ++sample) + { + INFO ("channel = " << channel << ", sample = " << sample); + CHECK (chunkedData[sample] == Catch::Approx (referenceData[sample]).margin (1.0e-5f)); + } + } +} From 9b8ea2733522b61b26ef20cbab98397d244489c7 Mon Sep 17 00:00:00 2001 From: Yves Vogl Date: Thu, 16 Jul 2026 04:04:09 +0200 Subject: [PATCH 5/5] docs: reword suite framing from symphonic-metal to heavy-music CLAUDE.md and docs/manual.md still carried the pre-suite-bible "symphonic-metal" framing; reworded to "heavy music" (the suite bible's production/mix-context term) to match the rest of the documentation. --- CLAUDE.md | 2 +- docs/manual.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a5e53b6..e2e25be 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Crypta — parallel bass processor (bass) -Per-repo working memory for Claude Code sessions on this plugin. Part of the **Metal up your ass** symphonic-metal plugin suite (`github.com/metal-up-your-ass`). +Per-repo working memory for Claude Code sessions on this plugin. Part of the **Metal up your ass** heavy-music plugin suite (`github.com/metal-up-your-ass`). Formerly named **Twist Your Guts**; renamed to Crypta 2026-07-14 as part of the suite's move toward Basilica Audio naming (the crypt: the basilica's low-end foundation). New identity: `com.yvesvogl.crypta`, plugin code `Cryp` (old: `com.yvesvogl.twistyourguts`, `Tygt` — DAWs treat this as a new plugin). diff --git a/docs/manual.md b/docs/manual.md index 02304b8..be4cc00 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -8,7 +8,7 @@ Crypta is a Parallax-style **parallel bass processor** built for metal production. It splits your bass signal into a low band and a high band with a 4th-order Linkwitz-Riley ("LR4") crossover, keeps the low band tight with a parallel compressor, and runs the high band through a choice of three distortion voicings before summing everything back together through a 4-band EQ and a cabinet-simulation IR loader. -### Where it sits in a symphonic-metal chain +### Where it sits in a heavy-music chain Crypta is designed to be the **bass-specific voicing stage** in the "Metal up your ass" suite: