Chorus LFO Noise?

I’m trying my hand at writing some delay-based effects for the first time and am trying to create a chorus effect. I’ve been able to figure out how to write the ring buffer for the delay and how to use a sine wave as an LFO to modulate the read position of the ring buffer to create a vibrato. But I’m getting quite a bit of noise from the LFO and can’t quite figure out how to get rid of it.

An example of the noise using the Sine Wave Synth in the AudioPluginHost:
sine_noise.zip (381.0 KB)

I based the sine wave LFO on JUCE’s sine wave synth tutorial:

class SineGenerator
{
public:
    SineGenerator() {};
    ~SineGenerator() {};

    void initSineWave(const double sampleRate, const float frequency)
    {
        currentSampleRate = sampleRate;
        targetFrequency = currentFrequency = frequency;
        updateAngleDelta();
    }

    float renderSineWave()
    {
        float currentSample = 0.f;

        if (! juce::approximatelyEqual (targetFrequency, currentFrequency))
        {
            currentSample = std::sin ((float) currentAngle);
            currentFrequency += frequencyIncrement;
            updateAngleDelta();
            currentAngle += angleDelta;
        }
        else
        {
            currentSample = std::sin ((float) currentAngle);
            currentAngle += angleDelta;
        }

        return currentSample;
    }

    inline void resetCurrentFrequency() { currentFrequency = targetFrequency; }
    inline void setTargetFrequency(const double frequency) { targetFrequency = frequency; }
    inline void setIncrement(const int numSamples) { frequencyIncrement = (targetFrequency - currentFrequency) / (double) numSamples; }

private:
    inline void updateAngleDelta() { angleDelta = (currentFrequency / currentSampleRate) * juce::MathConstants<double>::twoPi; }

    double currentSampleRate = 0.0, currentAngle = 0.0, angleDelta = 0.0, frequencyIncrement = 0.0;
    double currentFrequency = 500.0, targetFrequency = 500.0;

    JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (SineGenerator)
};

And for the PluginProcessor.h:

class ChorusAudioProcessor  : public juce::AudioProcessor
{
public:
    /* standard PluginProcessor code */
    ...
private:

    SineGenerator lfoSine;

    std::unique_ptr<juce::AudioBuffer<float>> delayBuffer;
    
    float *delayInL, *delayInR;
    const float *delayOutL, *delayOutR;

    int delayWritePos = 0;

    float mix = 0.5f, lfoVibe = 1.f, delayL = 0.f, delayR = 0.f;
    double devSampleRate = 48000.0;

    //==============================================================================
    JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (Juce_delayAudioProcessor)
};

PluginProcessor.cpp initialization…

ChorusAudioProcessor::ChorusAudioProcessor()
...
{
    delayBuffer = std::make_unique<juce::AudioBuffer<float>>();
}
...
void ChorusAudioProcessor::prepareToPlay (double sampleRate, int /* samplesPerBlock */)
{
    const int delayNumChannels = getTotalNumInputChannels();

    devSampleRate = sampleRate;

    lfoSine.initSineWave(devSampleRate,  2.0);
    delayBuffer->setSize(delayNumChannels, (int) devSampleRate);

    delayOutL = delayBuffer->getReadPointer(0);
    delayOutR = delayNumChannels > 1 ? delayBuffer->getReadPointer(1) : nullptr;

    delayInL = delayBuffer->getWritePointer(0),
    delayInR = delayNumChannels > 1 ? delayBuffer->getWritePointer(1) : nullptr;

And process block:

void ChorusAudioProcessor::processBlock (juce::AudioBuffer<float>& buffer, juce::MidiBuffer& /* midiMessages */)
{
    const int totalNumInputChannels  = getTotalNumInputChannels();
    const int totalNumOutputChannels = getTotalNumOutputChannels();

    const int delayNumSamples  = delayBuffer->getNumSamples();

    const float *inL = buffer.getReadPointer(0), 
                *inR = totalNumInputChannels > 1 ? buffer.getReadPointer(1) : nullptr;

    float *outL = buffer.getWritePointer(0),
          *outR = totalNumOutputChannels > 1 ? buffer.getWritePointer(1) : nullptr;

    int numSamples = buffer.getNumSamples();

    for (auto i = totalNumInputChannels; i < totalNumOutputChannels; ++i)
        buffer.clear (i, 0, buffer.getNumSamples());

    if(inL == nullptr || outL == nullptr) return;
    if(delayInL == nullptr || delayOutL == nullptr) return;

    lfoSine.setIncrement(numSamples);

    while(--numSamples >= 0)
    {
        const int delayTime = (int) std::floor(devSampleRate * (15e-3 * (double) lfoVibe)),
                  delayReadPos = delayWritePos < delayTime ? delayNumSamples + (delayWritePos - delayTime) : delayWritePos - delayTime;

        float l = *inL++, r = inR == nullptr ? l : *inR++, tempL, tempR;

        *(delayInL + delayWritePos) = l;
        if(delayInR != nullptr) *(delayInR + delayWritePos) = r;

        delayL = tempL = *(delayOutL + delayReadPos);
        delayR = tempR = delayOutR == nullptr ? delayL : *(delayOutR + delayReadPos);
       
        l = (l * mix) + (delayL * mix);
        r = (r * mix) + (delayR * mix);

        lfoVibe = 1.f + (0.05f * lfoSine.renderSineWave());

        if(outR == nullptr)
        {
            *outL++ = (l + r) * 0.5f;
        }
        else
        {
            *outL++ = l;
            *outR++ = r;
        }
        
        delayWritePos++;
        if(delayNumSamples < delayWritePos) delayWritePos = 0;
    }

    lfoSine.resetCurrentFrequency();
}

For now, I’ve hard-coded a few values like the delay time (15ms), LFO speed (2hz), and vibrato depth.

The noise seems to be linked with the number of samples in the buffer and difference of the number of samples between each delayReadPos. Normalizing this difference removes the noise but also removes the modulation needed for the vibrato. I’ve tried calculating the delayTime outside of the while loop and incrementing delayReadPos inside the while loop, but the noise remained the same and the modulation worsened with larger buffer sizes.

Adding cubic spline interpolation and IIR bandpass filters to the vibrato signal reduces the noise:
sine_noise_filter.zip (401.2 KB)

But it’s obviously still present and at this point I’m at a loss :sweat_smile:

I would maybe experiment with the juce::dsp::DelayLine class. It has some interpolation settings that allow for inter-sample delay times (smoothing over quantization noise). You could also look at the juce::dsp::Chorus too and see how they do it. It’s a very basic chorus, but a good place to build and learn from.

1 Like

You may have an off-by-one error somewhere. That creates “jumpy” transitions like these, which sound like noise.

2 Likes

While rewriting the delay line as its own class object (and adding in asserts in class functions), I realized that the if(delayNumSamples < delayWritePos) delayWritePos = 0 should’ve been if(delayNumSamples <= delayWritePos) delayWritePos = 0

That tiiiiny little difference between < and <= trips me up sometimes when I’m writing fast xD

Finding that helped me clean up the sound a little bit when the buffer repeats once a second, but obvs didn’t affect the lfo noise. Gonna have to keep going at it

looks like you just floor your readhead. try linear interpolation instead

I think it’s the wrong fix. If your delay jumps over the sample where delayNumSamples == delayWritePos it shouldn’t be set to 0 but to delayWritePos - delayNumSamples.

The proper fix is:

if (delayWritePos >= delayNumSamples)
    delayWritePos -= delayNumSamples;

EDIT: ok, since you are using the int increment, that situation shouldn’t occur. Still, this is safer.