Code Lab: Electronic Sound Synthesis

Electronic synthesizers create sound from scratch using mathematical operations on electrical signals -- or, in our case, on arrays of numbers. This lab walks through the three foundational synthesis techniques: additive synthesis (building timbres by summing sine waves), subtractive synthesis (carving timbres by filtering a harmonically rich source), and FM synthesis (creating complex spectra through frequency modulation). Along the way you will build a small but functional software synthesizer.

Prerequisites: numpy, matplotlib, scipy. Install with pip install numpy matplotlib scipy.

Global Setup

import numpy as np
import matplotlib.pyplot as plt
from scipy import signal as sig
from scipy.fft import fft, fftfreq

SAMPLE_RATE = 44100
DURATION = 2.0
N_SAMPLES = int(SAMPLE_RATE * DURATION)
t = np.linspace(0, DURATION, N_SAMPLES, endpoint=False)

def normalize(x):
    """Scale a signal to the range [-1, 1]."""
    peak = np.max(np.abs(x))
    return x / peak if peak > 0 else x

def plot_wave_and_spectrum(audio, title="", max_freq=5000):
    """Side-by-side time-domain (first 20 ms) and frequency-domain plots."""
    fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 4))
    fig.suptitle(title, fontsize=13, weight="bold")

    # Time domain
    n_show = int(0.02 * SAMPLE_RATE)
    ax1.plot(t[:n_show] * 1000, audio[:n_show], color="#2E86AB")
    ax1.set_xlabel("Time (ms)")
    ax1.set_ylabel("Amplitude")
    ax1.set_title("Waveform (first 20 ms)")
    ax1.grid(True, alpha=0.3)

    # Frequency domain
    n = len(audio)
    freqs = fftfreq(n, 1.0 / SAMPLE_RATE)[:n // 2]
    mags = 2.0 * np.abs(fft(audio))[:n // 2] / n
    mags_db = 20 * np.log10(mags + 1e-10)
    mask = freqs <= max_freq
    ax2.plot(freqs[mask], mags_db[mask], color="#E84855")
    ax2.set_xlabel("Frequency (Hz)")
    ax2.set_ylabel("Amplitude (dB)")
    ax2.set_title("Spectrum")
    ax2.grid(True, alpha=0.3)

    plt.tight_layout()
    plt.show()

1. Additive Synthesis

Additive synthesis is the most transparent method: you specify every partial (harmonic) individually. It is the direct application of Fourier's theorem -- any periodic sound can be built by summing sine waves.

def additive_synth(fundamental, partials, t):
    """
    Build a tone by summing sinusoidal partials.

    Parameters
    ----------
    fundamental : float -- base frequency in Hz
    partials : list of (harmonic_number, amplitude) tuples
    t : numpy array -- time axis

    Returns
    -------
    numpy array -- the synthesized waveform
    """
    wave = np.zeros_like(t)
    for n, amp in partials:
        wave += amp * np.sin(2 * np.pi * n * fundamental * t)
    return wave

# Flute-like: strong fundamental, rapidly decaying upper partials
flute_partials = [(1, 1.0), (2, 0.3), (3, 0.1), (4, 0.03)]
flute = normalize(additive_synth(440, flute_partials, t))
plot_wave_and_spectrum(flute, "Additive Synthesis: Flute-like Tone (440 Hz)")

# Oboe-like: many strong harmonics with a characteristic dip at the fundamental
oboe_partials = [(1, 0.6), (2, 1.0), (3, 0.8), (4, 0.7),
                 (5, 0.5), (6, 0.35), (7, 0.2), (8, 0.1)]
oboe = normalize(additive_synth(440, oboe_partials, t))
plot_wave_and_spectrum(oboe, "Additive Synthesis: Oboe-like Tone (440 Hz)")

The strength of additive synthesis is precision: you have independent control of every partial. The weakness is cost -- realistic timbres may need dozens of time-varying partials.

2. Subtractive Synthesis

Subtractive synthesis starts from a harmonically rich source (typically a sawtooth or pulse wave) and removes energy with filters. This is the architecture of classic analog synthesizers such as the Moog Minimoog and the Roland SH-101.

def make_sawtooth(freq, t, n_harmonics=30):
    """Band-limited sawtooth via Fourier series."""
    wave = np.zeros_like(t)
    for n in range(1, n_harmonics + 1):
        wave += ((-1) ** (n + 1)) * (1.0 / n) * np.sin(2 * np.pi * n * freq * t)
    return (2.0 / np.pi) * wave

def apply_lowpass(audio, cutoff_hz, order=4):
    """Butterworth low-pass filter at the given cutoff frequency."""
    nyq = SAMPLE_RATE / 2.0
    normalized_cutoff = np.clip(cutoff_hz / nyq, 1e-4, 0.9999)
    b, a = sig.butter(order, normalized_cutoff, btype="low")
    return sig.lfilter(b, a, audio)

def make_adsr(n_samples, attack=0.05, decay=0.1, sustain_level=0.7,
              release=0.3, sample_rate=SAMPLE_RATE):
    """Generate an ADSR amplitude envelope."""
    a = int(attack * sample_rate)
    d = int(decay * sample_rate)
    r = int(release * sample_rate)
    s = max(0, n_samples - a - d - r)
    envelope = np.concatenate([
        np.linspace(0, 1, a),
        np.linspace(1, sustain_level, d),
        np.full(s, sustain_level),
        np.linspace(sustain_level, 0, r),
    ])
    return envelope[:n_samples]

# Source: raw sawtooth at A3 (220 Hz)
saw = make_sawtooth(220, t)

# "Bright" patch: high cutoff, lets most harmonics through
bright = normalize(apply_lowpass(saw, cutoff_hz=3000))

# "Dark" patch: low cutoff, removes upper harmonics
dark = normalize(apply_lowpass(saw, cutoff_hz=600))

# Apply envelope to the dark patch for a complete synth voice
envelope = make_adsr(N_SAMPLES, attack=0.02, decay=0.15,
                     sustain_level=0.6, release=0.5)
voice = normalize(dark * envelope)

plot_wave_and_spectrum(bright, "Subtractive: Sawtooth through LP filter at 3000 Hz")
plot_wave_and_spectrum(dark,   "Subtractive: Sawtooth through LP filter at 600 Hz")
plot_wave_and_spectrum(voice,  "Subtractive: Complete voice (LP 600 Hz + ADSR)")

Lowering the filter cutoff removes upper harmonics, making the sound darker and more mellow. Sweeping the cutoff over time -- modulated by an envelope or an LFO -- is the defining gesture of analog subtractive synthesis.

3. FM Synthesis

Frequency Modulation (FM) synthesis, pioneered by John Chowning at Stanford in 1967 and commercialized by the Yamaha DX7 in 1983, generates complex spectra from just two oscillators. A modulator oscillator shifts the frequency of a carrier oscillator, producing sidebands whose number and strength depend on the modulation index.

def fm_synth(carrier_freq, mod_freq, mod_index, t):
    """
    Two-operator FM synthesis.

    Parameters
    ----------
    carrier_freq : float -- carrier frequency in Hz
    mod_freq : float -- modulator frequency in Hz
    mod_index : float -- modulation index (higher = more sidebands)
    t : numpy array -- time axis

    Returns
    -------
    numpy array -- the FM-synthesized waveform
    """
    modulator = mod_index * mod_freq * np.sin(2 * np.pi * mod_freq * t)
    carrier = np.sin(2 * np.pi * carrier_freq * t + modulator)
    return carrier

# "Electric piano" -- carrier:modulator ratio 1:1, moderate index
epiano = normalize(fm_synth(220, 220, mod_index=2.0, t=t))
plot_wave_and_spectrum(epiano, "FM Synthesis: Electric Piano (C:M = 1:1, index = 2)")

# "Bell" -- inharmonic ratio 1:1.4, high index
bell = normalize(fm_synth(440, 440 * 1.4, mod_index=5.0, t=t))
plot_wave_and_spectrum(bell, "FM Synthesis: Bell (C:M = 1:1.4, index = 5)")

# "Brass" -- ratio 1:1, high index with envelope-controlled index
index_env = np.linspace(6, 0.5, N_SAMPLES)  # index decays over time
brass = normalize(fm_synth(220, 220, mod_index=1.0, t=t)
                  * 0  # placeholder -- build it properly below)

# Proper time-varying FM for brass
mod_signal = index_env * 220 * np.sin(2 * np.pi * 220 * t)
brass = normalize(np.sin(2 * np.pi * 220 * t + mod_signal) * envelope)
plot_wave_and_spectrum(brass, "FM Synthesis: Brass (decaying mod index + ADSR)")

When the carrier-to-modulator ratio is a simple integer (1:1, 1:2, 2:3), the sidebands align with the harmonic series and the timbre sounds pitched and musical. Non-integer ratios (1:1.4, 1:sqrt(2)) produce inharmonic sidebands that resemble bells, gongs, and metallic percussion.

Putting It All Together: A Mini Synthesizer

def synth_note(freq, duration, method="subtractive",
               cutoff=1500, mod_ratio=1.0, mod_index=2.0,
               partials=None, sample_rate=SAMPLE_RATE):
    """
    Generate a single synthesizer note.

    method : "additive", "subtractive", or "fm"
    """
    n = int(duration * sample_rate)
    t_note = np.linspace(0, duration, n, endpoint=False)
    env = make_adsr(n, attack=0.01, decay=0.1, sustain_level=0.7, release=0.2)

    if method == "additive":
        if partials is None:
            partials = [(1, 1.0), (2, 0.5), (3, 0.25), (4, 0.12)]
        wave = additive_synth(freq, partials, t_note)
    elif method == "subtractive":
        wave = make_sawtooth(freq, t_note)
        wave = apply_lowpass(wave, cutoff)
    elif method == "fm":
        wave = fm_synth(freq, freq * mod_ratio, mod_index, t_note)
    else:
        raise ValueError(f"Unknown method: {method}")

    return normalize(wave * env)

# Compare the same pitch rendered by each method
for method in ["additive", "subtractive", "fm"]:
    note = synth_note(440, 1.0, method=method)
    plot_wave_and_spectrum(note, f"synth_note -- method='{method}', 440 Hz")

Try It Yourself

  1. Filter sweep animation. Modify the subtractive patch so that the low-pass cutoff frequency starts at 4000 Hz and drops to 200 Hz over the duration of the note (use np.linspace to create a cutoff envelope, then apply the filter in short overlapping blocks of ~1024 samples, each with its own cutoff). Plot the resulting spectrogram with plt.specgram. How does the sweeping cutoff change the perceived brightness over time?

  2. FM ratio explorer. Write a loop that generates FM tones with carrier = 440 Hz and modulator ratios of 1:1, 1:2, 1:3, 2:3, and 1:1.414. Use mod_index = 3 for all. Plot all five spectra on a single figure and label which ratios produce harmonic versus inharmonic timbres.

  3. Design a pad sound. Combine additive and FM synthesis: use additive synthesis for a warm fundamental with harmonics 1 through 5, then mix in a quiet FM component (carrier at the same frequency, ratio 1:7, low index) for shimmer. Apply a slow attack (0.5 s) and long release (1.0 s). Plot the result and describe the spectral characteristics that make a sound feel like a "pad."