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: diff --git a/src/PluginProcessor.cpp b/src/PluginProcessor.cpp index fe1b3ce..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() @@ -257,6 +263,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/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/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/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)); + } + } +} 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); +} 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)); +} 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;