9.9 Subtractive synthesis

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

9.9 Subtractive synthesis#

We opened this chapter by contrasting synthesis with processing, but filters can be a synthesis tool in their own right. Subtractive synthesis starts from a harmonically rich source (often noise or a buzzy waveform like a sawtooth) and carves away frequencies with filters to shape a timbre. It is the founding principle of the classic analog synthesizer, and the complement of the additive synthesis from Chapter 3: rather than building a sound up from sinusoids, we start with everything and subtract.

A subtractive-synthesis signal chain. On the left, a harmonically rich pulse wave and its spectrum of many strong harmonics. An arrow leads into a box labeled Filter (time-varying), whose magnitude response is a low-pass hump. An arrow leads out to the right, to the shaped output waveform and its resulting envelope, with the higher harmonics attenuated.

Fig. 60 Subtractive synthesis: start from a harmonically rich source (a pulse wave, with many strong harmonics), pass it through a filter (often time-varying), and the filter carves the spectrum into the desired shape. Compare this to additive synthesis, which instead builds up a spectrum from individual sinusoids.#

For these examples we use a small library of ready-made filter designs, rbj.py, which implements Robert Bristow-Johnson’s widely used “Audio EQ Cookbook” formulas [BJ16]. Each function returns the feedforward and feedback coefficients (\(b\) and \(a\)) of a second-order recursive filter (a biquad), ready to apply with SciPy’s lfilter.

Our first example filters white noise, whose spectrum is flat (equal energy at all frequencies) and therefore a perfect raw material. A low-pass version keeps only the lows for a soft “thump”, and a high-pass version keeps only the highs for a crisp “tick”. Arranging the two with a pq.Score produces a simple drum-like rhythm:

# hide
import numpy as np
import pyquist as pq
from scipy.signal import lfilter

F_S = 44100


# Two filter designs from the Audio EQ Cookbook (see code/rbj.py). Each returns
# the feedforward (b) and feedback (a) coefficients of a resonant biquad.
def lpf(f_c, Q, f_s):
    w0 = 2 * np.pi * f_c / f_s
    c, alpha = np.cos(w0), np.sin(w0) / (2 * Q)
    b = np.array([(1 - c) / 2, 1 - c, (1 - c) / 2])
    a = np.array([1 + alpha, -2 * c, 1 - alpha])
    return b / a[0], a / a[0]


def hpf(f_c, Q, f_s):
    w0 = 2 * np.pi * f_c / f_s
    c, alpha = np.cos(w0), np.sin(w0) / (2 * Q)
    b = np.array([(1 + c) / 2, -(1 + c), (1 + c) / 2])
    a = np.array([1 + alpha, -2 * c, 1 - alpha])
    return b / a[0], a / a[0]
# Subtractive synthesis: carve two percussion sounds out of white noise, then
# arrange them into a rhythm with a Score.

def noise_lo(duration, **kwargs):
    """A soft "thump": low-passed noise with a fast decay."""
    n = int(duration * F_S)
    x = np.random.uniform(-1, 1, n)
    b, a = lpf(f_c=180, Q=1.0, f_s=F_S)
    y = lfilter(b, a, x)
    env = np.exp(-np.linspace(0, 10, n))
    return pq.Audio((0.9 * y * env).astype(np.float32), F_S)


def noise_hi(duration, **kwargs):
    """A crisp "tick": high-passed noise with a very fast decay."""
    n = int(duration * F_S)
    x = np.random.uniform(-1, 1, n)
    b, a = hpf(f_c=6000, Q=0.8, f_s=F_S)
    y = lfilter(b, a, x)
    env = np.exp(-np.linspace(0, 45, n))
    return pq.Audio((0.9 * y * env).astype(np.float32), F_S)


def drum(voice, duration, **kwargs):
    """Dispatch each event to the right voice."""
    return noise_lo(duration) if voice == "lo" else noise_hi(duration)


# A 16-step pattern: low "thump" on the strong beats, high "tick" on every step.
beat = 0.22
kicks = {0, 4, 8, 12}
events = []
for step in range(16):
    events.append((step * beat, {"voice": "hi", "duration": 0.12}))
    if step in kicks:
        events.append((step * beat, {"voice": "lo", "duration": 0.35}))

rhythm = pq.Score(events)
pq.play(rhythm.render(drum))

Our second example is the sound most associated with subtractive synthesis: a resonant filter sweep. We start with a bright sawtooth-like tone and pass it through a resonant low-pass filter (one with a pronounced peak at its cutoff), then move the cutoff frequency over time. As the cutoff sweeps up and down, it emphasizes different harmonics in turn.

# hide
import numpy as np
import pyquist as pq
from scipy.signal import lfilter

F_S = 44100


def lpf(f_c, Q, f_s):
    """Resonant low-pass biquad (Audio EQ Cookbook). High Q gives a strong
    peak at the cutoff frequency."""
    w0 = 2 * np.pi * f_c / f_s
    c, alpha = np.cos(w0), np.sin(w0) / (2 * Q)
    b = np.array([(1 - c) / 2, 1 - c, (1 - c) / 2])
    a = np.array([1 + alpha, -2 * c, 1 - alpha])
    return b / a[0], a / a[0]


def sawtooth(f0, duration, num_harmonics=40):
    """A band-limited sawtooth: a sum of harmonics with 1/k amplitudes."""
    t = np.arange(int(duration * F_S)) / F_S
    x = sum((1 / k) * np.sin(2 * np.pi * k * f0 * t) for k in range(1, num_harmonics + 1))
    return x / np.max(np.abs(x))
# The classic subtractive-synthesis sound: a resonant low-pass filter whose
# cutoff sweeps over time, applied to a bright sawtooth tone. We process the
# signal block by block, redesigning the filter with a new cutoff each block.
x = sawtooth(f0=110, duration=4.0)

print("Dry sawtooth (before filtering):")
pq.play(pq.Audio(x, F_S))

block = 512
y = np.zeros_like(x)
state = np.zeros(2)                    # biquad filter memory, carried between blocks
for i in range(0, len(x), block):
    lfo = 0.5 * (1 + np.sin(2 * np.pi * 0.4 * i / F_S))   # slow 0..1 oscillation
    cutoff = 150 * (4500 / 150) ** lfo                    # sweep 150 Hz -> 4500 Hz
    b, a = lpf(f_c=cutoff, Q=6.0, f_s=F_S)                # Q=6 gives an audible resonance
    y[i:i + block], state = lfilter(b, a, x[i:i + block], zi=state)

y = y / np.max(np.abs(y))             # normalize (resonance can boost the level)
print("Filtered sweep:")
pq.play(pq.Audio(y, F_S))
Dry sawtooth (before filtering):
Filtered sweep: