13.1 Feedback delay

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,

\[y[n] = x[n] + g \cdot y[n - M].\]

Recall that its impulse response is an impulse train that decays by a factor of \(g\) every \(M\) samples:

\[\begin{split}h[n] = \begin{cases} g^{n / M} & \text{if } n = k \cdot M \text{ for some } k \in \mathbb{Z}_{\geq 0}, \\ 0 & \text{otherwise.} \end{cases}\end{split}\]

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:

A four-by-four grid. Rows vary the gain g from 0.25 to 0.95, and columns vary the delay M from 50 to 11025 samples, labeled with their durations, from 1.1 ms to 250 ms, and their tooth spacings f_s over M, from 882 Hz down to 4 Hz. Each cell shows an impulse response on top, red stems spaced M samples apart whose heights decay by g each time, and a magnitude response on the bottom, from 0 to 1000 Hz. Larger g makes the stems decay more slowly and the peaks of the magnitude response taller and sharper. Larger M spaces the stems further apart in time and packs the peaks more densely in frequency, until at 250 ms they blur together.

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:

# hide
import numpy as np
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:

# hide
import numpy as np
import pyquist as pq
# hide
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
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

\[\frac{M}{f_s} \left[\frac{\text{samples}}{\text{trip}} \cdot \frac{\text{seconds}}{\text{sample}}\right] \geq \frac{1}{20} \left[\frac{\text{seconds}}{\text{trip}}\right],\]

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:

# hide
import numpy as np
import pyquist as pq
# hide
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


def wet_ratio(db: float) -> float:
    """The wet ratio w that mixes the wet signal `db` decibels above the dry signal."""
    return 1 / (1 + pq.helper.db_to_amplitude(-db))
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

Sound from Freesound: 453384 by loudernoises, License: CC0.

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:

# hide
import numpy as np
import pyquist as pq
# hide
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


def wet_ratio(db: float) -> float:
    """The wet ratio w that mixes the wet signal `db` decibels above the dry signal."""
    return 1 / (1 + pq.helper.db_to_amplitude(-db))
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)

Sound from Freesound: 484841 by Astounded, License: CC0.