Simple circular buffer based delay line creates artifacts

Hi!

I am an intermediate developer, new to JUCE. I have been working on a custom implementation of a circularBuffer that I really like, and a super short (flangy effect) delay processing block that goes with it, but it creates the weirdest artifacts. I know there are tutorials out there and I have looked in this forum, but everybody has a different implementation, and I would really like to make mine work (plus I think it might be valuable for someone else to read, I think it’s a cool implementation). I am going to post here the crucial pieces of code in case someone can help me out.

The effect is part of a larger system, and has only one parameter handled elsewhere: intensity. The larger the intensity the shorter the delay (very very short) and the greater the feedback on the delay line.

Main processing block:

void  Flanger::processLocal (juce::AudioBuffer<float>& buffer, juce::MidiBuffer& midibuffer)
{
    
    int numSamples = buffer.getNumSamples();
    int delayInSamps = static_cast<int> (((1.0f-intensity_) * SAMP_DELAY_MULTIPLIER + SAMP_DELAY_OFFSET) * mSampleRate);
    
    
    delayLine->setReadWriteDelay(delayInSamps);
    delayLine->write(buffer);
    
    delayLine->readTo(auxiliarBuffer, numSamples, false);
    
    // Feedback the delayed signal
    float feedbackGain = std::pow(intensity_,EXP_FACTOR) * MAX_FEEDBACK;
    
    delayLine->feed(auxiliarBuffer,
                    delayLine->writeHead-numSamples,
                    0,
                    -1,
                    feedbackGain);
    
    // Read with the feedback
    
    delayLine->readTo(auxiliarBuffer, numSamples);
    
    // Perform drywet
    float wetAmount =0.0f;
    if(intensity_ < 0.5){
        wetAmount = std::pow(0.7f * 2 * intensity_, EXP_FACTOR);
    } else {
        wetAmount = std::pow(0.7f, EXP_FACTOR);
    }
    
    juce::dsp::AudioBlock<float> auxiliarBlock(auxiliarBuffer);
    juce::dsp::AudioBlock<float> dryBlock(buffer);
    
    auxiliarBlock.multiplyBy(wetAmount);
    dryBlock.multiplyBy(1.0f-wetAmount); // not dry anymore
    
    
    dryBlock.add(auxiliarBlock);
    
    dryBlock.multiplyBy(1.0f-intensity_*GAIN_INTENSITY_ADJUSTMENT);

}

Some key circular buffer functions:


int CircularBuffer::write(juce::AudioBuffer<float>& input, int writeHead, bool advanceWriteHead){
    
    int numSamples = input.getNumSamples();
    int numChannels = input.getNumChannels();
    jassert(numChannels == buffer.getNumChannels());
    for (int channel = 0; channel < numChannels; ++channel){
        
        const float* inputData = input.getReadPointer(channel);
        float* toStorage = buffer.getWritePointer(channel);
        
        for (int sample = 0; sample < numSamples; ++sample){
            toStorage[adjustIndex(writeHead + sample)] = inputData[sample];
        }
    }
    if(advanceWriteHead)
        return advanceHead(writeHead, numSamples);
    else return writeHead;
}

void CircularBuffer::write(juce::AudioBuffer<float>& input, bool advanceWriteHead){
    writeHead = write(input, writeHead, advanceWriteHead);
}

void CircularBuffer::advanceWriteHead(int howMuch){
    writeHead = advanceHead(writeHead, howMuch);
}

void CircularBuffer::advanceReadHead(int howMuch){
    readHead = advanceHead(readHead, howMuch);
}

int CircularBuffer::adjustIndex(int index){
    return ((index % buffer.getNumSamples()) // Adjust the bounds
    +buffer.getNumSamples()) // Make it positive in case it was negative
    % buffer.getNumSamples(); // Adjust bounds again in case it was already positive
}

When intensity is low (0.27), you can start to see artifacts like this one, original signal is up, processed on the bottom:

When intensity grows even more artifacts happen:

The artifacts length matches the buffer size I am testing with, but they don’t start with every new buffer.

Thanks in advance!

[EDIT]: forgot to mention that if I delete the “feed” line in the main processing block, there are no artifacts, so it seems like the problem might be in how I am performing feedback, but I keep debugging and reading the code and I can’t find the issue

Found the mistake! Publishing in case someone faces a similar problem.

I was feedbacking by blocks, and it should be done sample by sample. When you read a sample, feed it back. I was reading a block, feeding it back, reading again, without noticing that in delay times shorter than the block size, the feedback you put back in at the beginning might affect the feedback you read at the end. Adding some code on how I ended up doing it:

void CircularBuffer::readToWithFeedback(juce::AudioBuffer<float> &dest, int length, bool advanceHead, float fbGain, int fbStart, bool advanceWriteHeadFlag){
    if(length<0) length = dest.getNumSamples();
    length = std::min(length, dest.getNumSamples());
    copyToWithFeedback(dest, readHead, 0, length, fbGain, fbStart);
    if(advanceHead)
        advanceReadHead(length);
    if(advanceWriteHeadFlag)
        advanceWriteHead(length);
    
}

void CircularBuffer::copyToWithFeedback(juce::AudioBuffer<float> &dest, int originStart, int destStart, int length, float fbGain, int fbStart){
    int numChannels = buffer.getNumChannels();
    if(fbStart < 0) fbStart = writeHead;
    jassert(dest.getNumChannels() == buffer.getNumChannels());
    
    int destLength = dest.getNumSamples();
    int originLength = buffer.getNumSamples();
    
    destStart = adjustIndex(destStart);
    originStart = adjustIndex(originStart);
    fbStart = adjustIndex(fbStart);
    
    for(int ch = 0; ch < numChannels; ch++){
        auto originPointer = buffer.getWritePointer(ch);
        auto destPointer = dest.getWritePointer(ch);
        
        int destIndex = destStart;
        int originIndex = originStart;
        int fbIndex = fbStart;
        
        for(int i = 0; i < length; i++){
 
            destPointer[destIndex] = originPointer[originIndex];
            
            originPointer[fbIndex] += originPointer[originIndex] * fbGain;
            
            destIndex = ( destIndex + 1 ) % destLength;
            originIndex = ( originIndex + 1 ) % originLength;
            fbIndex = (fbIndex + 1) % originLength;
            
        }
    }
}

And the main process block now looks like this:

void  Flanger::processLocal (juce::AudioBuffer<float>& buffer, juce::MidiBuffer& midibuffer)
{

    int numSamples = buffer.getNumSamples();
    int delayInSamps = static_cast<int> (((1.0f-intensity_) * SAMP_DELAY_MULTIPLIER + SAMP_DELAY_OFFSET) * mSampleRate);
  
    delayLine->setReadWriteDelay(delayInSamps);
    delayLine->write(buffer, false);
    

    // Feedback the delayed signal
    float feedbackGain = std::pow(intensity_,EXP_FACTOR) * MAX_FEEDBACK;
    
    delayLine->readToWithFeedback(auxiliarBuffer, numSamples, true, feedbackGain, -1, true);
    
    // Perform drywet
    float wetAmount =0.0f;
    if(intensity_ < 0.5){
        wetAmount = std::pow(0.7f * 2 * intensity_, EXP_FACTOR);
    } else {
        wetAmount = std::pow(0.7f, EXP_FACTOR);
    }
    
    juce::dsp::AudioBlock<float> auxiliarBlock(auxiliarBuffer);
    juce::dsp::AudioBlock<float> dryBlock(buffer);
    
    auxiliarBlock.multiplyBy(wetAmount);
    dryBlock.multiplyBy(1.0f-wetAmount); // not dry anymore
    
    
    dryBlock.add(auxiliarBlock);
    
    dryBlock.multiplyBy(1.0f-intensity_*GAIN_INTENSITY_ADJUSTMENT);


}
1 Like