From 342d41356364c9af0f623bbbb436ecdb46793ee0 Mon Sep 17 00:00:00 2001 From: Armin Date: Thu, 6 Aug 2026 16:35:34 +0200 Subject: [PATCH] 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. --- CMakeLists.txt | 2 + Source/PluginEditor.cpp | 78 +++++++++---- Source/PluginEditor.h | 9 +- Source/PluginProcessor.cpp | 49 +++++++- Source/PluginProcessor.h | 10 +- Source/dsp/FxProcessor.cpp | 231 +++++++++++++++++++++++++++++++++++++ Source/dsp/FxProcessor.h | 71 ++++++++++++ tests/TestHost.cpp | 78 +++++++++++++ 8 files changed, 505 insertions(+), 23 deletions(-) create mode 100644 Source/dsp/FxProcessor.cpp create mode 100644 Source/dsp/FxProcessor.h diff --git a/CMakeLists.txt b/CMakeLists.txt index eac6ef1..fd94bc7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -84,6 +84,7 @@ target_sources(Monostep PRIVATE Source/PluginProcessor.cpp Source/PluginEditor.cpp Source/ui/SequencerMatrix.cpp + Source/dsp/FxProcessor.cpp ) target_include_directories(Monostep PRIVATE Source) @@ -108,6 +109,7 @@ if(MONOSTEP_BUILD_TESTS) target_include_directories(MonostepTestHost PRIVATE Source) target_sources(MonostepTestHost PRIVATE Source/PluginProcessor.cpp + Source/dsp/FxProcessor.cpp tests/TestHost.cpp ) target_compile_definitions(MonostepTestHost PRIVATE diff --git a/Source/PluginEditor.cpp b/Source/PluginEditor.cpp index a24be49..b81c8f9 100644 --- a/Source/PluginEditor.cpp +++ b/Source/PluginEditor.cpp @@ -3,6 +3,7 @@ #include "PluginProcessor.h" #include "Theme.h" #include "BuildInfo.h" +#include "dsp/FxProcessor.h" using namespace monostep; @@ -657,6 +658,11 @@ MonostepAudioProcessorEditor::MonostepAudioProcessorEditor (MonostepAudioProcess fAmountKnob ("Amt"), driveKnob ("Drive"), ringKnob ("Ring"), + delayTimeKnob ("Time"), + delayStrengthKnob ("Strength"), + reverbRoomKnob ("Room"), + reverbStrengthKnob ("Strength"), + reverbDiffusionKnob ("Diff"), accentKnob ("Accent"), rateBar (processor.getAPVTS(), "seqRate", { "1/4", "1/8", "1/8T", "1/16", "1/32" }), 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 (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 (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)) + "%"; }); + delaySyncToggle.setButtonText ("Sync"); + delaySyncToggle.setTooltip ("Snap the delay time to the host tempo (off: free milliseconds)"); + delaySyncAttachment = std::make_unique (apvts, "delaySync", delaySyncToggle); + content.addAndMakeVisible (delaySyncToggle); + fAmountKnob.setDragSensitivity (800); content.addAndMakeVisible (matrix); @@ -895,11 +915,20 @@ void MonostepAudioProcessorEditor::paintContent (juce::Graphics& g) return r; }; - // Draw a knob group: subtle gradient behind the title legend + a slight border - // around the knobs themselves. leftPad extends the frame to the left so it can - // enclose side labels. - auto drawGroup = [&] (const juce::Component& first, const juce::Component& last, - const juce::String& title, float pad, float leftPad = 0.0f) + // Draw a knob group frame: a slight border around the knobs themselves. + // leftPad extends the frame to the left so it can enclose side labels. + auto drawFrame = [&] (const juce::Component& first, const juce::Component& last, + 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 capW = last.getRight() - capX; @@ -912,19 +941,24 @@ void MonostepAudioProcessorEditor::paintContent (juce::Graphics& g) g.setFont (font (9.0f).boldened()); g.setColour (colours::bg); 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)); - drawGroup (glideKnob, accentKnob, "NOTE", 6.0f); - drawGroup (driveKnob, ringKnob, "DIST", 6.0f); - drawGroup (filterTypeBox, resKnob, "FILTER", 6.0f, 6.0f); - drawGroup (attackKnob, releaseKnob, "AMP ENV", 6.0f); - drawGroup (fAttackKnob, fAmountKnob, "FILTER ENV", 6.0f); + drawFrame (wave1Box, mixKnob, 6.0f, (float) (oscLabelW - 21 + 9)); + drawPill (wave1Box, mixKnob, "OSC", (float) (oscLabelW - 21 + 9)); + drawFrame (glideKnob, accentKnob, 6.0f); + drawPill (glideKnob, accentKnob, "NOTE"); + + 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; g.setColour (colours::grid); @@ -993,7 +1027,7 @@ void MonostepAudioProcessorEditor::resized() const int oscColW = oscLabelW + oscDropW + oscGapW; // labels + dropdowns + gap to knobs // 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 row1Y = bottomY + 24; 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. - // NOTE and DIST are pushed apart to clear the group frames. + // Row 1: coarse / detune / mix / glide / accent / FX (drive / ring / delay / reverb). placeRow (row1Y, oscX, { &coarseKnob, &detuneKnob, &mixKnob }); 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. // The three sections are centered as one block with equal frame gaps. diff --git a/Source/PluginEditor.h b/Source/PluginEditor.h index 05af312..d6516b0 100644 --- a/Source/PluginEditor.h +++ b/Source/PluginEditor.h @@ -194,7 +194,7 @@ private: ContentComponent content; - static constexpr int baseWidth = 1060; + static constexpr int baseWidth = 1280; static constexpr int baseHeight = 780; float zoomFactor = 1.0f; @@ -229,6 +229,13 @@ private: Knob fAmountKnob; Knob driveKnob; Knob ringKnob; + Knob delayTimeKnob; + Knob delayStrengthKnob; + Knob reverbRoomKnob; + Knob reverbStrengthKnob; + Knob reverbDiffusionKnob; + juce::ToggleButton delaySyncToggle; + std::unique_ptr delaySyncAttachment; Knob accentKnob; ChoiceBar rateBar; diff --git a/Source/PluginProcessor.cpp b/Source/PluginProcessor.cpp index 1f57850..b2f4e5f 100644 --- a/Source/PluginProcessor.cpp +++ b/Source/PluginProcessor.cpp @@ -13,7 +13,9 @@ static const char* paramId (int index) "seqLen", "seqRate", "seqRoot", "seqOctave", "seqSwing", "seqGateLen", "drive", "ringmod", "accent", "fine1", "filterAttack", "filterDecay", "filterSustain", "filterRelease", "filterEnvAmt", - "seqRetrig" + "seqRetrig", + "delayTime", "delayStrength", "delaySync", + "reverbRoom", "reverbStrength", "reverbDiffusion" }; return ids[index]; } @@ -26,6 +28,8 @@ enum ParamIndex pDrive, pRingMod, pAccent, pFine1, pFilterAttack, pFilterDecay, pFilterSustain, pFilterRelease, pFilterEnvAmt, pSeqRetrig, + pDelayTime, pDelayStrength, pDelaySync, + pReverbRoom, pReverbStrength, pReverbDiffusion, numParams }; @@ -65,6 +69,12 @@ MonostepAudioProcessor::MonostepAudioProcessor() ringModParam = apvts.getRawParameterValue (paramId (pRingMod)); accentParam = apvts.getRawParameterValue (paramId (pAccent)); 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(); } @@ -172,6 +182,24 @@ juce::AudioProcessorValueTreeState::ParameterLayout MonostepAudioProcessor::crea layout.add (std::make_unique (paramId (pAccent), "Accent", juce::NormalisableRange (0.0f, 1.0f), 0.7f)); + layout.add (std::make_unique (paramId (pDelayTime), + "Delay Time", juce::NormalisableRange (0.0f, 1.0f), 0.35f)); + + layout.add (std::make_unique (paramId (pDelayStrength), + "Delay Strength", juce::NormalisableRange (0.0f, 1.0f), 0.0f)); + + layout.add (std::make_unique (paramId (pDelaySync), + "Delay Sync", true)); + + layout.add (std::make_unique (paramId (pReverbRoom), + "Reverb Roomsize", juce::NormalisableRange (0.0f, 1.0f), 0.5f)); + + layout.add (std::make_unique (paramId (pReverbStrength), + "Reverb Strength", juce::NormalisableRange (0.0f, 1.0f), 0.0f)); + + layout.add (std::make_unique (paramId (pReverbDiffusion), + "Reverb Diffusion", juce::NormalisableRange (0.0f, 1.0f), 0.6f)); + return layout; } @@ -391,6 +419,12 @@ void MonostepAudioProcessor::randomizeFx() { applyParamValue ("drive", 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() @@ -407,6 +441,7 @@ void MonostepAudioProcessor::prepareToPlay (double sr, int samplesPerBlock) sampleRate = sr; voice.prepare (sr); scratch.setSize (1, samplesPerBlock); + fx.prepare (sr, juce::jmax (1, getTotalNumOutputChannels())); lastStep = -1; lastGateApplied = false; lastFreqApplied = -1.0f; @@ -418,6 +453,7 @@ void MonostepAudioProcessor::prepareToPlay (double sr, int samplesPerBlock) void MonostepAudioProcessor::releaseResources() { voice.reset(); + fx.reset(); } void MonostepAudioProcessor::updateVoiceParams() @@ -676,6 +712,17 @@ void MonostepAudioProcessor::processBlock (juce::AudioBuffer& buffer, juc for (int ch = 0; ch < numChannels; ++ch) 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) diff --git a/Source/PluginProcessor.h b/Source/PluginProcessor.h index 9af6b01..7aeb056 100644 --- a/Source/PluginProcessor.h +++ b/Source/PluginProcessor.h @@ -3,6 +3,7 @@ #include #include "dsp/SynthVoice.h" #include "dsp/StepSequencer.h" +#include "dsp/FxProcessor.h" class MonostepAudioProcessor final : public juce::AudioProcessor { @@ -20,7 +21,7 @@ public: const juce::String getName() const override { return "Monostep"; } bool acceptsMidi() const override { return true; } 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 getCurrentProgram() override { return 0; } @@ -106,9 +107,16 @@ private: std::atomic* ringModParam = nullptr; std::atomic* accentParam = nullptr; std::atomic* seqRetrigParam = nullptr; + std::atomic* delayTimeParam = nullptr; + std::atomic* delayStrengthParam = nullptr; + std::atomic* delaySyncParam = nullptr; + std::atomic* reverbRoomParam = nullptr; + std::atomic* reverbStrengthParam = nullptr; + std::atomic* reverbDiffusionParam = nullptr; monostep::StepSequencer sequencer; monostep::SynthVoice voice; + monostep::FxProcessor fx; juce::AudioBuffer scratch; juce::Random random; diff --git a/Source/dsp/FxProcessor.cpp b/Source/dsp/FxProcessor.cpp new file mode 100644 index 0000000..4d8e288 --- /dev/null +++ b/Source/dsp/FxProcessor.cpp @@ -0,0 +1,231 @@ +#include "FxProcessor.h" + +#include +#include + +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& buffer, int numSamples) +{ + if (numSamples <= 0 || buffer.getNumChannels() == 0) + return; + + processDelay (buffer, numSamples); + processReverb (buffer, numSamples); +} + +void FxProcessor::processDelay (juce::AudioBuffer& 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& 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 diff --git a/Source/dsp/FxProcessor.h b/Source/dsp/FxProcessor.h new file mode 100644 index 0000000..e3905bd --- /dev/null +++ b/Source/dsp/FxProcessor.h @@ -0,0 +1,71 @@ +#pragma once + +#include +#include + +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& 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 memory; + int writeIndex = 0; + float smoothedDelay = 0.0f; + float feedbackLp = 0.0f; + }; + + struct ReverbChannel + { + std::vector combs[4]; + std::vector allpasses[2]; + int combIndex[4] = {}; + int allpassIndex[2] = {}; + }; + + void processDelay (juce::AudioBuffer& buffer, int numSamples); + void processReverb (juce::AudioBuffer& 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 delays; + std::vector reverbs; +}; + +} // namespace monostep diff --git a/tests/TestHost.cpp b/tests/TestHost.cpp index eda49e5..bdf8bb1 100644 --- a/tests/TestHost.cpp +++ b/tests/TestHost.cpp @@ -299,6 +299,84 @@ static int testProcessorRendering() 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 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; }