13.3 Multitap delay lines

Contents

import matplotlib
if not hasattr(matplotlib.RcParams, "_get"):
    matplotlib.RcParams._get = dict.get

13.3 Multitap delay lines#

A tapped delay line need not stop at one tap. A multitap delay line reads one delay line at several positions. For example, with taps at \(M_a < M_b < M'\):

A signal-flow diagram. x[n] passes through three blocks in series, labeled z to the minus M_a, z to the minus (M_b minus M_a), and z to the minus (M prime minus M_b), and exits as x[n minus M prime]. Taps between the blocks lead down to the labels x[n minus M_a] and x[n minus M_b].

Fig. 99 A delay line of length \(M'\) with taps at \(M_a\) and \(M_b\). The delays of the blocks between taps add up to \(M'\).#

The code below implements a multitap delay line, whose taps may vary over time and take fractional values. It returns one row of output per tap, like one channel of multichannel audio per tap:

# hide
import numpy as np
def multitap_delay(x: np.ndarray, taps: list[np.ndarray]) -> np.ndarray:
    """Read one delay line at several time-varying, fractional delays.
    Returns one row per tap, where row t is x[n - taps[t][n]]."""
    L = int(np.ceil(max(M.max() for M in taps))) + 2
    buffer = np.zeros(L)              # the delay line, as a circular buffer
    i = 0
    y = np.zeros((len(taps), len(x)))
    for n in range(len(x)):
        buffer[i] = x[n]              # write first, so that a delay of 0 reads x[n]
        for t, M in enumerate(taps):
            k = int(M[n])
            a = M[n] - k
            y[t, n] = (1 - a) * buffer[(i - k) % L] + a * buffer[(i - k - 1) % L]
        i = (i + 1) % L
    return y


impulse = np.zeros(8)
impulse[0] = 1.0
taps = [np.full(8, 1.0), np.full(8, 3.0), np.full(8, 6.0)]   # M_a = 1, M_b = 3, M' = 6
print(multitap_delay(impulse, taps))
[[0. 1. 0. 0. 0. 0. 0. 0.]
 [0. 0. 0. 1. 0. 0. 0. 0.]
 [0. 0. 0. 0. 0. 0. 1. 0.]]

Chorus#

When several people sing the same melody together, their voices never line up perfectly. Each singer’s timing, pitch, and loudness deviate slightly from the others, and these small deviations give a choir its rich, full sound. The chorus effect simulates this, by mixing a sound with several copies of itself, each delayed by a slowly varying amount:

A signal-flow diagram. The input x[n] splits into a dry path along the top, and three branches. Each branch passes through a delay block, labeled z to the minus M_1[n], M_2[n], and M_3[n], and then a triangular gain, labeled g_1, g_2, and g_3. The dry path and all three branches meet at a summing junction, whose output is y[n].

Fig. 100 A chorus with three voices. Each voice is a copy of the input with its own time-varying delay \(M_k[n]\) and gain \(g_k\).#

A chorus is like a flanger with several delayed copies. But a chorus uses longer delays, typically \(20\) to \(30\) ms, and no feedback. A delay that changes over time also shifts the pitch of its copy slightly, because the tap moves through the delay line slightly faster or slower than the samples arrive, much like the resampling of Chapter 11. So each voice of a chorus wavers slightly in pitch, like a real singer. The code below builds a three-voice chorus from our multitap delay line, and applies it to a guitar arpeggio:

# hide
import numpy as np
import pyquist as pq
# hide
def multitap_delay(x: np.ndarray, taps: list[np.ndarray]) -> np.ndarray:
    """Read one delay line at several time-varying, fractional delays.
    Returns one row per tap, where row t is x[n - taps[t][n]]."""
    L = int(np.ceil(max(M.max() for M in taps))) + 2
    buffer = np.zeros(L)              # the delay line, as a circular buffer
    i = 0
    y = np.zeros((len(taps), len(x)))
    for n in range(len(x)):
        buffer[i] = x[n]              # write first, so that a delay of 0 reads x[n]
        for t, M in enumerate(taps):
            k = int(M[n])
            a = M[n] - k
            y[t, n] = (1 - a) * buffer[(i - k) % L] + a * buffer[(i - k - 1) % L]
        i = (i + 1) % L
    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))
def chorus(x: np.ndarray, delays: list[float], depth: float, rates: list[float],
           g: float, f_s: int) -> np.ndarray:
    """The dry signal plus one delayed copy per voice, whose delay (in seconds)
    wobbles by up to `depth` seconds around its own base delay, at its own rate."""
    n = np.arange(len(x))
    taps = [f_s * (d + depth * np.sin(2 * np.pi * r * n / f_s)) for d, r in zip(delays, rates)]
    return x + g * multitap_delay(x, taps).sum(axis=0)


guitar = pq.Audio.from_file("./assets/guitar-arpeggio.wav")
x = guitar.samples[:, 0]
wet = chorus(x, delays=[0.020, 0.025, 0.030], depth=0.003, rates=[0.35, 0.5, 0.65],
             g=0.7, f_s=guitar.sample_rate)
w = wet_ratio(0.0)
pq.play(guitar, normalize=True)
pq.play(pq.Audio((1 - w) * x + w * wet, guitar.sample_rate), normalize=True)

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