Audio delay with feedback

I'm writing a simple "audio delay effect", with feedback (the closer FEEDBACK from 1, the more the repetitions continue endlessly), as used in many audio software.

The main function process_block works on chunks of 512 or 1024 samples (we usually cannot go lower, depending on the audio interface).

The following code works, but, in the output, the delay is not 10 milliseconds as desired, but 10 + 23 milliseconds.

How to fix the following delay algorithm? Is the problem in the way I build delayed_buf?

import numpy as np, scipy.io.wavfile as wavfile

DELAY = 0.010  # seconds
WET = 0.50
FEEDBACK = 0.8
BLOCK_SIZE = 1024  # 23 ms @ 44.1 Khz

y = np.zeros_like(x)
buf = np.zeros(44100)

def process_block(inp):
global buf
delayed_buf = buf[-BLOCK_SIZE-int(DELAY*sr):-int(DELAY*sr)]   # problem here but I don't see how we can fix it
out = (1 - WET) * inp + WET * delayed_buf
buf = np.concatenate((buf, inp + FEEDBACK * delayed_buf))
return out

for i in range(0, len(x), BLOCK_SIZE):
if i+BLOCK_SIZE > len(x):
break
y[i:i+BLOCK_SIZE] = process_block(x[i:i+BLOCK_SIZE])

wavfile.write("out.wav", sr, y)

• Your block size is 23ms long… so the implied delay there is 23ms
– Jdip
Commented Apr 16 at 16:31
• Yes @Jdip, that's precisely the point. Buffer size is very often 512 or 1024 in audio applications, i.e. the audio fx software can only process realtime audio by blocks of 512 or 1024. But still, it is common to have delays with delay < 23 ms. How to modify my code to do so? Commented Apr 17 at 9:10
• My point is you cannot, unless you decrease the block size. Latency of 20ms can be acceptable for some applications, for others it's not and you need smaller block sizes...
– Jdip
Commented Apr 17 at 17:58
• @Jdip Actually it's possible: every music production software (DAW) have a buffer size set to 512 or 1024 samples, depending on the sound card. Still they have built-in realtime audio FX and it is totally possible to set it to 1 ms or 2 ms feedback delay, without having a 12 or 23 ms additional delay. So there is maybe a little bug in my code. Do you know how to fix it? Commented Apr 17 at 20:58
• Any device intended for real-time operation (where an operator or musician is hearing the output of this device live) that has a 512 sample block size, that's just inexcusable. Someone needs to be fired. Commented Apr 17 at 21:38

Let's start with the block diagram. A recursive delay consist of forward path, a feedback path, a bulk delay and a feedback gain. There are four different way to arrange these.

For each we can derive the coupled difference equations

$$\begin{cases} \text{I:} & y[n] = g \cdot v[n-N] & v[n] = y[n] + x[n] \\ \text{II:} & y[n] = v[n-N] & v[n] = g \cdot y[n] + x[n] \\ \text{III:} & v[n] = y[n-N] & y[n] = g \cdot (v[n] + x[n]) \\ \text{IV:} & v[n] = g \cdot y[n-N] & y[n] = v[n] + x[n] \\ \end{cases} \tag{1}$$

They all behave slightly different behaviors (which have pros and cons) but I think you are trying to implement version II so that's the equation we need to implement.

Turns out delays seem easy but are actually a bit tricky to implement because the state management is hard to do efficiently. The cleanest way is to use a circular buffer with write pointer and a read pointer. The read pointer trails the write pointer by the amount of the desired delay. New data gets written to the write pointer location, the delayed pointer get read from the read pointer location and than both pointers get incremented module the delay buffer size.

The modulo function is quite inefficient no most processors. A partial workaround is to make the delay buffer size a power of 2 and than use a single bit and operation to mask out the overflowing bit.

Here is some code that implements. It's poor code quality but I was trying to stick with your initial flow.

import numpy as np
import scipy.io.wavfile as wavfile

DELAY = 0.01  # seconds
WET = 0.50
FEEDBACK = 0.8
BLOCK_SIZE = 1024  # 23 ms @ 44.1 Khz
MAX_DELAY_LOG2 = 12             # max delay is a power of 2
MAX_DELAY = 1 << MAX_DELAY_LOG2

y = np.zeros_like(x)

delayBuf = np.zeros(MAX_DELAY)         # delay buffer
delayWP = 0                            # write pointer
delayRP = MAX_DELAY - int(DELAY * sr)  # read pointer

def process_block(inp):

global delayBuf
global delayWP
global delayRP
global FEEDBACK
out = np.zeros_like(inp)
for i in range(0, BLOCK_SIZE):
out[i] = delayBuf[delayRP]
delayBuf[delayWP] = out[i] * FEEDBACK + inp[i]
# update the delay pointers circularly
delayWP = (delayWP + 1) & MAX_DELAY_MASK
delayRP = (delayRP + 1) & MAX_DELAY_MASK
return out

# run the block by block loop
for i in range(0, len(x), BLOCK_SIZE):
if i + BLOCK_SIZE > len(x):
break
inp = x[i:i + BLOCK_SIZE]
y[i:i + BLOCK_SIZE] = (1 - WET) * inp + WET * process_block(inp)

wavfile.write("out.wav", sr, y)


One more note on scaling and the WET parameter. To maintain overall loudness when adjusting this parameter it's typically better to pan in energy and not in amplitude, .i.e. something like

y[i:i + BLOCK_SIZE] = np.sqrt((1 - WET**2)) * inp + (WET**2) * process_block(inp)


You will also find that the energy in the delayed portion is dependent on the feedback. The higher the feedback, the louder the delay will get. You can compensate this by scaling the delayed part with $$\frac{1}{1-g^2}$$.

The issue you're encountering with the additional delay in your audio effect is due to the way the audio buffer is being handled in combination with the block processing approach. In your current setup, the audio processing introduces an inherent delay of one block size (BLOCK_SIZE) before the input audio is actually output, which results in a total delay of BLOCK_SIZE/sr + DELAY.

In each iteration, the output out depends on delayed_buf, which is taken from the end of the buffer buf. However, the current input block (inp) is appended to the buffer only after it's used to generate out. This means that the input has to wait for one block period before it affects the output, thus introducing an extra delay of one block size.

Haven't tested, but something like this should work:

def process_block(inp):
global buf
# Append input to buffer first
buf = np.concatenate((buf[BLOCK_SIZE:], inp))

# Calculate indices for delayed buffer extraction
delay_samples = int(DELAY * sr)
delayed_buf = buf[-BLOCK_SIZE-delay_samples:-delay_samples]

# Calculate output
out = (1 - WET) * inp + WET * delayed_buf

# Update the buffer again, now incorporating feedback into the buffer's end part
buf[-BLOCK_SIZE:] = inp + FEEDBACK * delayed_buf

# OR MAYBE:
# buf[-BLOCK_SIZE-delay_samples:-delay_samples] *= FEEDBACK
# buf[-BLOCK_SIZE-delay_samples:-delay_samples] += out * FEEDBACK
return out