2026-08-06 16:35:34 +02:00
|
|
|
#include "FxProcessor.h"
|
|
|
|
|
|
|
|
|
|
#include <algorithm>
|
|
|
|
|
#include <cmath>
|
|
|
|
|
|
|
|
|
|
namespace monostep
|
|
|
|
|
{
|
|
|
|
|
|
|
|
|
|
// Freeverb-style comb / allpass sizes, slightly offset per channel so the
|
2026-08-06 18:38:32 +02:00
|
|
|
// 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.
|
2026-08-06 16:35:34 +02:00
|
|
|
static constexpr int combSizes[2][4] =
|
|
|
|
|
{
|
2026-08-06 18:38:32 +02:00
|
|
|
{ 558, 594, 639, 678 },
|
|
|
|
|
{ 563, 599, 644, 683 }
|
2026-08-06 16:35:34 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
static constexpr int allpassSizes[2][2] =
|
|
|
|
|
{
|
2026-08-06 18:38:32 +02:00
|
|
|
{ 113, 278 },
|
|
|
|
|
{ 118, 283 }
|
2026-08-06 16:35:34 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
{
|
2026-08-06 18:38:32 +02:00
|
|
|
static const char* names[] = { "1/16", "1/8", "3/16", "1/4", "3/8", "1/2", "3/4", "1 bar" };
|
2026-08-06 16:35:34 +02:00
|
|
|
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)
|
|
|
|
|
{
|
2026-08-06 18:38:32 +02:00
|
|
|
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);
|
2026-08-06 16:35:34 +02:00
|
|
|
|
|
|
|
|
currentDelaySeconds = delayTimeSeconds (params.delayTime, params.delaySync, params.bpm);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void FxProcessor::process (juce::AudioBuffer<float>& buffer, int numSamples)
|
|
|
|
|
{
|
|
|
|
|
if (numSamples <= 0 || buffer.getNumChannels() == 0)
|
|
|
|
|
return;
|
|
|
|
|
|
2026-08-06 18:38:32 +02:00
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 16:35:34 +02:00
|
|
|
processDelay (buffer, numSamples);
|
2026-08-06 18:38:32 +02:00
|
|
|
|
|
|
|
|
if (reverbActive)
|
|
|
|
|
processReverb (dryScratch, buffer, numSamples);
|
2026-08-06 16:35:34 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
2026-08-06 18:38:32 +02:00
|
|
|
d.memory[d.writeIndex] = x + params.delayFeedback * d.feedbackLp;
|
2026-08-06 16:35:34 +02:00
|
|
|
|
|
|
|
|
out[i] = x + delayed * mix;
|
|
|
|
|
|
|
|
|
|
if (++d.writeIndex >= size)
|
|
|
|
|
d.writeIndex = 0;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 18:38:32 +02:00
|
|
|
void FxProcessor::processReverb (const juce::AudioBuffer<float>& dryIn,
|
|
|
|
|
juce::AudioBuffer<float>& buffer, int numSamples)
|
2026-08-06 16:35:34 +02:00
|
|
|
{
|
2026-08-06 18:38:32 +02:00
|
|
|
if (params.reverbMix <= 0.0001f || params.reverbLevel <= 0.0001f)
|
2026-08-06 16:35:34 +02:00
|
|
|
return;
|
|
|
|
|
|
2026-08-06 18:38:32 +02:00
|
|
|
const int n = juce::jmin (numChannels, juce::jmin (dryIn.getNumChannels(), buffer.getNumChannels()));
|
2026-08-06 16:35:34 +02:00
|
|
|
const float combGain = 0.70f + 0.28f * params.reverbRoom; // 0.70 .. 0.98
|
|
|
|
|
const float allpassGain = 0.75f * params.reverbDiff; // 0 .. 0.75
|
2026-08-06 18:38:32 +02:00
|
|
|
// 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);
|
2026-08-06 16:35:34 +02:00
|
|
|
const float mix = params.reverbMix;
|
|
|
|
|
|
|
|
|
|
for (int ch = 0; ch < n; ++ch)
|
|
|
|
|
{
|
|
|
|
|
auto& r = reverbs[ch];
|
2026-08-06 18:38:32 +02:00
|
|
|
const float* in = dryIn.getReadPointer (ch);
|
2026-08-06 16:35:34 +02:00
|
|
|
auto* out = buffer.getWritePointer (ch);
|
|
|
|
|
|
|
|
|
|
for (int i = 0; i < numSamples; ++i)
|
|
|
|
|
{
|
2026-08-06 18:38:32 +02:00
|
|
|
const float x = in[i];
|
2026-08-06 16:35:34 +02:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 18:38:32 +02:00
|
|
|
float wet = combSum * wetScale * params.reverbLevel;
|
2026-08-06 16:35:34 +02:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 18:38:32 +02:00
|
|
|
out[i] += wet * mix;
|
2026-08-06 16:35:34 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
} // namespace monostep
|