Add delay/reverb dry-wet, granular randomize toggles, rework accent DSP

- Add delay and reverb Dry/Wet parameters and knobs, wired into presets
- Add Accent/Slide/Fine/Rate toggles to the Randomize panel with new
  per-aspect pattern randomizers
- Soften accent: modest volume lift plus small cutoff and filter-attack
  modulation instead of the heavy 5x gain boost
- Halve reverb comb/allpass delays so the wet speaks sooner
- Regenerate a fresh build stamp on every build
This commit is contained in:
Armin 2026-08-06 18:38:32 +02:00
commit 662a1f7383
11 changed files with 310 additions and 97 deletions

View file

@ -7,17 +7,19 @@ namespace monostep
{
// Freeverb-style comb / allpass sizes, slightly offset per channel so the
// left and right reverb tails decorrelate.
// left and right reverb tails decorrelate. Halved from the classic freeverb
// set so the early reflections land ~13 ms after the note instead of ~25 ms:
// a 100%-wet reverb keeps its tail but speaks much sooner.
static constexpr int combSizes[2][4] =
{
{ 1116, 1188, 1277, 1356 },
{ 1126, 1198, 1287, 1366 }
{ 558, 594, 639, 678 },
{ 563, 599, 644, 683 }
};
static constexpr int allpassSizes[2][2] =
{
{ 225, 556 },
{ 235, 566 }
{ 113, 278 },
{ 118, 283 }
};
float FxProcessor::delayTimeSeconds (float knob, bool sync, double bpm)
@ -36,7 +38,7 @@ juce::String FxProcessor::delayTimeLabel (float knob, bool sync, double bpm)
{
if (sync)
{
static const char* names[] = { "1/16", "1/8", "1/8D", "1/4", "1/4D", "1/2", "1/2D", "1W" };
static const char* names[] = { "1/16", "1/8", "3/16", "1/4", "3/8", "1/2", "3/4", "1 bar" };
const int idx = juce::roundToInt (juce::jlimit (0.0f, 1.0f, knob) * 7.0f);
return names[juce::jlimit (0, 7, idx)];
}
@ -112,13 +114,15 @@ void FxProcessor::reset()
void FxProcessor::setParams (const Params& p)
{
params.delayTime = juce::jlimit (0.0f, 1.0f, p.delayTime);
params.delayMix = juce::jlimit (0.0f, 1.0f, p.delayMix);
params.delaySync = p.delaySync;
params.reverbRoom = juce::jlimit (0.0f, 1.0f, p.reverbRoom);
params.reverbMix = juce::jlimit (0.0f, 1.0f, p.reverbMix);
params.reverbDiff = juce::jlimit (0.0f, 1.0f, p.reverbDiff);
params.bpm = juce::jmax (20.0, p.bpm);
params.delayTime = juce::jlimit (0.0f, 1.0f, p.delayTime);
params.delayFeedback = juce::jlimit (0.0f, 0.9f, p.delayFeedback);
params.delaySync = p.delaySync;
params.delayMix = juce::jlimit (0.0f, 1.0f, p.delayMix);
params.reverbRoom = juce::jlimit (0.0f, 1.0f, p.reverbRoom);
params.reverbLevel = juce::jlimit (0.0f, 1.0f, p.reverbLevel);
params.reverbMix = juce::jlimit (0.0f, 1.0f, p.reverbMix);
params.reverbDiff = juce::jlimit (0.0f, 1.0f, p.reverbDiff);
params.bpm = juce::jmax (20.0, p.bpm);
currentDelaySeconds = delayTimeSeconds (params.delayTime, params.delaySync, params.bpm);
}
@ -128,8 +132,23 @@ void FxProcessor::process (juce::AudioBuffer<float>& buffer, int numSamples)
if (numSamples <= 0 || buffer.getNumChannels() == 0)
return;
// The reverb must feed off the dry (pre-delay) signal, not the delay's
// output, so snapshot the incoming buffer before the delay runs.
const bool reverbActive = params.reverbMix > 0.0001f && params.reverbLevel > 0.0001f;
if (reverbActive)
{
if (dryScratch.getNumSamples() < numSamples)
dryScratch.setSize (buffer.getNumChannels(), numSamples, false, false, true);
const int n = juce::jmin (buffer.getNumChannels(), dryScratch.getNumChannels());
for (int c = 0; c < n; ++c)
dryScratch.copyFrom (c, 0, buffer, c, 0, numSamples);
}
processDelay (buffer, numSamples);
processReverb (buffer, numSamples);
if (reverbActive)
processReverb (dryScratch, buffer, numSamples);
}
void FxProcessor::processDelay (juce::AudioBuffer<float>& buffer, int numSamples)
@ -166,7 +185,7 @@ void FxProcessor::processDelay (juce::AudioBuffer<float>& buffer, int numSamples
// Feedback with a little high-frequency damping.
d.feedbackLp += 0.35f * (delayed - d.feedbackLp);
d.memory[d.writeIndex] = x + 0.42f * d.feedbackLp;
d.memory[d.writeIndex] = x + params.delayFeedback * d.feedbackLp;
out[i] = x + delayed * mix;
@ -176,24 +195,38 @@ void FxProcessor::processDelay (juce::AudioBuffer<float>& buffer, int numSamples
}
}
void FxProcessor::processReverb (juce::AudioBuffer<float>& buffer, int numSamples)
void FxProcessor::processReverb (const juce::AudioBuffer<float>& dryIn,
juce::AudioBuffer<float>& buffer, int numSamples)
{
if (params.reverbMix <= 0.0001f)
if (params.reverbMix <= 0.0001f || params.reverbLevel <= 0.0001f)
return;
const int n = juce::jmin (numChannels, buffer.getNumChannels());
const int n = juce::jmin (numChannels, juce::jmin (dryIn.getNumChannels(), buffer.getNumChannels()));
const float combGain = 0.70f + 0.28f * params.reverbRoom; // 0.70 .. 0.98
const float allpassGain = 0.75f * params.reverbDiff; // 0 .. 0.75
// Room normalisation: the comb network's DC gain is ~4 / (1 - combGain),
// so scaling by (1 - combGain) keeps the wet level bounded as the room
// (decay length) grows instead of blowing up near oscillation. The 2.5
// constant is calibrated (standalone replication of this network): at
// room 0.5 it puts the note-off ring ~ +6 dB above the dry and the tail
// ~ -6 dB one second in, so the reverb is clearly audible even at modest
// dry/wet settings. The wet is intentionally NOT clipped here: any
// soft-clip knee far below the signal level pins the wet at a constant
// flat value (the early tail stops audibly decaying, which reads as a
// "reverb slowly kicking in" plateau) and makes 100% wet sound quiet
// next to a hot dry signal.
const float wetScale = 2.5f * (1.0f - combGain);
const float mix = params.reverbMix;
for (int ch = 0; ch < n; ++ch)
{
auto& r = reverbs[ch];
const float* in = dryIn.getReadPointer (ch);
auto* out = buffer.getWritePointer (ch);
for (int i = 0; i < numSamples; ++i)
{
const float x = out[i];
const float x = in[i];
float combSum = 0.0f;
for (int c = 0; c < 4; ++c)
@ -208,7 +241,7 @@ void FxProcessor::processReverb (juce::AudioBuffer<float>& buffer, int numSample
pos = 0;
}
float wet = combSum * 0.012f;
float wet = combSum * wetScale * params.reverbLevel;
for (int a = 0; a < 2; ++a)
{
@ -223,7 +256,7 @@ void FxProcessor::processReverb (juce::AudioBuffer<float>& buffer, int numSample
pos = 0;
}
out[i] = x * (1.0f - mix) + wet * mix;
out[i] += wet * mix;
}
}
}

View file

@ -17,13 +17,15 @@ public:
struct Params
{
float delayTime = 0.35f; // normalized knob 0..1
float delayMix = 0.0f; // 0..1 dry/wet
bool delaySync = true; // tempo-synced vs. free milliseconds
float reverbRoom = 0.5f; // 0..1
float reverbMix = 0.0f; // 0..1 dry/wet
float reverbDiff = 0.6f; // 0..1 allpass diffusion
double bpm = 120.0;
float delayTime = 0.35f; // normalized knob 0..1
float delayFeedback = 0.45f; // 0..0.9 feedback (repeat density)
bool delaySync = true; // tempo-synced vs. free milliseconds
float delayMix = 0.0f; // 0..1 dry/wet
float reverbRoom = 0.5f; // 0..1
float reverbLevel = 1.0f; // 0..1 wet send gain
float reverbMix = 0.0f; // 0..1 dry/wet
float reverbDiff = 0.6f; // 0..1 allpass diffusion
double bpm = 120.0;
};
void setParams (const Params& p);
@ -52,7 +54,7 @@ private:
};
void processDelay (juce::AudioBuffer<float>& buffer, int numSamples);
void processReverb (juce::AudioBuffer<float>& buffer, int numSamples);
void processReverb (const juce::AudioBuffer<float>& dryIn, juce::AudioBuffer<float>& buffer, int numSamples);
double sampleRate = 44100.0;
int numChannels = 2;
@ -64,6 +66,10 @@ private:
Params params;
float currentDelaySeconds = 0.0f;
// Pre-delay snapshot: the reverb is fed the dry signal (before the delay),
// so delay and reverb run in parallel instead of in series.
juce::AudioBuffer<float> dryScratch;
std::vector<DelayChannel> delays;
std::vector<ReverbChannel> reverbs;
};

View file

@ -137,6 +137,10 @@ private:
fenvStage = Stage::attack;
}
// RC-style (exponential) filter envelope: the cutoff glides smoothly instead
// of stepping linearly, so fast filter sweeps sound analog rather than
// zippered. Each stage still reaches ~95% within the labelled time, so the
// knob values keep their musical meaning.
void updateFilterEnvelope()
{
const float sr = sampleRate;
@ -144,23 +148,37 @@ private:
switch (fenvStage)
{
case Stage::attack:
fenv += 1.0f / (params.fAttack * sr);
if (fenv >= 1.0f) { fenv = 1.0f; fenvStage = Stage::decay; }
{
// Accented notes attack the filter ever so slightly faster.
const float acc = accentSmooth - 1.0f;
const float t = juce::jmax (0.0005f, params.fAttack / (1.0f + acc * 0.5f));
const float a = 1.0f - std::exp (-3.0f / (t * sr));
fenv += (1.0f - fenv) * a;
if (fenv >= 0.999f) { fenv = 1.0f; fenvStage = Stage::decay; }
break;
}
case Stage::decay:
fenv -= (1.0f - params.fSustain) / (params.fDecay * sr);
if (fenv <= params.fSustain) { fenv = params.fSustain; fenvStage = Stage::sustain; }
{
const float t = juce::jmax (0.0005f, params.fDecay);
const float a = 1.0f - std::exp (-3.0f / (t * sr));
fenv += (params.fSustain - fenv) * a;
if (std::fabs (fenv - params.fSustain) < 0.001f) { fenv = params.fSustain; fenvStage = Stage::sustain; }
break;
}
case Stage::sustain:
fenv = params.fSustain;
break;
case Stage::release:
fenv -= 1.0f / (params.fRelease * sr);
if (fenv <= 0.0f) { fenv = 0.0f; fenvStage = Stage::idle; }
{
const float t = juce::jmax (0.0005f, params.fRelease);
const float a = 1.0f - std::exp (-3.0f / (t * sr));
fenv -= fenv * a;
if (fenv <= 0.001f) { fenv = 0.0f; fenvStage = Stage::idle; }
break;
}
case Stage::idle:
break;
@ -278,15 +296,18 @@ private:
float renderSample()
{
updateEnvelope();
updateFilterEnvelope();
updateGlide();
// Accent gains fast attack / slow release, so the boost fades out smoothly
// at the tail of an accented note instead of cutting off hard-edged.
accentSmooth += (accentLevel - accentSmooth)
* (accentLevel > accentSmooth ? accentAttackCoef : accentReleaseCoef);
// 0 for unaccented steps, rising with the accent knob on accented ones.
const float accBoost = accentSmooth - 1.0f;
updateEnvelope();
updateFilterEnvelope();
updateGlide();
const float incA = (currentFreq * params.detuneRatioA) / sampleRate;
const float incB = (currentFreq * params.detuneRatio) / sampleRate;
@ -302,14 +323,17 @@ private:
out = (1.0f - params.ringMod) * out + params.ringMod * (a * b);
const float preFilter = out;
// Accented steps open the filter a tiny bit, so they sparkle without
// relying on a big volume jump.
const float envCutoff = juce::jlimit (20.0f, 20000.0f,
params.cutoff * std::pow (2.0f, params.fAmount * fenv));
params.cutoff * std::pow (2.0f, params.fAmount * fenv + accBoost * 1.5f));
out = processFilter (out, envCutoff);
// Accented steps also get a touch of the unfiltered signal so they cut
// through the mix (presence), not just a volume bump.
if (accentSmooth > 1.0f)
out += (accentSmooth - 1.0f) * 0.2f * preFilter;
if (accBoost > 0.0f)
out += accBoost * 0.2f * preFilter;
if (params.drive > 0.0f)
{