Notes on continuous (ring) buffer reading

Hi everyone! I’ve been working on a module that uses a ring buffer for recording continuous audio. I wanted to share a technique that I used to solve an issue when reading audio from the buffer close to the write head.

Imagine it like a spool of audio tape, like an old tape echo:

The issue arises when you attempt to play a section of audio that contains both older audio and newer audio, like this:

When the read head reaches the write head, there’s a discontinuity between the older (previously recorded) audio and the newer audio which can cause an audible “pop”.

When discussing options with AI, it keep urging me to “crossfade” between the two, but I don’t think that’s actually possible or logical.

Instead, I’m using a fast fade-out when the read head (aka. the playback position) approaches the write head, and then fading-in after it passes the write head.

I’m just sharing in case anyone runs into a similar situation. And I’m all ears if anyone has a better approach. :slight_smile:

Here’s pseudocode for my implementation:

// --- Assumed Interfaces for Dependencies ---

INTERFACE AudioBuffer
    // Gets the total number of samples the buffer can hold
    FUNCTION getSize() -> INTEGER

    // Gets the current write position (like a playhead) in the buffer
    FUNCTION getWritePosition() -> INTEGER

    // Gets the raw left channel sample at a specific integer index
    FUNCTION getSampleLeft(index: INTEGER) -> FLOAT

    // Gets the raw right channel sample at a specific integer index
    FUNCTION getSampleRight(index: INTEGER) -> FLOAT
END INTERFACE

ENUM InterpolationMethod
    LINEAR, CUBIC, SINC // etc.
END ENUM

INTERFACE SampleInterpolator
    // Sets the interpolation algorithm to use
    PROCEDURE setMethod(method: InterpolationMethod)

    // Calculates an interpolated sample value at a fractional read position.
    // It uses 'sampleSourceFunc' to get raw sample values at integer indices.
    // 'bufferSize' is provided for context (e.g., boundary handling).
    FUNCTION interpolate(readPosition: FLOAT, sampleSourceFunc: FUNCTION(INTEGER) -> FLOAT, bufferSize: INTEGER) -> FLOAT
END INTERFACE

// Structure to hold a stereo sample pair
STRUCTURE StereoSample
    left: FLOAT
    right: FLOAT
END STRUCTURE

// --- Pseudocode for FadingBufferReader ---

CLASS FadingBufferReader

    // --- Member Variables ---
    PRIVATE buffer: REFERENCE to AudioBuffer    // The audio buffer being read from
    PRIVATE fadeZoneWidth: INTEGER              // The number of samples around the write head to fade
    PRIVATE interpolator: SampleInterpolator    // Object to handle fractional sample interpolation

    // --- Constructor ---
    // Initializes the reader with a buffer and an optional fade zone width.
    CONSTRUCTOR FadingBufferReader(inputBuffer: REFERENCE to AudioBuffer, initialFadeWidth: INTEGER DEFAULT 1000)
        SET this.buffer = inputBuffer
        // Ensure fade width is at least 1 to avoid division by zero later
        SET this.fadeZoneWidth = MAX(1, initialFadeWidth)
        // Create or initialize the interpolator, defaulting to Linear
        CREATE this.interpolator
        this.interpolator.setMethod(InterpolationMethod.LINEAR)
    END CONSTRUCTOR

    // --- Public Methods ---

    // Gets an interpolated mono sample at the given fractional read position,
    // applying fading near the write head.
    FUNCTION getSample(readPosition: FLOAT) -> FLOAT
        // Define a helper function that retrieves a raw *faded* mono sample
        // at an integer index. This is what the interpolator will call.
        DEFINE FUNCTION getFadedMonoSampleAtIndex(index: INTEGER) -> FLOAT
            rawLeft = this.buffer.getSampleLeft(index)
            rawRight = this.buffer.getSampleRight(index)
            monoSample = 0.5 * (rawLeft + rawRight)
            RETURN this.applyFadingIfNeeded(index, monoSample)
        END FUNCTION

        // Use the interpolator to get the final value
        bufferSize = this.buffer.getSize()
        RETURN this.interpolator.interpolate(readPosition, getFadedMonoSampleAtIndex, bufferSize)
    END FUNCTION

    // Gets an interpolated stereo sample pair at the given fractional read position,
    // applying fading near the write head to both channels independently.
    FUNCTION getSampleStereo(readPosition: FLOAT) -> StereoSample
        // Define helper function for the left channel
        DEFINE FUNCTION getFadedLeftSampleAtIndex(index: INTEGER) -> FLOAT
            rawLeft = this.buffer.getSampleLeft(index)
            RETURN this.applyFadingIfNeeded(index, rawLeft)
        END FUNCTION

        // Define helper function for the right channel
        DEFINE FUNCTION getFadedRightSampleAtIndex(index: INTEGER) -> FLOAT
            rawRight = this.buffer.getSampleRight(index)
            RETURN this.applyFadingIfNeeded(index, rawRight)
        END FUNCTION

        // Use the interpolator separately for each channel
        bufferSize = this.buffer.getSize()
        interpolatedLeft = this.interpolator.interpolate(readPosition, getFadedLeftSampleAtIndex, bufferSize)
        interpolatedRight = this.interpolator.interpolate(readPosition, getFadedRightSampleAtIndex, bufferSize)

        // Combine into a stereo sample structure
        RETURN CREATE StereoSample(left = interpolatedLeft, right = interpolatedRight)
    END FUNCTION

    // Updates the width of the fade zone.
    PROCEDURE setFadeZoneWidth(width: INTEGER)
        // Ensure fade width is at least 1
        this.fadeZoneWidth = MAX(1, width)
    END PROCEDURE

    // Changes the interpolation method used.
    PROCEDURE setInterpolationMethod(method: InterpolationMethod)
        this.interpolator.setMethod(method)
    END PROCEDURE

    // Allows access to the underlying buffer object.
    FUNCTION getBuffer() -> REFERENCE to AudioBuffer
        RETURN this.buffer
    END FUNCTION

    // --- Private Helper Methods ---

    // Calculates and applies a fade multiplier to a sample if its read position
    // is close to the buffer's write position.
    PRIVATE FUNCTION applyFadingIfNeeded(readIndex: INTEGER, sampleValue: FLOAT) -> FLOAT
        writePos = this.buffer.getWritePosition()
        bufferSize = this.buffer.getSize()

        // Handle empty buffer case
        IF bufferSize == 0 THEN
            RETURN 0.0
        END IF

        // Calculate the minimum distance between the read index and write position,
        // accounting for buffer wrap-around.
        diff = writePos - readIndex
        directDistance = ABSOLUTE_VALUE(diff)
        wrapAroundDistance = bufferSize - directDistance
        distance = MIN(directDistance, wrapAroundDistance)

        // If the distance is greater than the fade zone, no fading is needed.
        IF distance > this.fadeZoneWidth THEN
            RETURN sampleValue
        END IF

        // Calculate a fade factor between 0.0 and 1.0.
        // Factor is 0.0 at the write head (distance=0)
        // Factor is 1.0 at the edge of the fade zone (distance=fadeZoneWidth)
        fadeFactor = CAST_TO_FLOAT(distance) / CAST_TO_FLOAT(this.fadeZoneWidth)

        // Ensure factor is clamped between 0 and 1 (optional, but good practice)
        fadeFactor = CLAMP(fadeFactor, 0.0, 1.0)

        // Apply a smoothing curve (cosine half-wave) for a smoother fade-in/out
        // This maps the linear fadeFactor [0, 1] to a smooth curve [0, 1]
        DEFINE PI = 3.14159...
        smoothFactor = (1.0 - COS(fadeFactor * PI)) * 0.5

        // Apply the fade multiplier to the sample
        RETURN sampleValue * smoothFactor
    END FUNCTION

END CLASS

If you want more information about the process, let me know!

[ Updated by removing the AI generated explaination of that code. ]

3 Likes

I will re-open if you rewrite this like a human.

2 Likes

Where is your read head? Why would you hear an audible “pop” if you can guarantee that the read head is always behind the write head?

… if you can guarantee that the read head is always behind the write head?

Ah, in my case, I can’t make that assumption because I’m spawning grains for granular synthesis, and the grains can read from anywhere in the buffer.

I experimented with creating a “buffer zone” to prevent grains from overlapping the write head, but it became extremely complicated to take that approach. Maybe others will have a different implementation and be able to pull that off.

When the reading head and the writing head run at the same speed, then there should not be any problem.

I only see a problem when the reading head is close behind the writing head and reads faster than the writing head writes data (or vice versa).

Does your module read data at a different speed?

We have a new delay effect in Surge-land (not yet made into a rack module though we want to do that eventually) which plays back echoes at variable speeds, and thus runs into this same problem. It’s inspired by “JS:Floaty Delay” found in Reaper, which uses this same fade in/out technique you are, but I honestly don’t love the results it gives. At some combinations of delay times and playback rates, there’s very noticeable fluttering. I’ve been meaning to improve on that a bit. I believe you’re quite right that a crossfade is not a reasonable proposal in in a rolling buffer (a fixed loop is differrent). That is as long as you have one write and one repro head. By using two playback heads further apart, you can get an actual crossfade though. You’d crossfade to the second repro head when the first approaches the write. Haven’t actually gotten around to coding that up yet, but I thought I’d share anyway.

1 Like

Yes, it can read data from the buffer at different speeds.

I have a feature called “spread” which takes the initial read position and offsets the read position for each grain based on a select-able algorithm. Here’s a visualization of that in action:

In addition, I have another feature called “warp” :wavy_dash: which allows the read head to linger (or race) through the buffer.

I’m guessing that my module might have that type of fluttering as well, but it might be less noticeable in my situation. Anyhow, it’s good to get your feedback, and it’s nice to know that my approach isn’t unique! :man_bowing:

A large portion of the work I put into my Memory modules (docs, video) do pretty much exactly this fast-fade technique (and also a “smoothing” technique). In my case, it’s even worse, since users can have multiple read and multiple white heads.

Here’s my documentation about the techniques I use. I’m happy it works pretty well, but it took me a while to understand all the causes of discontinuities. There’s still some I don’t handle well (like when a module is pulled out of the group), but, ok, fine, don’t do that.

3 Likes

Hi Bret! It’s wild that you and I are both working on this exact problem at the same time. In my case, this problem happens when running the recorder in reverse. I’m going to study your pseudo-code and see if I can understand this. I was also thinking in terms of cross-fading, and I do think it could be made to work, so I’m exploring that also.

What I think is that you have to have multiple parallel buffers containing older data and newer data, and figure out how to slowly cross-fade between them until you are outside your “slewing zone”. The mix coefficient changes as the playback head advances, fading in newly written data while fading out the data that was written the previous cycle.

But I could be completely wrong, LOL.

I’ll post back here if I figure more out.

1 Like

Oh man, this is awesome. Thanks for posting!

1 Like

Depending on what you’re doing, you might also wish to check my code for smoothing out discontinuities.

In brief, instead of fading out and in, the smoother gets handed a region of samples and a point in that region where the caller is worried might be a discontinuity. Then the smoother tries to find a minimal region around that point that can be roughly leveled within certain constraints.

This is helpful for avoiding adding fades when, in fact, there isn’t a need for one, or at least less need. E.g., here are some sample values:

2.0
2.2
2.4
2.6
2.8  --- Stopped recording here, maybe discontinuity?
3.2
3.1
3.0
2.9
2.8
2.7

An implementation of smoothing might replace this with:

2.0
2.2
2.4
2.6
2.8  --- Stopped recording here, maybe discontinuity?
2.8  -- altered
2.8  -- altered
2.8  -- altered
2.8  -- altered
2.8
2.7

Which is a much smaller alteration than a fade out/in might do.

The testing code might illustrate this better.

Hope that’s interesting.

m

1 Like

I have the following idea for the case that the length of a grain is known in advance before the grain starts to happen, and if your module does not use hundreds of grains at the same time:

Just before the moment, the data-read for a grain is going to start, the whole data for that grain (according to its length) will be copied to a separate buffer.

Then the write-head may overwrite the whole main-buffer without affecting the data for that specific grain.

It would make sense to allocate memory for the maximum number of buffers in advance to avoid memory allocation in the process-thread.

This is REALLY interesting!! What a clever idea! I’ll try to experiment with this and report back. The fade-in / fade-out strategy is working beautifully for me, so I don’t know if it would be worth the overhead in my situation, but I kind-of want to try it out just to see how it plays out.

That’s really clever. I’ve used a similar technique in some of my other modules. I’d essentially start an increasingly aggressive slew toward the destination sample.

PS:

I don’t know if I’d trust that guy. He’s maybe a bit :crazy_face:

4 Likes

This is very interesting to me! This is similar to the approach I was thinking of, only applied to a tape loop rather than a granular system, although of course the two cases are not that different.

I mainly have the problem of recording audio to the tape loop while playing it in reverse. Like you both have said, when the playback head sweeps over the record head, there is a discontinuity.

Instead of ducking playback, my idea was to have my tape loop, plus a short linear piece of tape that stores maybe 5ms of older audio that was recorded on the previous loop. This short piece of tape is centered on the record head, so maybe ±2.5ms before and after it.

When the playback head enters the “danger zone” ±2.5ms around the record head, depending on which side you are entering from (the “old audio” side or the “new audio” side of the record head), you start crossfading from older audio to newer audio.

At any moment, if the playback head is in the danger zone on the “old audio” side, you adjust the mix to favor the older audio. Exactly at the record head position, you have a 50/50 mix. And when the playback head is on the “new data” side, you include majority new data, minority old data. Something like:

playback = mix*newAudio + (1-mix)*oldAudio;

The mix variable approaches 0 as you approach the “old audio” edge of the short tape, and approaches 1 toward the “new audio” edge.

I don’t know yet whether this will sound better, worse, or even different from the playback ducking approach.

So my plan is to implement both and make it a menu option, just so I can experiment and hear the difference. It will probably take me a week or two to get there, but I’ll report back here once I know more.

OK, I figured how to draw a picture of what I’m visualizing. Like I said, as an alternative option to ducking playback near the record head, I want to also try crossfading just behind the record head by a small amount of time \tau.

Let T be the desired loop time expressed in seconds. And \tau might be something like 0.01 seconds. So you keep a circular buffer of T+\tau seconds worth of audio samples. I like to think of it as a slightly self-overlapping spiral:

The crossfade happens at time t when the read head ends up inside the interval T \le t \le (T+\tau). Then you crossfade as shown by the red and blue lines.

This picture means: crossfade between the audio that used to be there, but just got overwritten by the record head (red), with the new audio that just now got on the tape because of the record head (blue).

I feel optimistic that this will work and sound decent, and maybe even different enough from ducking, to be its own separate mode option in my module.

1 Like

That’s a really neat way to visualize it. I understood from your previous posting, and it’s a cool strategy. With a really long buffer, it might introduce from “sounds from the past”, but I can definitely see it working well in some situations!

I had thought of a similar strategy which played the recorded audio in reverse (from the end) and cross-faded that into the newer content. You might hear a bit of reverse sound, but it might be a little more subtle. :thinking:

2 Likes

Thanks for sharing all your thoughts everyone. Super useful. Appreciate that smoothing code in particular @StochasticTelegraph, mind if I attempt borrowing from that when I eventually get to improving our aforementioned effect?

The smoothing stragegy (sic) I wrote for which can be seen here btw: sst-effects/include/sst/effects/FloatyDelay.h at 34215094c7e7a4f65fdcb06bd989e628f43e8295 · surge-synthesizer/sst-effects · GitHub Though again, I wouldn’t necessarily recommend it as a general solution. Sounds good for your average delay times and playrates in -2…-1 and 1…2. But not so great for short times and/or -1…1 playrates.

If by some unlikely chance I’ve written something useful, please borrow/steal from it boldly.

mahlen

4 Likes