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

View file

@ -84,6 +84,7 @@ target_sources(Monostep PRIVATE
Source/PluginProcessor.cpp Source/PluginProcessor.cpp
Source/PluginEditor.cpp Source/PluginEditor.cpp
Source/ui/SequencerMatrix.cpp Source/ui/SequencerMatrix.cpp
Source/dsp/FxProcessor.cpp
) )
target_include_directories(Monostep PRIVATE Source) target_include_directories(Monostep PRIVATE Source)
@ -108,6 +109,7 @@ if(MONOSTEP_BUILD_TESTS)
target_include_directories(MonostepTestHost PRIVATE Source) target_include_directories(MonostepTestHost PRIVATE Source)
target_sources(MonostepTestHost PRIVATE target_sources(MonostepTestHost PRIVATE
Source/PluginProcessor.cpp Source/PluginProcessor.cpp
Source/dsp/FxProcessor.cpp
tests/TestHost.cpp tests/TestHost.cpp
) )
target_compile_definitions(MonostepTestHost PRIVATE target_compile_definitions(MonostepTestHost PRIVATE

View file

@ -3,6 +3,7 @@
#include "PluginProcessor.h" #include "PluginProcessor.h"
#include "Theme.h" #include "Theme.h"
#include "BuildInfo.h" #include "BuildInfo.h"
#include "dsp/FxProcessor.h"
using namespace monostep; using namespace monostep;
@ -657,6 +658,11 @@ MonostepAudioProcessorEditor::MonostepAudioProcessorEditor (MonostepAudioProcess
fAmountKnob ("Amt"), fAmountKnob ("Amt"),
driveKnob ("Drive"), driveKnob ("Drive"),
ringKnob ("Ring"), ringKnob ("Ring"),
delayTimeKnob ("Time"),
delayStrengthKnob ("Strength"),
reverbRoomKnob ("Room"),
reverbStrengthKnob ("Strength"),
reverbDiffusionKnob ("Diff"),
accentKnob ("Accent"), accentKnob ("Accent"),
rateBar (processor.getAPVTS(), "seqRate", { "1/4", "1/8", "1/8T", "1/16", "1/32" }), rateBar (processor.getAPVTS(), "seqRate", { "1/4", "1/8", "1/8T", "1/16", "1/32" }),
octaveBar (processor.getAPVTS(), "seqOctave", { "-2", "-1", "0", "+1", "+2" }), octaveBar (processor.getAPVTS(), "seqOctave", { "-2", "-1", "0", "+1", "+2" }),
@ -759,8 +765,22 @@ MonostepAudioProcessorEditor::MonostepAudioProcessorEditor (MonostepAudioProcess
addKnob (fAmountKnob, "filterEnvAmt", [] (float v) { return (v >= 0.0f ? "+" : "") + juce::String (v, 2) + " oct"; }); addKnob (fAmountKnob, "filterEnvAmt", [] (float v) { return (v >= 0.0f ? "+" : "") + juce::String (v, 2) + " oct"; });
addKnob (driveKnob, "drive", [] (float v) { return juce::String (juce::roundToInt (v * 100.0f)) + "%"; }); addKnob (driveKnob, "drive", [] (float v) { return juce::String (juce::roundToInt (v * 100.0f)) + "%"; });
addKnob (ringKnob, "ringmod", [] (float v) { return juce::String (juce::roundToInt (v * 100.0f)) + "%"; }); addKnob (ringKnob, "ringmod", [] (float v) { return juce::String (juce::roundToInt (v * 100.0f)) + "%"; });
addKnob (delayTimeKnob, "delayTime", [this] (float v)
{
const bool sync = processorRef.getAPVTS().getRawParameterValue ("delaySync")->load() >= 0.5f;
return monostep::FxProcessor::delayTimeLabel (v, sync, 120.0);
});
addKnob (delayStrengthKnob, "delayStrength", [] (float v) { return juce::String (juce::roundToInt (v * 100.0f)) + "%"; });
addKnob (reverbRoomKnob, "reverbRoom", [] (float v) { return juce::String (juce::roundToInt (v * 100.0f)) + "%"; });
addKnob (reverbStrengthKnob, "reverbStrength", [] (float v) { return juce::String (juce::roundToInt (v * 100.0f)) + "%"; });
addKnob (reverbDiffusionKnob, "reverbDiffusion", [] (float v) { return juce::String (juce::roundToInt (v * 100.0f)) + "%"; });
addKnob (accentKnob, "accent", [] (float v) { return juce::String (juce::roundToInt (v * 100.0f)) + "%"; }); addKnob (accentKnob, "accent", [] (float v) { return juce::String (juce::roundToInt (v * 100.0f)) + "%"; });
delaySyncToggle.setButtonText ("Sync");
delaySyncToggle.setTooltip ("Snap the delay time to the host tempo (off: free milliseconds)");
delaySyncAttachment = std::make_unique<juce::AudioProcessorValueTreeState::ButtonAttachment> (apvts, "delaySync", delaySyncToggle);
content.addAndMakeVisible (delaySyncToggle);
fAmountKnob.setDragSensitivity (800); fAmountKnob.setDragSensitivity (800);
content.addAndMakeVisible (matrix); content.addAndMakeVisible (matrix);
@ -895,11 +915,20 @@ void MonostepAudioProcessorEditor::paintContent (juce::Graphics& g)
return r; return r;
}; };
// Draw a knob group: subtle gradient behind the title legend + a slight border // Draw a knob group frame: a slight border around the knobs themselves.
// around the knobs themselves. leftPad extends the frame to the left so it can // leftPad extends the frame to the left so it can enclose side labels.
// enclose side labels. auto drawFrame = [&] (const juce::Component& first, const juce::Component& last,
auto drawGroup = [&] (const juce::Component& first, const juce::Component& last, float pad, float leftPad = 0.0f)
const juce::String& title, float pad, float leftPad = 0.0f) {
auto box = groupBox (first, last, pad);
box.setLeft (box.getX() - leftPad);
g.setColour (colours::section.withAlpha (0.30f));
g.drawRoundedRectangle (box, 3.0f, 1.0f);
};
// A gradient legend pill spanning the given group, with the title centred.
auto drawPill = [&] (const juce::Component& first, const juce::Component& last,
const juce::String& title, float leftPad = 0.0f)
{ {
const int capX = first.getX() - (int) leftPad; const int capX = first.getX() - (int) leftPad;
const int capW = last.getRight() - capX; const int capW = last.getRight() - capX;
@ -912,19 +941,24 @@ void MonostepAudioProcessorEditor::paintContent (juce::Graphics& g)
g.setFont (font (9.0f).boldened()); g.setFont (font (9.0f).boldened());
g.setColour (colours::bg); g.setColour (colours::bg);
g.drawText (title, legend, juce::Justification::centred); g.drawText (title, legend, juce::Justification::centred);
auto box = groupBox (first, last, pad);
box.setLeft (box.getX() - leftPad);
g.setColour (colours::section.withAlpha (0.30f));
g.drawRoundedRectangle (box, 3.0f, 1.0f);
}; };
drawGroup (wave1Box, mixKnob, "OSC", 6.0f, (float) (oscLabelW - 21 + 9)); drawFrame (wave1Box, mixKnob, 6.0f, (float) (oscLabelW - 21 + 9));
drawGroup (glideKnob, accentKnob, "NOTE", 6.0f); drawPill (wave1Box, mixKnob, "OSC", (float) (oscLabelW - 21 + 9));
drawGroup (driveKnob, ringKnob, "DIST", 6.0f); drawFrame (glideKnob, accentKnob, 6.0f);
drawGroup (filterTypeBox, resKnob, "FILTER", 6.0f, 6.0f); drawPill (glideKnob, accentKnob, "NOTE");
drawGroup (attackKnob, releaseKnob, "AMP ENV", 6.0f);
drawGroup (fAttackKnob, fAmountKnob, "FILTER ENV", 6.0f); drawFrame (driveKnob, reverbDiffusionKnob, 6.0f);
drawPill (driveKnob, ringKnob, "FX");
drawPill (delayTimeKnob, delaySyncToggle, "DELAY");
drawPill (reverbRoomKnob, reverbDiffusionKnob, "REVERB");
drawFrame (filterTypeBox, resKnob, 6.0f, 6.0f);
drawPill (filterTypeBox, resKnob, "FILTER", 6.0f);
drawFrame (attackKnob, releaseKnob, 6.0f);
drawPill (attackKnob, releaseKnob, "AMP ENV");
drawFrame (fAttackKnob, fAmountKnob, 6.0f);
drawPill (fAttackKnob, fAmountKnob, "FILTER ENV");
const int footY = baseHeight - 20; const int footY = baseHeight - 20;
g.setColour (colours::grid); g.setColour (colours::grid);
@ -993,7 +1027,7 @@ void MonostepAudioProcessorEditor::resized()
const int oscColW = oscLabelW + oscDropW + oscGapW; // labels + dropdowns + gap to knobs const int oscColW = oscLabelW + oscDropW + oscGapW; // labels + dropdowns + gap to knobs
// Both rows are centered over the full bottom bar width. // Both rows are centered over the full bottom bar width.
const int blockW = oscColW + 6 * step + knobW + 48; // +48 = the two group gaps const int blockW = oscColW + 12 * step + 104; // osc col + FX (8 widgets + subgroup gaps)
const int oscX = pad + ((w - 2 * pad) - blockW) / 2 + oscColW; const int oscX = pad + ((w - 2 * pad) - blockW) / 2 + oscColW;
const int row1Y = bottomY + 24; const int row1Y = bottomY + 24;
const int row2Y = row1Y + knobH + 34; // row 1 + a caption band const int row2Y = row1Y + knobH + 34; // row 1 + a caption band
@ -1013,11 +1047,15 @@ void MonostepAudioProcessorEditor::resized()
} }
}; };
// Row 1: coarse / detune / mix / glide / accent / drive / ring. // Row 1: coarse / detune / mix / glide / accent / FX (drive / ring / delay / reverb).
// NOTE and DIST are pushed apart to clear the group frames.
placeRow (row1Y, oscX, { &coarseKnob, &detuneKnob, &mixKnob }); placeRow (row1Y, oscX, { &coarseKnob, &detuneKnob, &mixKnob });
placeRow (row1Y, oscX + 3 * step + 24, { &glideKnob, &accentKnob }); placeRow (row1Y, oscX + 3 * step + 24, { &glideKnob, &accentKnob });
placeRow (row1Y, oscX + 5 * step + 48, { &driveKnob, &ringKnob });
const int fxX = oscX + 5 * step + 48;
placeRow (row1Y, fxX, { &driveKnob, &ringKnob });
placeRow (row1Y, fxX + 2 * step + 24, { &delayTimeKnob, &delayStrengthKnob });
placeRow (row1Y, fxX + 5 * step + 48, { &reverbRoomKnob, &reverbStrengthKnob, &reverbDiffusionKnob });
delaySyncToggle.setBounds (fxX + 4 * step + 24, row1Y + (knobH - 24) / 2, knobW, 24);
// Row 2: Filter (dropdown + cutoff, res) [gap] Amp Envelope [gap] Filter Env. // Row 2: Filter (dropdown + cutoff, res) [gap] Amp Envelope [gap] Filter Env.
// The three sections are centered as one block with equal frame gaps. // The three sections are centered as one block with equal frame gaps.

View file

@ -194,7 +194,7 @@ private:
ContentComponent content; ContentComponent content;
static constexpr int baseWidth = 1060; static constexpr int baseWidth = 1280;
static constexpr int baseHeight = 780; static constexpr int baseHeight = 780;
float zoomFactor = 1.0f; float zoomFactor = 1.0f;
@ -229,6 +229,13 @@ private:
Knob fAmountKnob; Knob fAmountKnob;
Knob driveKnob; Knob driveKnob;
Knob ringKnob; Knob ringKnob;
Knob delayTimeKnob;
Knob delayStrengthKnob;
Knob reverbRoomKnob;
Knob reverbStrengthKnob;
Knob reverbDiffusionKnob;
juce::ToggleButton delaySyncToggle;
std::unique_ptr<juce::AudioProcessorValueTreeState::ButtonAttachment> delaySyncAttachment;
Knob accentKnob; Knob accentKnob;
ChoiceBar rateBar; ChoiceBar rateBar;

View file

@ -13,7 +13,9 @@ static const char* paramId (int index)
"seqLen", "seqRate", "seqRoot", "seqOctave", "seqSwing", "seqGateLen", "seqLen", "seqRate", "seqRoot", "seqOctave", "seqSwing", "seqGateLen",
"drive", "ringmod", "accent", "fine1", "drive", "ringmod", "accent", "fine1",
"filterAttack", "filterDecay", "filterSustain", "filterRelease", "filterEnvAmt", "filterAttack", "filterDecay", "filterSustain", "filterRelease", "filterEnvAmt",
"seqRetrig" "seqRetrig",
"delayTime", "delayStrength", "delaySync",
"reverbRoom", "reverbStrength", "reverbDiffusion"
}; };
return ids[index]; return ids[index];
} }
@ -26,6 +28,8 @@ enum ParamIndex
pDrive, pRingMod, pAccent, pFine1, pDrive, pRingMod, pAccent, pFine1,
pFilterAttack, pFilterDecay, pFilterSustain, pFilterRelease, pFilterEnvAmt, pFilterAttack, pFilterDecay, pFilterSustain, pFilterRelease, pFilterEnvAmt,
pSeqRetrig, pSeqRetrig,
pDelayTime, pDelayStrength, pDelaySync,
pReverbRoom, pReverbStrength, pReverbDiffusion,
numParams numParams
}; };
@ -65,6 +69,12 @@ MonostepAudioProcessor::MonostepAudioProcessor()
ringModParam = apvts.getRawParameterValue (paramId (pRingMod)); ringModParam = apvts.getRawParameterValue (paramId (pRingMod));
accentParam = apvts.getRawParameterValue (paramId (pAccent)); accentParam = apvts.getRawParameterValue (paramId (pAccent));
seqRetrigParam = apvts.getRawParameterValue (paramId (pSeqRetrig)); seqRetrigParam = apvts.getRawParameterValue (paramId (pSeqRetrig));
delayTimeParam = apvts.getRawParameterValue (paramId (pDelayTime));
delayStrengthParam = apvts.getRawParameterValue (paramId (pDelayStrength));
delaySyncParam = apvts.getRawParameterValue (paramId (pDelaySync));
reverbRoomParam = apvts.getRawParameterValue (paramId (pReverbRoom));
reverbStrengthParam = apvts.getRawParameterValue (paramId (pReverbStrength));
reverbDiffusionParam = apvts.getRawParameterValue (paramId (pReverbDiffusion));
setDefaultPattern(); setDefaultPattern();
} }
@ -172,6 +182,24 @@ juce::AudioProcessorValueTreeState::ParameterLayout MonostepAudioProcessor::crea
layout.add (std::make_unique<juce::AudioParameterFloat> (paramId (pAccent), layout.add (std::make_unique<juce::AudioParameterFloat> (paramId (pAccent),
"Accent", juce::NormalisableRange<float> (0.0f, 1.0f), 0.7f)); "Accent", juce::NormalisableRange<float> (0.0f, 1.0f), 0.7f));
layout.add (std::make_unique<juce::AudioParameterFloat> (paramId (pDelayTime),
"Delay Time", juce::NormalisableRange<float> (0.0f, 1.0f), 0.35f));
layout.add (std::make_unique<juce::AudioParameterFloat> (paramId (pDelayStrength),
"Delay Strength", juce::NormalisableRange<float> (0.0f, 1.0f), 0.0f));
layout.add (std::make_unique<juce::AudioParameterBool> (paramId (pDelaySync),
"Delay Sync", true));
layout.add (std::make_unique<juce::AudioParameterFloat> (paramId (pReverbRoom),
"Reverb Roomsize", juce::NormalisableRange<float> (0.0f, 1.0f), 0.5f));
layout.add (std::make_unique<juce::AudioParameterFloat> (paramId (pReverbStrength),
"Reverb Strength", juce::NormalisableRange<float> (0.0f, 1.0f), 0.0f));
layout.add (std::make_unique<juce::AudioParameterFloat> (paramId (pReverbDiffusion),
"Reverb Diffusion", juce::NormalisableRange<float> (0.0f, 1.0f), 0.6f));
return layout; return layout;
} }
@ -391,6 +419,12 @@ void MonostepAudioProcessor::randomizeFx()
{ {
applyParamValue ("drive", random.nextFloat()); applyParamValue ("drive", random.nextFloat());
applyParamValue ("ringmod", random.nextFloat()); applyParamValue ("ringmod", random.nextFloat());
applyParamValue ("delayTime", random.nextFloat());
applyParamValue ("delayStrength", random.nextFloat() * 0.8f);
applyParamValue ("delaySync", random.nextFloat() < 0.8f ? 1.0f : 0.0f);
applyParamValue ("reverbRoom", 0.2f + random.nextFloat() * 0.7f);
applyParamValue ("reverbStrength", random.nextFloat() * 0.7f);
applyParamValue ("reverbDiffusion", random.nextFloat());
} }
void MonostepAudioProcessor::randomizeParams() void MonostepAudioProcessor::randomizeParams()
@ -407,6 +441,7 @@ void MonostepAudioProcessor::prepareToPlay (double sr, int samplesPerBlock)
sampleRate = sr; sampleRate = sr;
voice.prepare (sr); voice.prepare (sr);
scratch.setSize (1, samplesPerBlock); scratch.setSize (1, samplesPerBlock);
fx.prepare (sr, juce::jmax (1, getTotalNumOutputChannels()));
lastStep = -1; lastStep = -1;
lastGateApplied = false; lastGateApplied = false;
lastFreqApplied = -1.0f; lastFreqApplied = -1.0f;
@ -418,6 +453,7 @@ void MonostepAudioProcessor::prepareToPlay (double sr, int samplesPerBlock)
void MonostepAudioProcessor::releaseResources() void MonostepAudioProcessor::releaseResources()
{ {
voice.reset(); voice.reset();
fx.reset();
} }
void MonostepAudioProcessor::updateVoiceParams() void MonostepAudioProcessor::updateVoiceParams()
@ -676,6 +712,17 @@ void MonostepAudioProcessor::processBlock (juce::AudioBuffer<float>& buffer, juc
for (int ch = 0; ch < numChannels; ++ch) for (int ch = 0; ch < numChannels; ++ch)
buffer.addFrom (ch, 0, scratch, 0, 0, numSamples); buffer.addFrom (ch, 0, scratch, 0, 0, numSamples);
monostep::FxProcessor::Params fp;
fp.delayTime = delayTimeParam->load();
fp.delayMix = delayStrengthParam->load();
fp.delaySync = delaySyncParam->load() >= 0.5f;
fp.reverbRoom = reverbRoomParam->load();
fp.reverbMix = reverbStrengthParam->load();
fp.reverbDiff = reverbDiffusionParam->load();
fp.bpm = bpm;
fx.setParams (fp);
fx.process (buffer, numSamples);
} }
void MonostepAudioProcessor::setStepGate (int idx, bool gate) void MonostepAudioProcessor::setStepGate (int idx, bool gate)

View file

@ -3,6 +3,7 @@
#include <JuceHeader.h> #include <JuceHeader.h>
#include "dsp/SynthVoice.h" #include "dsp/SynthVoice.h"
#include "dsp/StepSequencer.h" #include "dsp/StepSequencer.h"
#include "dsp/FxProcessor.h"
class MonostepAudioProcessor final : public juce::AudioProcessor class MonostepAudioProcessor final : public juce::AudioProcessor
{ {
@ -20,7 +21,7 @@ public:
const juce::String getName() const override { return "Monostep"; } const juce::String getName() const override { return "Monostep"; }
bool acceptsMidi() const override { return true; } bool acceptsMidi() const override { return true; }
bool producesMidi() const override { return false; } bool producesMidi() const override { return false; }
double getTailLengthSeconds() const override { return 0.0; } double getTailLengthSeconds() const override { return 4.0; }
int getNumPrograms() override { return 1; } int getNumPrograms() override { return 1; }
int getCurrentProgram() override { return 0; } int getCurrentProgram() override { return 0; }
@ -106,9 +107,16 @@ private:
std::atomic<float>* ringModParam = nullptr; std::atomic<float>* ringModParam = nullptr;
std::atomic<float>* accentParam = nullptr; std::atomic<float>* accentParam = nullptr;
std::atomic<float>* seqRetrigParam = nullptr; std::atomic<float>* seqRetrigParam = nullptr;
std::atomic<float>* delayTimeParam = nullptr;
std::atomic<float>* delayStrengthParam = nullptr;
std::atomic<float>* delaySyncParam = nullptr;
std::atomic<float>* reverbRoomParam = nullptr;
std::atomic<float>* reverbStrengthParam = nullptr;
std::atomic<float>* reverbDiffusionParam = nullptr;
monostep::StepSequencer sequencer; monostep::StepSequencer sequencer;
monostep::SynthVoice voice; monostep::SynthVoice voice;
monostep::FxProcessor fx;
juce::AudioBuffer<float> scratch; juce::AudioBuffer<float> scratch;
juce::Random random; juce::Random random;

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

View file

@ -299,6 +299,84 @@ static int testProcessorRendering()
std::cout << "Master=0 silences output OK\n"; std::cout << "Master=0 silences output OK\n";
// delay + reverb FX: after the note releases, the FX tail must ring out
// (delay repeats / reverb decay) instead of going silent, with no NaN/blow-up.
*processor.getAPVTS().getRawParameterValue ("master") = 1.0f;
*processor.getAPVTS().getRawParameterValue ("release") = 0.01f;
*processor.getAPVTS().getRawParameterValue ("delayStrength") = 0.0f;
*processor.getAPVTS().getRawParameterValue ("reverbStrength") = 0.0f;
const int releaseBlock = numBlocks / 2;
const auto renderFxTail = [&] () -> double
{
processor.reset();
processor.prepareToPlay (44100.0, 512);
juce::AudioBuffer<float> fxBlock (2, blockSize);
juce::MidiBuffer fxMidi;
double tailSum = 0.0;
int tailSamples = 0;
bool finite = true;
for (int b = 0; b < numBlocks; ++b)
{
fxBlock.clear();
fxMidi.clear();
if (b == 0)
fxMidi.addEvent (juce::MidiMessage::noteOn (1, 60, 0.9f), 0);
else if (b == releaseBlock)
fxMidi.addEvent (juce::MidiMessage::noteOff (1, 60), 0);
processor.processBlock (fxBlock, fxMidi);
if (b > releaseBlock)
for (int c = 0; c < 2; ++c)
for (int i = 0; i < blockSize; ++i)
{
const float s = fxBlock.getSample (c, i);
if (! std::isfinite (s))
finite = false;
tailSum += (double) s * s;
++tailSamples;
}
}
if (! finite)
return -1.0;
return std::sqrt (tailSum / (double) std::max (1, tailSamples));
};
const double dryTail = renderFxTail();
if (dryTail > 1e-3)
{
std::cout << "FAILED: dry tail after release should be silent, got " << dryTail << "\n";
return 1;
}
*processor.getAPVTS().getRawParameterValue ("delayStrength") = 0.8f;
*processor.getAPVTS().getRawParameterValue ("delayTime") = 0.4f;
*processor.getAPVTS().getRawParameterValue ("delaySync") = 1.0f;
*processor.getAPVTS().getRawParameterValue ("reverbStrength") = 0.8f;
*processor.getAPVTS().getRawParameterValue ("reverbRoom") = 0.9f;
*processor.getAPVTS().getRawParameterValue ("reverbDiffusion") = 1.0f;
const double wetTail = renderFxTail();
if (wetTail < 0.0)
return 1;
if (wetTail < 1e-3)
{
std::cout << "FAILED: delay/reverb tail is inaudible (wet=" << wetTail << ")\n";
return 1;
}
std::cout << "Delay + reverb FX OK (dryTail=" << dryTail << " wetTail=" << wetTail << ")\n";
return 0; return 0;
} }