Writing to buffer at rate>1 - delay-line approach questions

melt's icon

Hi all,

Another newbie question. I appreciate the support.

Based a suggestion from Graham W., I am trying to apply his delay-line approach to overdubbing at rates > 1 in a varispeed looper I am building. I am adopting this approach from the looper example in Jeff K's/Gregory T's tutorials seen here: https://technophony.com/building-live-loopers-in-max/chapter-4-varispeed-overdubbing-looper-gen/

I have to say that the conceptual jump from the previous patch to this patch is quite a challenge for me.

In any case, my question is regarding the operation to "fill in skipped samples" when overdubbing at sample counter rates > 1. Graham's example is the following:

for (i=0; i<min(32, abs(t)); i+=1) {
	// get the corresponding sound from our past input:
	s = indelay.read((abs(t)-i)/aspd);
	// write it to tape at the writer position
	// using crossfading with existing data
	poke(tape, s*xb, wb, 0, xa, boundmode="wrap");
	// move the writer on one sample in the desired direction
	wb += sign(t);	
}

t = how many samples are skipped, i.e., how many samples we need to fill in (if I am interpreting the other code correctly), and
aspd = (basically) the rate of playback
xb/xa = a crossfade on the input/overdub

I have a couple of questions:

  • I don't really follow the math in the delay read() function. Why divide by the speed?

  • how does this account for fractional sample indices, such as when the playback rate is a non-integer (e.g., 1.75)?

  • I'm having a lot of trouble wrapping my head around how the delay read function is working. For delay.read(x) - are you fetching the data at sample x in the delay buffer? For the code example above, wouldn't the value for read() need to be relative to where we are "in time" i.e., fetch the data from 3 samples ago?

Thanks in advance for helping me wrap my head around this stuff!

melt's icon

The reason for my inquiry is that I am trying to adopt a similar approach to my own project, which requires the "filling in" of skipped samples when running a sample counter at rates > 1.

I can't seem to get the math right; I am hitting a wall with a problem of overlapping sample writes, which because I am overdubbing results in a periodic increase in sample value, which creates artifacts.

My scenario is little different than Graham's in that I am doing the sample counting outside of the codebox with a modulo and a history+accum (basically a looping counter). To change the "rate" of playback I simply multiply the left input of the modulo with a rate multiplier. The output therefore can be non-integer. I use this rate value as a way of informing the code (similar to the above) how many samples need to be filled in with the delay. At a rate of 2, I need to fill in 2 sample for every tick, at a rate of 3.5, I need to fill in 3.5 samples for every tick, and so on. From my interpretation of Graham's code, he is doing something similar while also accounting for non-integer sample indices.

But I cannot wrap my head around how we account the non-integer values since we cannot poke a value at non-integer indices (it rounds down, right?). And the for loop cannot run a "half" time either. So for example, at rate = 1.5 (i.e., we want to fill in 1.5 samples) the for loop runs twice, and we write two samples with poke:

Green = sample reads @ rate = 1.5. Red = sample writes

So if we overdub those values into the buffer, we'll have higher values every other sample, resulting in the artifacts that I am seeing/hearing.

I am having much difficulty wrapping my head around this!

Graham Wakefield's icon

A common approach here is to interpolate the input, so that we are still only writing at integer locations. An easy way to interpolated input is to add a Delay operator on the input, which will do linear interpolation between samples on read.

Let's say our tape is modelled using data tape, and the current write `head` location is defined by your accum. Make another variable, let's call it history write to keep track of where we have actually filled input to the data. The goal is that write catches up to the desired head location, but as you say, you can't write to a non integer location in the data. So write will catch up to the nearest integer location behind the desired head position.

Let's say 'tape speed's is 2.5, so we are supposed to be writing 2.5 points per sample. So actually on one sample we can write 2 points, on the next sample we can write 3 points, and 2, 3, 2, 3 etc.

We can do something like (psuedocode)

while (write < floor(head)) {

write += 1

poke(tape, value, write)

}

What should the value be? This is the thing that we have to interpolate. For example, if our true head position is 7.5, but our write position is 6, then we need to write the input as it was 1.5 samples ago. If we started our code box with

Delay input;

input.write(in1);

Then we can always get an interpolated, delayed version of the input with `input.read(time)`, where time can be a value like 1.5 etc.

But the only thing is, this time is in terms of tape speed, so we need to say time / tape_speed for it to be right. So, in our while loop, we can say

value = input.read((head - write) / tape_speed);

That should give us what we need for a basic varispeed data writer.

You'll have to modify this to get it to work though. For example, handling what happens when the head wraps around at the limits of the tape, and likewise for write. This can be a bit tricky to get right, but it is very doable.

--

There's other things you might want to do to limit potential aliasing, both for writing and for reading. But solve the basic varispeed writer first :+)

melt's icon

Thanks a lot (again) Graham. Your willingness to help educate this novice is really appreciated.

I ended up - for the first time in my life - asking ChatGPT about this problem and it illuminated a couple significant errors in my interpretation of your algorithm.

Firstly, like you said, we're interpolating the audio not the sample index. For some reason I was thinking your approach was a fancy way to write in between samples. But that is incorrect. It is a way of filling in the skipped samples with meaningful data.

ChatGPT also helped me understand what the delay.read(x) is doing. X is basically how far back in time we're reaching, if I understand correctly. I guess because Delay allows for interpolated reads, it allows us to have an accurate/contextually meaningful bit of data to write in those skipped sample indices?

The problem of my samples overlapping I now understand to be a limitation of how we're determining the integer values of where to begin and where to end the "filling in of samples."

So actually on one sample we can write 2 points, on the next sample we can write 3 points, and 2, 3, 2, 3 etc.

Exactly the problem I have been facing with my implementation - how to make it write 2 samples, then 3 samples, then 2 again, etc. The missing piece of the puzzle (I think) is to round down the index I am using in my for/while condition, which you show here:

While (write < floor(head)) {

...

I missed this in your looper code. My for loop was using the fractional sample index as the "end point" thus, in the case of rate = 2.5, it was writing 3 samples every time and periodically overlapping.

I will try and put this in practice later on this evening. Thanks again, Graham!

Graham Wakefield's icon

I just edited my reply above, because I missed a detail -- the delay time needs to also be divided by the tape speed to be accurate. Then it works.

I've just tried to patch something from scratch based on my own advice up there -- that's how I spotted that missing element -- and I solved the wrap around problem too.

Here's the patch with this method, more or less. It demonstrates using it as a simple delay.

It also shows how we can do feedback/looping by using the overdub property within the buffer itself.

Hopefully this is a useful start point for trying different things out!

Max Patch
Copy patch and select New From Clipboard in Max.

melt's icon

This is great, thanks again Graham. I finally got it working properly! I was hitting a few different issues, such as boundaries being mishandled outside my codebox (I was clamping the length which caused an artifact at the loop boundary), not fully understanding where/when to declare variables in regards to my iteration loop (I had to move my declaration of the variable which iterates on the sample index being written outside of the for loop), as well as an issue with wrapping the sample index being written (which I mishandled and was creating a sample overlap at the index length-rate)

Thank you for the suggestion re: wrapping. It (signed shortest distance) didn't work for establishing my interpolated write start and end points, but it was exactly what I needed to determine the number of skipped samples when running the buffer in both directions (i.e., forward and reverse).

Next up is to see how this works for rates < 1.

This has been challenging but I'm learning lots. I picked up your book, by the way, which I look forward to digging in to. Thanks again!