#include "FxProcessor.h" #include #include namespace monostep { // Freeverb-style comb / allpass sizes, slightly offset per channel so the // 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] = { { 558, 594, 639, 678 }, { 563, 599, 644, 683 } }; static constexpr int allpassSizes[2][2] = { { 113, 278 }, { 118, 283 } }; 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", "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)]; } 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.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); } void FxProcessor::process (juce::AudioBuffer& 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); if (reverbActive) processReverb (dryScratch, 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 + params.delayFeedback * d.feedbackLp; out[i] = x + delayed * mix; if (++d.writeIndex >= size) d.writeIndex = 0; } } } void FxProcessor::processReverb (const juce::AudioBuffer& dryIn, juce::AudioBuffer& buffer, int numSamples) { if (params.reverbMix <= 0.0001f || params.reverbLevel <= 0.0001f) return; 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 = in[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 * wetScale * params.reverbLevel; 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] += wet * mix; } } } } // namespace monostep