Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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).

Expand Down
2 changes: 1 addition & 1 deletion docs/manual.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
37 changes: 36 additions & 1 deletion src/PluginProcessor.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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();
Expand Down
9 changes: 9 additions & 0 deletions src/PluginProcessor.h
Original file line number Diff line number Diff line change
Expand Up @@ -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<float>&, juce::MidiBuffer&) override;
Expand Down
16 changes: 16 additions & 0 deletions src/dsp/IRLoader.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -58,6 +65,15 @@ namespace cryp

void IRLoader::loadImpulseResponse (juce::AudioBuffer<float> 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<double> (irBuffer.getNumSamples()) / irSampleRate
: 0.0;

convolution.loadImpulseResponse (std::move (irBuffer),
irSampleRate,
juce::dsp::Convolution::Stereo::yes,
Expand Down
17 changes: 17 additions & 0 deletions src/dsp/IRLoader.h
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,18 @@ namespace cryp
// resamples internally to match the session's sample rate.
void loadImpulseResponse (juce::AudioBuffer<float> 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.
Expand All @@ -73,5 +85,10 @@ namespace cryp
private:
juce::dsp::Convolution convolution;
juce::dsp::DryWetMixer<float> 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;
};
}
7 changes: 7 additions & 0 deletions src/dsp/Voicing.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
103 changes: 103 additions & 0 deletions tests/ChunkingTests.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
#include "PluginProcessor.h"
#include "params/ParameterIds.h"
#include "TestHelpers.h"

#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>

// 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<float> referenceOutput (2, oversizedBlockSamples);

for (int offset = 0; offset < oversizedBlockSamples; offset += preparedBlockSize)
{
const auto chunkLength = juce::jmin (preparedBlockSize, oversizedBlockSamples - offset);
juce::AudioBuffer<float> 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<float> 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));
}
}
}
54 changes: 54 additions & 0 deletions tests/ResetTests.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
#include "PluginProcessor.h"
#include "TestHelpers.h"

#include <catch2/catch_test_macros.hpp>

// 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<float> excite (2, blockSize);

for (int i = 0; i < 24; ++i)
{
TestHelpers::fillWithSine (excite, sampleRate, 80.0, 0.8f, static_cast<juce::int64> (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<float> silence (2, blockSize);
silence.clear();

processor.processBlock (silence, midi);

CHECK (TestHelpers::allSamplesFinite (silence));
CHECK (TestHelpers::rms (silence) < 1.0e-4);
}
77 changes: 77 additions & 0 deletions tests/TailLengthTests.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
#include "PluginProcessor.h"

#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>

#include <cmath>

// 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<float> makeImpulseResponseOfLength (int numIrSamples)
{
juce::AudioBuffer<float> 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<float> (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<double> (irSampleRate));

const auto expectedTailSeconds = static_cast<double> (numIrSamples) / static_cast<double> (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<double> (irSampleRate));

const auto expectedTailSeconds = static_cast<double> (numIrSamples) / static_cast<double> (irSampleRate);

CHECK (processor.getTailLengthSeconds() == Catch::Approx (expectedTailSeconds).margin (1.0e-6));
}
Loading