import matplotlib
if not hasattr(matplotlib.RcParams, "_get"):
matplotlib.RcParams._get = dict.get
13.1 Feedback delay#
We begin with a family of effects built from a single primitive that we met in Chapter 12: the feedback delay line,
Recall that its impulse response is an impulse train that decays by a factor of \(g\) every \(M\) samples:
And as we saw in Chapter 12, its frequency response is a comb, with teeth at whole-number multiples of \(\frac{f_s}{M}\). The delay \(M\) sets the spacing of the echoes in time, and of the teeth in frequency, while the gain \(g\) sets how quickly the echoes decay, and how sharp the teeth are:
Fig. 95 Impulse responses (top of each cell) and magnitude responses (bottom of each cell) of feedback delay lines with four gains \(g\) (rows) and four delays \(M\) (columns), at \(f_s = 44{,}100\) Hz.#
Here is the same feedback_delay function from Chapter 12, which we will use in the examples that follow:
def feedback_delay(x: np.ndarray, M: int, g: float = 1.0) -> np.ndarray:
"""A feedback delay line with attenuation, y[n] = x[n] + g * y[n - M]."""
buffer = np.zeros(M) # the delay line, as a circular buffer
i = 0
y = np.zeros(len(x))
for n in range(len(x)):
y[n] = x[n] + g * buffer[i] # attenuate y[n - M] on every trip
buffer[i] = y[n]
i = (i + 1) % M
return y
impulse = np.zeros(10)
impulse[0] = 1.0
print(feedback_delay(impulse, M=3, g=0.5)) # h[n] = g ** (n / M) whenever M divides n
[1. 0. 0. 0.5 0. 0. 0.25 0. 0. 0.125]
In Chapter 12, we used a feedback delay line to model the acoustics of a string, by feeding it unit impulses and bursts of noise. But what happens if the input \(\mathbf{x}\) is some other sound? And how does our perception of the result change as we vary \(M\)? The answer hinges on the comb’s tooth spacing, \(\frac{f_s}{M}\) and how it compares to the lower limit of human hearing (\(20\) Hz).
Comb filtering#
When \(M\) is small, the teeth of the comb are spaced far apart, at whole-number multiples of \(f_0 = \frac{f_s}{M}\). As \(M\) shrinks, \(f_0\) grows, and once it exceeds \(20\) Hz, we perceive it as the fundamental frequency of a tone. So a feedback delay line with a small \(M\) imposes a pitch on any sound passed through it, an effect known as comb filtering. The gain \(g\) determines the strength of the effect: the closer \(g\) is to \(1\), the sharper the teeth, and the more clearly we hear the pitch.
The code below passes a recording of a bouncing ball through a feedback delay line with \(M = 200\) samples. Every bounce now rings at a pitch of \(f_0 = \frac{44{,}100}{200} = 220.5\) Hz:
ball = pq.Audio.from_file("./assets/ball.wav")
M = 200 # f_s / M = 220.5 Hz, well above 20 Hz
comb = feedback_delay(ball.samples[:, 0], M, g=0.5)
print(f"The comb's teeth are f_s / M = {ball.sample_rate / M} Hz apart")
pq.play(ball, normalize=True) # dry
pq.play(pq.Audio(comb, ball.sample_rate), normalize=True) # wet
The comb's teeth are f_s / M = 220.5 Hz apart
Sound from Freesound: 445724 by 180241virtualwindow.co.za, License: Attribution NonCommercial 3.0.
Delay#
When \(M\) grows so large that \(\frac{f_s}{M} \leq 20\) Hz, the teeth of the comb crowd so close together that we stop perceiving them as harmonics. Instead, we perceive the delayed copies as distinct sound events, or echoes. By the units, this happens once the delay lasts
or \(50\) ms. This regime is what guitarists colloquially call a delay effect.
Delays are often timed to the music. The code below adds a delay to a guitar that plays a note every \(0.221\) seconds. We set the delay to half of that, so every echo lands halfway between two notes, and the guitar sounds like it is playing twice as fast:
guitar = pq.Audio.from_file("./assets/guitar-arpeggio.wav")
f_s = guitar.sample_rate
x = np.concatenate([guitar.samples[:, 0], np.zeros(2 * f_s)]) # room for the echoes
note_interval = 0.221 # seconds between the guitar's notes
M = round(f_s * note_interval / 2) # echo halfway between notes: double time
wet = feedback_delay(x, M, g=0.75)
w = wet_ratio(6.0) # wet 6dB above dry
print(f"M = {M} samples, so f_s / M = {f_s / M:.1f} Hz, below 20 Hz")
pq.play(guitar, normalize=True)
pq.play(pq.Audio((1 - w) * x + w * wet, f_s), normalize=True)
M = 4873 samples, so f_s / M = 9.0 Hz, below 20 Hz
Echo#
Suppose we make \(M\) very long, on the order of seconds. We still perceive the delayed copies as distinct sound events, but they arrive so long after the original that they no longer sound like a part of it. Instead, they sound like a reflection from a distant surface, like shouting into a canyon. For example, sound travels at about \(343\) m/s, so an echo that returns after \(1\) second has traveled \(343\) meters, to a cliff about \(171.5\) meters away and back.
The code below sets \(M = f_s\), a \(1\) second delay, and applies it to a short yodel:
yodel = pq.Audio.from_file("./assets/yodel.wav")
f_s = yodel.sample_rate
x = np.concatenate([yodel.samples[:, 0], np.zeros(5 * f_s)]) # room for the echoes
M = f_s # a 1 second round trip
wet = feedback_delay(x, M, g=0.4)
w = wet_ratio(0.0) # equal parts wet and dry
pq.play(yodel, normalize=True)
pq.play(pq.Audio((1 - w) * x + w * wet, f_s), normalize=True)