Add delay and reverb FX section with full DSP

Rename the DIST section to FX and expand it with tempo-synced delay
(Time/Strength/Sync) and Schroeder reverb (Roomsize/Strength/Diffusion).
New FxProcessor runs post-voice on the stereo bus; widen the editor to
1280px to fit the new section. Add a TestHost check that the FX tail
rings out after note release.
This commit is contained in:
Armin 2026-08-06 16:35:34 +02:00
commit 342d413563
8 changed files with 505 additions and 23 deletions

231
Source/dsp/FxProcessor.cpp Normal file
View file

@ -0,0 +1,231 @@
#include "FxProcessor.h"
#include <algorithm>
#include <cmath>
namespace monostep
{
// Freeverb-style comb / allpass sizes, slightly offset per channel so the
// left and right reverb tails decorrelate.
static constexpr int combSizes[2][4] =
{
{ 1116, 1188, 1277, 1356 },
{ 1126, 1198, 1287, 1366 }
};
static constexpr int allpassSizes[2][2] =
{
{ 225, 556 },
{ 235, 566 }
};
float FxProcessor::delayTimeSeconds (float knob, bool sync, double bpm)
{
if (sync)
{
static const float divisions[] = { 0.25f, 0.5f, 0.75f, 1.0f, 1.5f, 2.0f, 3.0f, 4.0f };
const int idx = juce::roundToInt (juce::jlimit (0.0f, 1.0f, knob) * 7.0f);
return divisions[juce::jlimit (0, 7, idx)] * 60.0f / (float) juce::jmax (1.0, bpm);
}
return 0.005f * std::pow (200.0f, juce::jlimit (0.0f, 1.0f, knob));
}
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" };
const int idx = juce::roundToInt (juce::jlimit (0.0f, 1.0f, knob) * 7.0f);
return names[juce::jlimit (0, 7, idx)];
}
return juce::String (juce::roundToInt (delayTimeSeconds (knob, false, bpm) * 1000.0f)) + " ms";
}
void FxProcessor::prepare (double sr, int channels)
{
sampleRate = juce::jmax (44100.0, sr);
numChannels = juce::jmax (1, channels);
maxDelaySamples = (int) (sampleRate * 2.0);
delays.clear();
delays.resize (numChannels);
for (auto& d : delays)
{
d.memory.assign ((size_t) maxDelaySamples + 8, 0.0f);
d.writeIndex = 0;
d.smoothedDelay = 0.0f;
d.feedbackLp = 0.0f;
}
const float srScale = (float) (sampleRate / 44100.0);
for (int ch = 0; ch < 2; ++ch)
{
for (int i = 0; i < 4; ++i)
combDelaySamples[ch][i] = juce::roundToInt (combSizes[ch][i] * srScale);
for (int i = 0; i < 2; ++i)
allpassDelaySamples[ch][i] = juce::roundToInt (allpassSizes[ch][i] * srScale);
}
reverbs.clear();
reverbs.resize (numChannels);
for (int ch = 0; ch < numChannels; ++ch)
{
const int rc = ch & 1;
auto& r = reverbs[ch];
for (int i = 0; i < 4; ++i)
r.combs[i].assign ((size_t) combDelaySamples[rc][i], 0.0f);
for (int i = 0; i < 2; ++i)
r.allpasses[i].assign ((size_t) allpassDelaySamples[rc][i], 0.0f);
}
}
void FxProcessor::reset()
{
for (auto& d : delays)
{
std::fill (d.memory.begin(), d.memory.end(), 0.0f);
d.writeIndex = 0;
d.smoothedDelay = 0.0f;
d.feedbackLp = 0.0f;
}
for (auto& r : reverbs)
{
for (int i = 0; i < 4; ++i)
{
std::fill (r.combs[i].begin(), r.combs[i].end(), 0.0f);
r.combIndex[i] = 0;
}
for (int i = 0; i < 2; ++i)
{
std::fill (r.allpasses[i].begin(), r.allpasses[i].end(), 0.0f);
r.allpassIndex[i] = 0;
}
}
}
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);
currentDelaySeconds = delayTimeSeconds (params.delayTime, params.delaySync, params.bpm);
}
void FxProcessor::process (juce::AudioBuffer<float>& buffer, int numSamples)
{
if (numSamples <= 0 || buffer.getNumChannels() == 0)
return;
processDelay (buffer, numSamples);
processReverb (buffer, numSamples);
}
void FxProcessor::processDelay (juce::AudioBuffer<float>& buffer, int numSamples)
{
if (params.delayMix <= 0.0001f)
return;
const int n = juce::jmin (numChannels, buffer.getNumChannels());
const float targetDelay = currentDelaySeconds * (float) sampleRate;
const float mix = params.delayMix;
for (int ch = 0; ch < n; ++ch)
{
auto& d = delays[ch];
auto* out = buffer.getWritePointer (ch);
const int size = (int) d.memory.size();
const int maxD = juce::jmax (1, size - 8);
for (int i = 0; i < numSamples; ++i)
{
const float x = out[i];
d.smoothedDelay += (targetDelay - d.smoothedDelay) * 0.0008f;
const float delay = juce::jlimit (8.0f, (float) maxD, d.smoothedDelay);
float readPos = (float) d.writeIndex - delay;
if (readPos < 0.0f)
readPos += (float) size;
const int idxA = (int) readPos;
const int idxB = (idxA + 1) % size;
const float frac = readPos - (float) idxA;
const float delayed = d.memory[idxA] + frac * (d.memory[idxB] - d.memory[idxA]);
// Feedback with a little high-frequency damping.
d.feedbackLp += 0.35f * (delayed - d.feedbackLp);
d.memory[d.writeIndex] = x + 0.42f * d.feedbackLp;
out[i] = x + delayed * mix;
if (++d.writeIndex >= size)
d.writeIndex = 0;
}
}
}
void FxProcessor::processReverb (juce::AudioBuffer<float>& buffer, int numSamples)
{
if (params.reverbMix <= 0.0001f)
return;
const int n = juce::jmin (numChannels, 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
const float mix = params.reverbMix;
for (int ch = 0; ch < n; ++ch)
{
auto& r = reverbs[ch];
auto* out = buffer.getWritePointer (ch);
for (int i = 0; i < numSamples; ++i)
{
const float x = out[i];
float combSum = 0.0f;
for (int c = 0; c < 4; ++c)
{
auto& buf = r.combs[c];
const int size = (int) buf.size();
int& pos = r.combIndex[c];
const float delayed = buf[pos];
buf[pos] = x + combGain * delayed;
combSum += delayed;
if (++pos >= size)
pos = 0;
}
float wet = combSum * 0.012f;
for (int a = 0; a < 2; ++a)
{
auto& buf = r.allpasses[a];
const int size = (int) buf.size();
int& pos = r.allpassIndex[a];
const float delayed = buf[pos];
const float outAp = -allpassGain * wet + delayed;
buf[pos] = wet + allpassGain * delayed;
wet = outAp;
if (++pos >= size)
pos = 0;
}
out[i] = x * (1.0f - mix) + wet * mix;
}
}
}
} // namespace monostep

71
Source/dsp/FxProcessor.h Normal file
View file

@ -0,0 +1,71 @@
#pragma once
#include <JuceHeader.h>
#include <vector>
namespace monostep
{
// Tempo-synced delay + Schroeder reverb, applied after the voice.
class FxProcessor
{
public:
FxProcessor() = default;
void prepare (double sampleRate, int numChannels);
void reset();
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;
};
void setParams (const Params& p);
void process (juce::AudioBuffer<float>& buffer, int numSamples);
// Shared knob-to-time mapping so the editor displays the same values the DSP uses.
static float delayTimeSeconds (float knobNormalized, bool sync, double bpm);
static juce::String delayTimeLabel (float knobNormalized, bool sync, double bpm);
private:
struct DelayChannel
{
std::vector<float> memory;
int writeIndex = 0;
float smoothedDelay = 0.0f;
float feedbackLp = 0.0f;
};
struct ReverbChannel
{
std::vector<float> combs[4];
std::vector<float> allpasses[2];
int combIndex[4] = {};
int allpassIndex[2] = {};
};
void processDelay (juce::AudioBuffer<float>& buffer, int numSamples);
void processReverb (juce::AudioBuffer<float>& buffer, int numSamples);
double sampleRate = 44100.0;
int numChannels = 2;
int maxDelaySamples = 0;
int combDelaySamples[2][4] = {};
int allpassDelaySamples[2][2] = {};
Params params;
float currentDelaySeconds = 0.0f;
std::vector<DelayChannel> delays;
std::vector<ReverbChannel> reverbs;
};
} // namespace monostep