An interactive primer · every figure drawn by the code it describes
Signal
Path
A synthesizer is a function that gets handed an empty buffer a few thousand times a second and has to fill it with numbers. Everything else — oscillators, filters, envelopes, the whole vocabulary — is a way of deciding what those numbers should be. This book builds that function one piece at a time, and lets you hear each piece the moment it exists.
Hero · Pure tone
One partial · the smallest sound there isSound as Numbers
Before any synthesis, one conversion: something you can hear becomes a list of numbers. Every limitation you will fight later is already hiding in that step.
This chapter is written so you can start from nothing. Every symbol is explained before it is used, there is a table of notation below, and a glossary at the end. Nothing here requires anything beyond multiplying and dividing — the frightening-looking formulas are all shorthand for pictures, and you will be shown the picture first.
Start with the wiggle
Sound is air being squashed and stretched. A speaker cone pushes forward, crowding the air in front of it; it pulls back, thinning that air out. Do that fast enough and the ripple reaches your eardrum, which wiggles in the same pattern, and you hear something.
Two things about that wiggle matter most:
- How fast it wiggles decides the pitch. Faster is higher.
- How far it wiggles decides the loudness. Bigger is louder.
A third thing — the exact shape of the wiggle — decides why a violin and a flute playing the same note still sound like different instruments. That one takes until Chapter 2.
The flipbook
A film is not moving. It is a stack of still photographs shown twenty-four times a second, fast enough that your eye stops noticing the gaps. Digital audio works the same way, only far faster: instead of photographs, we take measurements of the air pressure, about 48,000 times every second.
One measurement is called a sample. That is the whole idea. A sample is not a tiny piece of sound and it has no length — it is one reading, like one frame of the film. String enough of them together and play them back at the same rate, and you have sound.
What a sample actually is
Inside a synthesizer there is no microphone and nothing to measure. We invent the numbers directly. That is the entire job: decide what number comes next, 48,000 times a second.
By convention each number runs from -1.0 to +1.0. Zero means the air is
undisturbed — silence. Positive means pushed, negative means pulled. +1.0 is as loud
as the system can go, which engineers call full scale.
We keep these as decimals rather than whole numbers. The reason is practical: when you add several sounds together the total can briefly overshoot 1.0, and a decimal simply carries the larger value along until a later stage brings it back down. Whole numbers would wrap around and turn the overshoot into a loud crackle. The conversion to whole numbers happens once, at the very end, inside the sound card.
Every symbol used in this chapter, in plain words:
| Symbol | Said aloud | What it means |
|---|---|---|
π | "pie" | A fixed number, about 3.14159. It just is that value, the way 7 is 7. |
2π | "two pie" | One complete turn around a circle. Maths measures angles this way instead of in degrees; 2π means the same thing as 360°. |
sin(…) | "sine of…" | A button on a calculator. Give it an angle, it gives back the height of a point on a circle. The next section shows exactly what that means. |
f | "eff" | Frequency — how many times per second something repeats. |
fs | "eff ess" | Sample rate — how many measurements we take per second. Usually 48,000. |
Hz | "hertz" | Just a unit meaning "times per second". 440 Hz is 440 wiggles per second. |
2ⁿ | "two to the n" | 2 multiplied by itself n times. 2³ = 2×2×2 = 8. |
log₁₀(x) | "log of x" | The reverse question: "10 to what power gives me x?" Since 10×10 = 100, log₁₀(100) = 2. |
≈ | "about" | Approximately equal. |
Where the sine wave comes from
One shape of wiggle matters more than every other, because Chapter 2 will show that all the others are built out of it. It has an intimidating name — the sine wave — and a completely ordinary origin.
Picture a dot painted on the rim of a bicycle wheel, and spin the wheel at a steady speed. Now ignore everything except how high the dot is. It rises to the top, falls through the middle, drops to the bottom, comes back. Plot that height against time and you have drawn a sine wave. That is all a sine wave is: going round in circles, viewed from the side.
A · The circle and the wave
Same motion, two viewsNow the formula, which says nothing the picture did not:
Read it left to right, one piece at a time:
| Piece | In the picture | What you hear |
|---|---|---|
x(t) | The height of the dot at time t | The sample value we store |
A | The radius of the circle | Loudness |
f | Laps per second | Pitch |
t | How long we have been spinning | — |
2π f t | The angle turned through so far | — |
φ ("phi") | Where the dot started | Nothing, alone |
So 2π f t is just "one full turn, multiplied by how many turns we have done". Multiply
the sine's answer by A to set the size, and you have your number. The module at the
top of the page computes exactly this, 48,000 times a second.
How many measurements is enough?
Back to the flipbook. If you photograph a spinning wheel too rarely, something strange happens — you have seen it in films, where wagon wheels appear to spin backwards. The camera catches the wheel a fraction before it completes each turn, so spoke by spoke it seems to creep the wrong way. Nothing is wrong with the wheel or the camera. There simply is not enough information in the photographs to tell the true motion from a slower, backwards one.
Audio has exactly this problem, and there is a precise answer to it. You need more than two measurements per wiggle. Two is the bare minimum to catch a peak and a trough; fewer and the wiggle can masquerade as a slower one. Since we take 48,000 measurements per second, the fastest wiggle we can capture is just under 24,000 per second.
Half the sample rate is called the Nyquist frequency, after Harry Nyquist, and the rule is the Nyquist–Shannon sampling theorem. It is not a quality-of-equipment problem that better hardware will fix. It is arithmetic. Conveniently, human hearing tops out around 20,000 Hz anyway, which is why 44,100 and 48,000 were chosen.
Now the important part. If something faster than Nyquist does sneak in, it does not get quietly dropped and it does not sound like distortion. It comes out as a different, lower note — the wagon wheel, spinning backwards. A 30,000 Hz tone recorded at 48,000 Hz emerges as 48,000 − 30,000 = 18,000 Hz, and nothing downstream can tell it from a tone that was genuinely there. This is called aliasing, and it is the main antagonist of Chapter 3.
B · The fold
Visual only · no audioHow finely do we measure?
Sample rate is how often we measure. There is a second question: how precisely.
Think of measuring people's heights with a ruler marked only in whole centimetres. Everyone gets rounded to the nearest mark, so every measurement carries a small error. Use a ruler marked in millimetres and the errors shrink. Bit depth is how many marks our ruler has. With b bits there are 2b marks — 16 bits gives 65,536 of them.
Those rounding errors do not disappear; they add a faint hiss underneath the music, called the noise floor. Each extra bit halves the error, which works out to roughly 6 dB of extra range per bit — a unit explained in the next section. 16 bits gives about 96 dB of range, which is why CDs sound clean.
Because we work in decimals inside the synth rather than whole numbers, this stops being something you think about until the very end. Which is exactly why we do it that way.
Loudness, and why decibels exist
Here is something odd about hearing. Going from an amplitude of 0.1 to 0.2 feels like the same size of jump as going from 0.2 to 0.4 — even though the first added 0.1 and the second added 0.2. Your ears respond to ratios, not differences. Equal multiplications feel like equal steps.
So it is unhelpful to describe loudness by adding and subtracting. We want a scale where "multiply by 2" is always the same distance, no matter where you start. That scale is the decibel, and the maths that produces it is the logarithm:
You will rarely compute this by hand. What you need is the feel of it:
| Multiply amplitude by… | …and you have changed it by | Sounds like |
|---|---|---|
| 2 | +6 dB | Clearly louder |
| 1 | 0 dB | No change |
| 0.5 | −6 dB | Clearly quieter |
| 0.1 | −20 dB | Much quieter, still easily heard |
| 0.001 | −60 dB | Nearly silent |
Note that the steps add up: −6 dB twice is −12 dB, which is a quarter of the amplitude. That is the whole convenience of the scale.
One consequence to file away for Chapter 7. Two copies of the same signal added together give exactly double the amplitude, so +6 dB. But two different signals of the same level — two different notes, say — only give about +3 dB, because their peaks do not line up. This is why eight voices playing at once are not eight times as loud, and why you must leave headroom when a polyphonic synth adds voices together.
Pitch, and why the keyboard is not evenly spaced
Pitch works on ratios too. Play a note, then play one at exactly double the frequency, and it sounds like "the same note, higher" — that is an octave.
| Note | Frequency | Relationship |
|---|---|---|
| A1 | 55 Hz | — |
| A2 | 110 Hz | double A1 |
| A3 | 220 Hz | double A2 |
| A4 | 440 Hz | double A3 — the standard tuning reference |
| A5 | 880 Hz | double A4 |
Notice the gaps get bigger as you go up: A1 to A2 spans 55 Hz, but A4 to A5 spans 440 Hz. Same musical distance, wildly different distance in hertz. Western music splits each octave into twelve steps called semitones, and to make every step feel equal, each one must multiply by the same amount. Twelve equal multiplications must reach 2, so each semitone multiplies by 21/12 ≈ 1.0595.
Which gives the formula that converts a piano key number into a frequency. Middle A is key 69:
In words: start at 440 Hz, and for every key above 69, multiply by the semitone ratio once more.
Anything to do with pitch should be expressed as a multiplication, never an addition. Detune is measured in cents — hundredths of a semitone — not in hertz. Filter brightness is adjusted in octaves, not hertz. Vibrato depth is a ratio.
The reason is simple: "add 20 Hz" is a huge wobble on a bass note and inaudible on a high one, whereas "raise by 20 cents" sounds identical everywhere on the keyboard. Get this right at the start and every effect you build later will behave consistently across the whole range.
Everything above, as a program. Use the Rust / Python switch on any listing in this book — it changes every listing on the page, so pick whichever you read more comfortably. Python is the gentler starting point and needs nothing installed for this first one; Rust is what you would ship.
// Cargo.toml: hound = "3"
const FS: f32 = 48_000.0; // measurements per second
// piano key number -> frequency, the formula from above
fn midi_to_freq(n: f32) -> f32 { 440.0 * f32::powf(2.0, (n - 69.0) / 12.0) }
fn main() -> Result<(), Box<dyn std::error::Error>> {
let spec = hound::WavSpec {
channels: 1, sample_rate: FS as u32,
bits_per_sample: 32, sample_format: hound::SampleFormat::Float,
};
let mut w = hound::WavWriter::create("tone.wav", spec)?;
let freq = midi_to_freq(69.0); // A4, 440 Hz
let mut phase: f32 = 0.0; // how far round the circle we are, 0 to 1
let inc = freq / FS; // fraction of a lap per measurement
for _ in 0..(FS as usize * 2) { // two seconds' worth
// TAU is one full turn. phase 0..1 becomes an angle 0..one turn.
let y = (std::f32::consts::TAU * phase).sin() * 0.3;
w.write_sample(y)?;
phase += inc;
if phase >= 1.0 { phase -= 1.0; } // completed a lap: start the next
}
w.finalize()?;
Ok(())
}
# Standard library only — nothing to install.
import math, wave, struct
FS = 48_000 # measurements per second
# piano key number -> frequency, the formula from above
def midi_to_freq(n):
return 440.0 * 2 ** ((n - 69) / 12)
freq = midi_to_freq(69) # A4, 440 Hz
phase = 0.0 # how far round the circle, 0 to 1
inc = freq / FS # fraction of a lap per measurement
frames = bytearray()
for _ in range(FS * 2): # two seconds' worth
# math.tau is one full turn. phase 0..1 becomes an angle.
y = math.sin(math.tau * phase) * 0.3
frames += struct.pack("<h", int(y * 32767))
phase += inc
if phase >= 1.0:
phase -= 1.0 # completed a lap: start the next
with wave.open("tone.wav", "wb") as w:
w.setnchannels(1)
w.setsampwidth(2) # 16-bit whole numbers on the way out
w.setframerate(FS)
w.writeframes(bytes(frames))
It looks simpler to write sin(TAU * freq * t) with t counting
seconds. Do not. After a few minutes t has grown so large that the computer can no
longer tell one measurement from the next, and the tone develops a rasp. Worse, changing
freq makes the angle jump, which you hear as a click. Counting laps as we go avoids
both. Chapter 1 lets you make that click deliberately, so you know what it sounds like.
Words from this chapter
| Word | Meaning |
|---|---|
| Sample | One measurement of the sound. One frame of the flipbook. |
Sample rate (fs) | How many samples per second. Usually 48,000. |
| Nyquist | Half the sample rate. The fastest wiggle that can be captured. |
| Aliasing | A too-fast wiggle coming out as a wrong, slower one. The wagon wheel. |
| Amplitude | How big the wiggle is. The radius of the circle. Loudness. |
| Frequency | How fast it wiggles, in Hz. Laps per second. Pitch. |
| Phase | Where in the cycle you are. The dot's starting angle. |
| Full scale | An amplitude of 1.0 — the loudest the system can represent. |
| Bit depth | How finely each measurement is recorded. The ruler's marks. |
| Decibel (dB) | A loudness scale where multiplying becomes adding. |
| Cent | One hundredth of a semitone. The unit for small tuning changes. |
| Sine wave | The wiggle you get from going round a circle at a steady speed. |
If you are new to this: you can explain why films show wagon wheels spinning backwards, and why that is the same problem as recording a very high note badly.
Going further: you can say why a 25,000 Hz tone recorded at 44,100 Hz comes out at 19,100 Hz, and why "raise the filter by 500 Hz" is a badly posed instruction.
The Callback
A synthesizer is not a program that produces sound. It is a function that the sound card calls, on a deadline it does not negotiate.
The contract
The audio device runs on its own high-priority thread. Every so often it hands you a buffer and says: fill this, now. A block of 128 samples at 48 kHz is 2.67 ms of audio, which is also your entire compute budget. Miss it and you do not get a slow frame like in graphics — you get a click, because the driver plays whatever was in the buffer, which is silence or stale data.
| Block size | At 48 kHz | Feels like |
|---|---|---|
| 64 | 1.33 ms | Playable on a controller; unforgiving of any hitch |
| 128 | 2.67 ms | The usual default. Good target. |
| 512 | 10.7 ms | Noticeable lag when playing; fine for playback |
| 2048 | 42.7 ms | Unplayable, but very hard to underrun |
Note that latency is at least two blocks, not one: you are filling block n + 1 while block n plays. Add the driver's own buffering and the round trip is typically three to five blocks.
The phase accumulator
The core primitive of every oscillator on this page is four lines long. Keep a phase in
[0, 1). Add an increment each sample. Wrap. Map the phase to a waveform.
Normalising phase to [0,1) rather than [0, 2π) is not cosmetic. The wrap
becomes a subtraction of exactly 1.0, which is exact in floating point; wrapping at 2π accumulates
error. It also makes Δφ directly meaningful: it is the fraction of a cycle traversed
per sample, and it is the parameter PolyBLEP needs in Chapter 3.
C · Phase accumulator
The ramp, and what it becomesThe rules inside the callback
The audio thread must never block. Not for a lock, not for a page fault, not for the allocator, not for a log line. If it blocks, the deadline passes and you hear it.
- No heap allocation. Preallocate every buffer, voice, and delay line before
the stream starts. In Rust this means no
Vec::push, noBox::new, noString, no collecting iterators. - No locks. A
Mutexheld by the UI thread during a GC pause or a scheduler preemption will stall the audio thread. Use lock-free single-producer queues (ringbuf) or atomics for scalar parameters. - No I/O, no syscalls, no
println!. - No unbounded work. Worst case matters, not average. Eight voices must all fit in the budget even when every one of them is sounding.
- Watch for denormals. Filter and reverb tails decay toward zero and enter subnormal float territory, where some CPUs take a large penalty per operation. Enable flush-to-zero, or add a tiny DC offset to the signal.
The demos on this page obey these rules. Each one runs in an AudioWorkletProcessor,
which is the browser's audio thread; parameters arrive as messages and are copied into plain
fields, and the analysis frames sent back to the page are written into a buffer allocated once
at construction.
// cpal = "0.15", ringbuf = "0.3"
struct Osc { phase: f32, inc: f32 }
impl Osc {
fn set_freq(&mut self, f: f32, fs: f32) { self.inc = f / fs; }
fn next(&mut self) -> f32 {
let p = self.phase;
self.phase += self.inc;
if self.phase >= 1.0 { self.phase -= 1.0; }
(std::f32::consts::TAU * p).sin()
}
}
let device = cpal::default_host().default_output_device().unwrap();
let config = device.default_output_config()?.config();
let fs = config.sample_rate.0 as f32;
let ch = config.channels as usize;
let mut osc = Osc { phase: 0.0, inc: 0.0 };
osc.set_freq(220.0, fs);
let stream = device.build_output_stream(
&config,
move |out: &mut [f32], _| {
// This closure IS the audio thread. Allocate nothing here.
for frame in out.chunks_mut(ch) {
let y = osc.next() * 0.25;
for s in frame.iter_mut() { *s = y; }
}
},
|err| eprintln!("stream error: {err}"),
None,
)?;
stream.play()?;
# pip install sounddevice numpy
import math
import numpy as np
import sounddevice as sd
FS = 48_000
class Osc:
def __init__(self):
self.phase = 0.0
self.inc = 0.0
def set_freq(self, f):
self.inc = f / FS
def block(self, n):
# A whole block of phases at once. Same accumulator as the
# Rust version, just computed n steps ahead in one go.
ph = (self.phase + self.inc * np.arange(n)) % 1.0
self.phase = (self.phase + self.inc * n) % 1.0
return np.sin(math.tau * ph)
osc = Osc()
osc.set_freq(220.0)
def callback(outdata, frames, time, status):
# This function IS the audio thread. Allocate as little as you can.
outdata[:, 0] = osc.block(frames) * 0.25
with sd.OutputStream(channels=1, samplerate=FS,
blocksize=128, callback=callback):
input("playing — press Enter to stop")
The Python listing works, and it is a fine way to learn — but Python cannot meet a hard real-time deadline the way Rust can. The garbage collector can pause you at any moment, the global interpreter lock can hold you up, and allocation happens constantly whether you asked for it or not. You will hear occasional clicks under load, and no amount of tuning removes them entirely.
The practical approach is to write your DSP with NumPy over whole blocks rather than sample by sample, keep the callback short, and treat the odd dropout as the price of a quick experiment. When you want an instrument rather than an experiment, port it — which is much easier than starting in Rust, because by then you will know exactly what you are building.
Sending a new gain from the UI and assigning it directly means the signal jumps between two blocks. That jump is a click, exactly like the phase reset above. Chapter 7 fixes it properly with a one-pole smoother; for now, know that any parameter multiplying the signal needs slewing, and any parameter that only sets a frequency usually does not.
You have a tone playing from a real audio callback that you can retune live without clicks, and you can state your compute budget in milliseconds.
Fourier
Timbre is spectrum. Once you can see a sound as a set of partials, most of synthesis becomes a question of which partials to make and which to remove.
The claim
Any periodic signal, however jagged, is a sum of sinusoids at integer multiples of its fundamental frequency. Those multiples are its harmonics or partials. The physical reason is that a bounded vibrating system — a string, an air column — only supports standing waves whose ends fit the boundary, and those fit at integer divisions of the length.
The three canonical synthesizer waveforms have famously simple recipes:
| Wave | Harmonics present | Amplitude of harmonic n | Character |
|---|---|---|---|
| Sawtooth | all | 1/n | Brightest. Brass, strings. |
| Square | odd only | 1/n | Hollow, woody. Clarinet-ish. |
| Triangle | odd only | 1/n² | Nearly a sine. Soft flutes. |
| Sine | the first, alone | — | No timbre to speak of. |
The 1/n² rolloff of the triangle is 12 dB per octave of harmonic number against the
sawtooth's 6, which is why a triangle sounds so much darker even though both contain infinitely
many partials.
D · Additive workbench
16 sine partials · build the waveform by handFrom series to transform
The Fourier series describes periodic signals. Real sounds are not periodic forever, so we chop them into short frames and analyse each one with the Discrete Fourier Transform. The DFT correlates the frame against a complex exponential at every candidate frequency:
Euler's formula, e^(jθ) = cos θ + j sin θ, is why a single complex multiply captures
both the in-phase and quadrature components at once — which is to say, both how much of that
frequency is present and at what phase. The magnitude |X[k]| is what we plot;
arg(X[k]) is the phase we usually discard.
The FFT is the same transform computed in O(N log N) instead of O(N²)
by exploiting the symmetry of those exponentials. Three practical consequences:
- Bin spacing is
fs/N. At 48 kHz with N = 2048, each bin is 23.4 Hz wide. You cannot resolve two partials closer than that; you need a longer frame, which means worse time resolution. That trade is not a limitation of the algorithm, it is a property of the universe. - Only the first
N/2bins are meaningful. The rest are the conjugate mirror — the same Nyquist limit, seen from the other side. - Window your frame. Chopping a signal abruptly is multiplying by a rectangle, which convolves the spectrum with a very leaky kernel; a single pure sine smears across dozens of bins. A Hann window fixes most of it. Every spectrum on this page is Hann-windowed.
Additive synthesis, and why nobody uses it alone
You now have a complete synthesis method. Want a sawtooth? Sum sines at 1/n up to
Nyquist. It is exact, it never aliases, and it is the ground truth against which Chapter 3's
cheaper methods are judged.
It is also brutally expensive. A 55 Hz sawtooth at 48 kHz needs 436 partials — 436 sine evaluations per sample, per voice. Eight voices is 3,500 sines every 20 microseconds. That is why the rest of this book is, in a sense, a catalogue of ways to fake it.
// rustfft = "6"
use rustfft::{FftPlanner, num_complex::Complex};
fn spectrum_db(x: &[f32], fs: f32) -> Vec<(f32, f32)> {
let n = x.len();
let mut buf: Vec<Complex<f32>> = x.iter().enumerate()
.map(|(i, &s)| {
// Hann window — without this a pure sine leaks everywhere
let w = 0.5 - 0.5 * (std::f32::consts::TAU * i as f32 / (n as f32 - 1.0)).cos();
Complex::new(s * w, 0.0)
}).collect();
FftPlanner::new().plan_fft_forward(n).process(&mut buf);
buf.iter().take(n / 2).enumerate().map(|(k, c)| {
let mag = c.norm() / (n as f32 / 4.0);
(k as f32 * fs / n as f32, 20.0 * mag.max(1e-9).log10())
}).collect()
}
# pip install numpy
import numpy as np
def spectrum_db(x, fs):
"""Magnitude spectrum in dB. Returns (frequencies, levels)."""
n = len(x)
# Hann window — without this a pure sine leaks everywhere
w = np.hanning(n)
# rfft gives only the first half: the rest is a mirror image,
# which is the Nyquist limit seen from the other side.
mag = np.abs(np.fft.rfft(x * w)) / (n / 4)
freqs = np.fft.rfftfreq(n, 1 / fs)
return freqs, 20 * np.log10(np.maximum(mag, 1e-9))
# Check your own sawtooth: the ten loudest bins should land on
# f0, 2*f0, 3*f0 ... with roughly 6 dB between octaves of harmonic number.
freqs, levels = spectrum_db(saw_samples, 48_000)
for i in np.argsort(levels)[-10:][::-1]:
print(f"{freqs[i]:8.1f} Hz {levels[i]:6.1f} dB")
Render a sawtooth with your Chapter 0 code, run the FFT over it, and print the ten loudest
bins. If they are not at f0, 2f0, 3f0… with roughly 6 dB drops between successive
octaves of harmonic number, something is wrong — and finding out what is worth more than reading
the next chapter.
You can look at a magnitude spectrum and predict what it will sound like, and you can explain why a longer analysis window gives sharper frequency resolution and blurrier timing.
Band-Limiting
The most obvious way to make a sawtooth is also the wrong one. This is the chapter where naive code starts to sound like broken code, and where most self-taught synth builders quietly give up.
Why the obvious thing fails
A sawtooth is a ramp from −1 to 1 that resets instantly. You already have a phase in
[0,1), so y = 2·phase − 1 and you are done. Play it at 55 Hz and it
sounds fine. Play it at 2 kHz and it sounds like a mistake.
The reason is the reset. An instantaneous jump has energy at every frequency — that is what Fig 2.2 was showing. An ideal sawtooth contains harmonics forever, and at 2 kHz only eleven of them fit below Nyquist. The rest do not vanish. They fold, per Fig 0.2, landing at frequencies that bear no harmonic relationship to the note you played.
That last part is what makes it so ugly. Distortion that adds harmonics sounds like distortion. Aliasing adds inharmonic partials, and worse, they move the wrong way: play a chromatic run upward and the aliased components descend, producing a shimmering metallic counter-melody that follows you around. Once you have heard it you will never mistake it for anything else.
E · Four ways to make a sawtooth
Same pitch · same waveform · very different spectraDotted verticals mark the true harmonics of f0. Anything between them is aliasing.
PolyBLEP
We cannot remove aliasing after it happens; the folded partials are indistinguishable from real ones. We have to avoid generating them. The observation behind BLEP — band-limited step — is that a naive waveform differs from its band-limited ideal only in a tiny neighbourhood of each discontinuity. So compute the naive waveform, then add a correction shaped like the difference.
A properly band-limited step is the integral of a sinc, which is expensive and infinite in extent. PolyBLEP replaces it with a two-piece quadratic covering just one sample either side of the jump. It is not exact — it typically knocks 40–60 dB off the aliasing rather than eliminating it — but it costs a handful of arithmetic operations, which is why nearly every virtual-analogue oscillator shipping today uses it.
/// t = phase distance from the discontinuity, in [0,1)
/// dt = phase increment per sample = freq / sample_rate
fn poly_blep(mut t: f32, dt: f32) -> f32 {
if t < dt {
t /= dt;
t + t - t * t - 1.0 // just after the jump
} else if t > 1.0 - dt {
t = (t - 1.0) / dt;
t * t + t + t + 1.0 // just before the jump
} else {
0.0 // nowhere near a jump: leave it alone
}
}
fn saw(&mut self) -> f32 {
let (p, dt) = (self.phase, self.inc);
self.advance();
(2.0 * p - 1.0) - poly_blep(p, dt)
}
fn square(&mut self, pw: f32) -> f32 {
let (p, dt) = (self.phase, self.inc);
self.advance();
let mut y = if p < pw { 1.0 } else { -1.0 };
y += poly_blep(p, dt); // rising edge at phase 0
y -= poly_blep((p - pw + 1.0) % 1.0, dt); // falling edge at phase pw
y
}
def poly_blep(t, dt):
"""t = phase distance from the discontinuity, in [0,1)
dt = phase increment per sample = freq / sample_rate"""
if t < dt:
t /= dt
return t + t - t * t - 1.0 # just after the jump
if t > 1.0 - dt:
t = (t - 1.0) / dt
return t * t + t + t + 1.0 # just before the jump
return 0.0 # nowhere near a jump
class Osc:
def saw(self):
p, dt = self.phase, self.inc
self.advance()
return (2.0 * p - 1.0) - poly_blep(p, dt)
def square(self, pw=0.5):
p, dt = self.phase, self.inc
self.advance()
y = 1.0 if p < pw else -1.0
y += poly_blep(p, dt) # rising edge at phase 0
y -= poly_blep((p - pw) % 1.0, dt) # falling edge at phase pw
return y
A square has two discontinuities per cycle, so it needs two corrections with opposite signs. A triangle has no discontinuity in value at all — only in slope — so it needs a different correction (PolyBLAMP), or, more cheaply, you integrate a band-limited square with a leaky one-pole. That is what this book's triangle does.
Wavetables
The other mainstream answer trades memory for arithmetic. Precompute one cycle of the waveform into a table by additive synthesis — expensive, but done once at startup — and then just read the table back at the right rate.
The catch is that a table built with 400 harmonics is only safe for low notes. So you build one table per octave, each containing only the harmonics that still fit under Nyquist at the top of that octave, and select the appropriate table by pitch. Eleven tables of 2048 floats is 88 KB — nothing — and playback is one lookup and one interpolation regardless of how rich the waveform is.
| Method | Cost per sample | Aliasing | Flexible shape? |
|---|---|---|---|
| Naive | 1 multiply | Catastrophic | Yes |
| PolyBLEP | ~10 ops | −40 to −60 dB | Yes — PWM and sync work naturally |
| Wavetable | 1 lookup + lerp | Excellent | Only precomputed shapes |
| Additive | N sines | None | Total, per-partial |
Linear interpolation between table entries is itself a lowpass filter with a small amount of distortion — the error shows up as a noise floor around −60 to −70 dB with a 2048-point table. Cubic (Catmull-Rom) interpolation buys you roughly another 20 dB for about three times the arithmetic. The same choice reappears in Chapter 8, where a chorus reads a delay line at a fractional position.
Two things PolyBLEP gives you for free
Pulse-width modulation. Move the falling edge of a square away from the halfway point and the spectrum changes dramatically — at 50% the even harmonics cancel completely, and as the width narrows they return. Sweep it slowly with an LFO and you get the thick, breathing texture that defines a great deal of 1980s synth pop. Because PolyBLEP corrects each edge independently, this costs nothing extra.
Hard sync. Reset oscillator B's phase every time oscillator A completes a cycle. B's pitch is then locked to A, but its timbre is set by B's own frequency, which you can sweep freely. The forced reset is a discontinuity, so it needs its own BLEP correction — and it produces the tearing, aggressive sound that made the sync lead a genre unto itself.
Your oscillator plays a four-octave chromatic run and the top octave sounds like the bottom octave, only higher. You can point at a spectrum and say which partials are real.
Envelopes
A continuous tone is not a note. What turns one into the other is a shape in time — and the ear cares more about the first 50 milliseconds of that shape than about almost anything else in the signal.
Why attack transients carry identity
Record a piano note and a trumpet note at the same pitch and loudness, then splice off the first tenth of a second of each. Listeners struggle to tell them apart. The steady-state spectra of many instruments are surprisingly similar; the attack — how fast, in what order the partials arrive, with what noise — is where the instrument lives. This is why a synth patch with a lazy envelope sounds generic no matter how good the oscillator.
ADSR
The standard shape has four parameters and, importantly, is driven by a gate: a boolean that is true while the key is held.
- Attack — time from 0 to full, starting at note-on.
- Decay — time from full down to the sustain level.
- Sustain — a level, not a time. Held for as long as the gate is true.
- Release — time from wherever the envelope is when the gate falls, down to zero.
The asymmetry catches everyone once: three times and one level. It follows from the gate being the thing in charge. Sustain has no duration because its duration is the player's business.
Why the curve has to be exponential
Physical systems lose energy in proportion to how much energy they have, which produces exponential decay. Our perception of loudness is also roughly logarithmic. A linear ramp down to zero therefore sounds like it hangs at a constant loudness and then suddenly disappears at the end; an exponential decay sounds like a decay.
The cheap and idiomatic way to get one is a one-pole filter aimed at a target:
For decay and release, target is the destination. For attack there is a trick: aim at something above 1.0 — say 1.25 — and stop when you cross 1.0. Aim exactly at 1.0 and you never arrive, because an exponential approaches asymptotically. The overshoot target also gives the attack a pleasantly convex shape rather than a decelerating one.
F · Envelope generator
Hold the gate · watch the shape · hear the differenceVelocity, and what to do with it
MIDI note-on carries a velocity from 1 to 127. Mapping it straight to amplitude is the obvious move and the least convincing one, because on a real instrument playing harder does not merely make the sound louder — it makes it brighter, because more energy goes into the higher modes. So route velocity to amplitude and to filter cutoff, which is exactly what the synth in Chapter 7 does.
Two details worth getting right. First, map velocity through a curve rather than linearly;
(v/127)² or similar feels far more natural on most controllers. Second, a note-on
with velocity 0 is defined by the MIDI spec to mean note-off, and a surprising amount of hardware
relies on that. Handle it or you will hang notes.
// midir = "0.9", ringbuf = "0.3"
enum Ev { On { note: u8, vel: u8 }, Off { note: u8 }, Cc { num: u8, val: u8 } }
let rb = ringbuf::HeapRb::<Ev>::new(256);
let (mut tx, mut rx) = rb.split();
// --- MIDI thread: parse and push. Never touches the synth. ---
let _conn = midi_in.connect(&port, "in", move |_stamp, msg, _| {
if msg.len() < 3 { return; }
let ev = match msg[0] & 0xF0 {
0x90 if msg[2] > 0 => Ev::On { note: msg[1], vel: msg[2] },
0x90 | 0x80 => Ev::Off { note: msg[1] }, // vel 0 == note off
0xB0 => Ev::Cc { num: msg[1], val: msg[2] },
_ => return,
};
let _ = tx.push(ev); // full queue drops, never blocks
}, ())?;
// --- audio thread: drain, then render. No locks, no allocation. ---
move |out: &mut [f32], _| {
while let Some(ev) = rx.pop() {
match ev {
Ev::On { note, vel } => synth.note_on(note, vel as f32 / 127.0),
Ev::Off { note } => synth.note_off(note),
Ev::Cc { num, val } => synth.control(num, val as f32 / 127.0),
}
}
synth.render(out);
}
# pip install mido python-rtmidi
import queue, mido
# A thread-safe queue between the MIDI thread and the audio thread.
events = queue.Queue(maxsize=256)
# --- MIDI thread: parse and push. Never touches the synth. ---
def on_midi(msg):
if msg.type == "note_on" and msg.velocity > 0:
ev = ("on", msg.note, msg.velocity / 127)
elif msg.type in ("note_off", "note_on"):
ev = ("off", msg.note, 0) # velocity 0 == note off
elif msg.type == "control_change":
ev = ("cc", msg.control, msg.value / 127)
else:
return
try:
events.put_nowait(ev) # full queue drops, never blocks
except queue.Full:
pass
port = mido.open_input(mido.get_input_names()[0], callback=on_midi)
# --- audio thread: drain, then render. ---
def callback(outdata, frames, time, status):
while True:
try:
kind, a, b = events.get_nowait()
except queue.Empty:
break
if kind == "on": synth.note_on(a, b)
elif kind == "off": synth.note_off(a)
else: synth.control(a, b)
outdata[:, 0] = synth.render(frames)
Draining the whole queue at the top of the block quantises every event to the block boundary — up to 2.67 ms of jitter, which is audible on fast passages. The fix is to timestamp events with a sample offset within the block and split rendering at those offsets. Worth doing eventually. Not worth doing first.
You press a key on a controller and hear a shaped note; you release it and hear it decay through its release stage. No clicks at either end.
Filters
The longest chapter, and the one that repays study. Oscillators give a synthesizer its raw material; the filter is what makes it expressive.
Subtractive synthesis in one sentence
Start with a harmonically rich waveform, remove parts of its spectrum, and modulate what you remove over time. That is the entire architecture of the Minimoog, the Prophet-5, the TB-303, and most software synths since.
The difference equation
A digital filter computes each output sample from recent inputs and recent outputs:
The b terms are feedforward and create zeros:
frequencies that get cancelled. The a terms are feedback and create
poles: frequencies that get emphasised. A filter with only b terms
is FIR — finite impulse response, unconditionally stable, and it takes many taps to be selective.
Add feedback and it becomes IIR, which is dramatically more efficient and can also explode.
Poles, zeros, and the unit circle
Apply the z-transform (substituting z⁻¹ for "delayed by one sample") to the
difference equation and you get a ratio of polynomials:
Roots of the numerator are zeros; roots of the denominator are poles. Evaluate H
around the unit circle z = e^(jω) and you have the frequency response — the magnitude
at each frequency is the product of distances to the zeros divided by the product of distances to
the poles. That geometric picture is worth internalising: a pole near the circle means a nearby
frequency gets divided by something tiny, hence a resonant peak.
The one-pole
Start here, because you can derive it. An RC lowpass is a first-order differential equation; the simplest discretisation gives:
One multiply, two adds, one state variable. It rolls off at 6 dB/octave, has no resonance, and
cannot go unstable for g in [0,1]. Subtract its output from its input and
you have a highpass for free. You will use this constantly — for DC blocking, for damping in a
reverb, for smoothing parameters, for the envelope in Chapter 4.
The biquad, and its limitation
Two poles and two zeros gets you 12 dB/octave and genuine resonance. The RBJ cookbook gives closed forms for lowpass, highpass, bandpass, notch, shelves and peaks in terms of cutoff and Q, and they are exact — a biquad matched to an analogue prototype is indistinguishable from it, at a fixed cutoff.
The trouble is modulation. Those coefficients are a nonlinear function of cutoff, and the filter's two state variables carry a memory that was consistent with the old coefficients. Recompute them every sample while an envelope sweeps and the state no longer matches; you get zipper noise at best and loud transients at worst. Recompute them once per block and you get stepping. Biquads are superb for EQ and poor for the thing a synth filter is for.
Zero-delay feedback, and why it exists
An analogue state-variable filter is a loop of two integrators with feedback. Translating it naively means the feedback needs the output before the output has been computed — a delay-free loop, which code cannot express. The old solution was to insert a one-sample delay in the loop. It works, but that delay is a phase error that grows with frequency, so as you raise cutoff the resonant peak drifts flat and the filter eventually blows up.
The modern solution, developed properly in Vadim Zavalishin's The Art of VA Filter Design, is to solve the loop algebraically. Write the integrator in its trapezoidal (TPT) form, substitute, and the instantaneous feedback resolves into a set of coefficients you can precompute. The result is a filter that stays accurate and stable while cutoff is modulated at audio rate.
pub struct Svf { g: f32, k: f32, a1: f32, a2: f32, a3: f32, ic1: f32, ic2: f32, fs: f32 }
impl Svf {
pub fn set_cutoff(&mut self, fc: f32) {
self.g = (std::f32::consts::PI * fc.clamp(12.0, self.fs * 0.47) / self.fs).tan();
self.update();
}
pub fn set_res(&mut self, r: f32) {
self.k = 2.0 - 2.0 * r.clamp(0.0, 0.985); // k -> 0 is self-oscillation
self.update();
}
fn update(&mut self) {
self.a1 = 1.0 / (1.0 + self.g * (self.g + self.k));
self.a2 = self.g * self.a1;
self.a3 = self.g * self.a2;
}
/// Returns (lowpass, bandpass, highpass) — all three, same cost.
pub fn process(&mut self, x: f32) -> (f32, f32, f32) {
let v3 = x - self.ic2;
let v1 = self.a1 * self.ic1 + self.a2 * v3;
let v2 = self.ic2 + self.a2 * self.ic1 + self.a3 * v3;
self.ic1 = 2.0 * v1 - self.ic1;
self.ic2 = 2.0 * v2 - self.ic2;
(v2, v1, x - self.k * v1 - v2)
}
}
import math
class Svf:
def __init__(self, fs):
self.fs = fs
self.ic1 = self.ic2 = 0.0
self.g, self.k = 0.0, 2.0 # so _update() has values to work with
self.set_cutoff(1000.0)
self.set_res(0.0)
def set_cutoff(self, fc):
fc = min(max(fc, 12.0), self.fs * 0.47)
self.g = math.tan(math.pi * fc / self.fs)
self._update()
def set_res(self, r):
self.k = 2.0 - 2.0 * min(max(r, 0.0), 0.985) # k -> 0 self-oscillates
self._update()
def _update(self):
self.a1 = 1.0 / (1.0 + self.g * (self.g + self.k))
self.a2 = self.g * self.a1
self.a3 = self.g * self.a2
def process(self, x):
"""Returns (lowpass, bandpass, highpass) — all three, same cost."""
v3 = x - self.ic2
v1 = self.a1 * self.ic1 + self.a2 * v3
v2 = self.ic2 + self.a2 * self.ic1 + self.a3 * v3
self.ic1 = 2.0 * v1 - self.ic1
self.ic2 = 2.0 * v2 - self.ic2
return v2, v1, x - self.k * v1 - v2
Notice that lowpass, bandpass and highpass all fall out of the same state — and that
notch = high + low and peak = high − low. One filter, five responses,
no extra arithmetic. That is why it is called a state-variable filter.
G · Filter bench
Four topologies, measured live from their impulse responsesSolid curve = the filter you are hearing. Dashed = the other three at the same settings.
The ladder, and nonlinearity
Moog's transistor ladder is four one-pole stages in series with global negative feedback. Four poles means 24 dB/octave, and the feedback path sets resonance. In a purely linear implementation, pushing feedback past the critical point makes it self-oscillate into an ever-growing sine.
In the real circuit, the transistors saturate. That single nonlinearity does several things at
once: it limits self-oscillation to a stable amplitude, it generates harmonics that thicken the
resonant peak, and it makes the filter respond differently to loud and quiet inputs. Put a
tanh in the feedback path of your ladder and you get most of it. Leave it out and you
have a filter that is technically correct and sounds sterile.
Saturation generates harmonics, and harmonics above Nyquist fold — the same problem as Chapter 3, arriving from a new direction. The standard mitigation is oversampling: upsample 2× or 4×, run the nonlinear stage, filter, downsample. It is the main reason a good software filter costs so much more than the arithmetic in its difference equation suggests.
Keyboard tracking
A fixed cutoff removes a different proportion of the harmonics from a low note than from a high one, so high notes sound progressively duller. The fix is to scale cutoff with pitch:
At amount 1.0 the filter tracks the keyboard exactly and timbre is constant across the range; at 0 it is fixed. Somewhere between 0.3 and 0.7 usually sounds most like an instrument. This is your first real taste of the principle that governs Chapter 6 — modulation belongs in the exponent.
You have a saw → SVF → amp voice with an envelope on cutoff, it sweeps musically without stepping or blowing up, and you can explain what a pole is and why yours are inside the circle.
Modulation
Nothing in an interesting sound holds still. Modulation is the machinery for making things move — and when you speed it up past about 20 Hz it stops being movement and becomes timbre.
Low-frequency oscillators
An LFO is the same phase accumulator from Chapter 1 running below the hearing threshold. Its output is a control signal, and where you send it decides what it is called:
| Destination | Name | Typical depth |
|---|---|---|
| Oscillator pitch | Vibrato | ±10 to 50 cents, 4–7 Hz |
| Amplitude | Tremolo | ±10–30%, 3–8 Hz |
| Filter cutoff | Wobble / auto-wah | ±0.5 to 3 octaves |
| Pulse width | PWM | ±30%, 0.2–2 Hz |
| Delay time | Chorus / flanger | ±2–5 ms, 0.3–1 Hz |
Two implementation notes that matter more than they look. Depth is exponential for anything pitch-like — a vibrato of "±20 Hz" is enormous on a bass note and inaudible on a high one, whereas "±20 cents" is the same everywhere. And free-running versus retriggered is a musical decision: a free-running LFO makes repeated notes differ from one another, which sounds alive; a retriggered one makes them identical, which sounds tight. Offer both.
Sample-and-hold deserves a mention: an LFO that latches a random value once per cycle. Route it to cutoff and you have the bubbling sound that defines a certain kind of sequenced electronic music.
FM is really PM
Push a modulator into the audio range and something different happens. Rather than hearing the pitch move, you hear a new, static timbre — because the modulation is now fast enough to create sidebands.
Here is the thing everyone gets wrong: what Yamaha sold as FM, and what almost every "FM" synth implements, is phase modulation. The modulator is added to the carrier's phase, not to its frequency:
The two are mathematically related — frequency is the derivative of phase — but PM is far better behaved in practice. True FM integrates the modulator, so any DC offset in it makes the carrier's pitch drift, and stacking operators compounds the problem. PM has no such issue, which is why it won.
Sidebands
The spectrum of the equation above is a carrier plus pairs of sidebands at
fc ± k·fm for every integer k. The amplitude of the
k-th pair is the Bessel function of the first kind,
Jk(I), where I is the modulation index.
You do not need to compute Bessel functions, and you should not try to memorise them. You need three facts from them:
- Index controls brightness. At
I = 0only the carrier exists — a pure sine. RaisingIpushes energy outward into higher-order sidebands. Roughly, significant sidebands extend to aboutk ≈ I + 1. - The carrier is not always the loudest component.
J₀passes through zero atI ≈ 2.4, so at that index the fundamental disappears entirely. This is a real effect you can hear, and it is why FM patches can sound like they change pitch when they do not. - Negative-frequency sidebands fold and phase-invert. When
fc − k·fmgoes below zero it reflects around DC, and if it lands on an existing partial it can cancel it. Not a bug — this is a large part of what makes FM spectra so distinctive and so hard to predict by ear.
The ratio decides harmonic or not
Sidebands sit at fc ± k·fm. If fm/fc
is a simple integer ratio, every sideband lands exactly on a harmonic of the carrier, and the
result is a pitched tone. If the ratio is irrational, the sidebands fall between the harmonics,
the result is inharmonic, and your ear reports "bell", "gong", or "metal".
| Ratio | Result |
|---|---|
| 1 : 1 | Sawtooth-like, all harmonics. Brass and reeds. |
| 2 : 1 | Odd harmonics only. Clarinet, hollow. |
| 3 : 1, 5 : 1 | Sparse harmonic spectra. Woodwind-ish. |
| 1.41 : 1 | Inharmonic. Bells, tubular metal. |
| 3.14 : 1 | Deeply inharmonic. Gongs, sound design. |
Add an envelope on the index and you have the FM electric piano: a bright inharmonic strike that settles into a nearly pure sine as the index falls. Two operators and two envelopes reproduce one of the most recognisable sounds of the 1980s.
H · Two-operator phase modulation
One modulator, one carrier, and a great deal of spectrumRing modulation, and routing
Multiplying two signals rather than adding gives ring modulation: sum and difference frequencies
only, no carrier. With two sines you get exactly two partials at
f₁ ± f₂ — the fastest route to inharmonic, clangorous, distinctly artificial tones.
Amplitude modulation is the same operation with a DC offset on one input, which keeps the carrier
in the output.
Once you have several sources and several destinations you need a modulation matrix: a list of (source, destination, depth) triples evaluated per sample or per block. Build this abstraction before you have five modulations, not after. Hard-coding "LFO1 goes to cutoff" into the voice is the single most common architectural mistake in a first synth, and unpicking it later touches every line of the render loop.
You can build a bell and an electric piano from two operators, and you can explain why ratio 2.13 sounds metallic when ratio 2.00 does not.
Voices
Everything so far made one sound at a time. Turning that into an instrument is mostly an exercise in resource management — which is the part of this book your systems instincts already cover.
The voice
A voice is one complete signal chain: oscillators, filter, envelopes, and the state they need. Polyphony is N voices summed. That is the whole idea, and it is worth stating plainly because the interesting problems are all about allocation rather than DSP.
Design decision to make early: every voice owns all its state. No shared filter, no shared envelope. It seems wasteful and it is not — the alternative is a tangle where one note's release affects another's tone. The only things reasonably shared are the global LFOs and the effects, which sit after the summing point.
Allocation and stealing
On note-on you need a voice. In order of preference:
- A voice already playing this exact note — retrigger it, so repeated notes do not stack up into a volume spike.
- Any completely idle voice.
- Failing both, steal one.
Stealing policy is audible. Taking the oldest voice is the standard choice and works well for keyboard playing. Taking the quietest — the one furthest into its release — is less noticeable but costs you a scan. Taking the lowest note is wrong for bass lines and right for pads. Whatever you pick, do not simply cut the stolen voice off: give it a very short forced release, a few milliseconds, before restarting. Otherwise you get a click on every stolen note, which is exactly when the texture is busiest and least able to hide it.
Smoothing
Chapter 1 warned about this; here is where it bites. Any parameter that multiplies the signal — gain, mix, effect depth — must be slewed, or every knob movement is a staircase of small steps, each one a discontinuity. The audible result is called zipper noise and it sounds exactly like the name.
A time constant around 5–20 ms is right for gains. Parameters that only set a frequency — oscillator pitch, filter cutoff — often do not need it, because the filter or oscillator absorbs the step; but cutoff on a high-resonance filter is an exception, and glide (portamento) is just a deliberately slow smoother on pitch.
Denormals
A filter's tail decays exponentially toward zero and eventually reaches values around 10−38, where floats become subnormal. On some x86 CPUs every operation on a subnormal takes a microcoded slow path, and a reverb decaying into silence can cost more than the entire rest of your synth. Symptoms: CPU usage that spikes after you stop playing.
Two fixes. Set the CPU's flush-to-zero and denormals-are-zero flags once at stream startup, or add a tiny constant — around 10−20 — to the signal at a few points in the feedback paths. The first is cleaner; the second is portable.
Block processing
Per-sample code like everything in this book is the clearest way to learn. Once it works, the standard optimisation is to hoist everything that does not change per sample out of the inner loop: coefficient updates, envelope stage checks, parameter reads. Many synths update modulation once every 8, 16 or 32 samples — a "control rate" — and interpolate between updates. The saving is large, the audible cost is usually nil, and the risk is that a fast envelope gets quantised into steps. Keep amplitude envelopes at full rate.
I · The synthesizer
8 voices · 2 oscillators + sub + noise · 2 envelopes · LFOpub struct Synth { voices: [Voice; 8], counter: u64 }
impl Synth {
pub fn note_on(&mut self, note: u8, vel: f32) {
self.counter += 1;
let stamp = self.counter;
// 1. same note already sounding -> retrigger it
if let Some(v) = self.voices.iter_mut()
.find(|v| v.note == note && v.held) {
return v.trigger(note, vel, stamp);
}
// 2. a free voice
if let Some(v) = self.voices.iter_mut().find(|v| !v.active()) {
return v.trigger(note, vel, stamp);
}
// 3. steal the oldest. min_by_key, not sort - no allocation.
let v = self.voices.iter_mut()
.min_by_key(|v| v.stamp).unwrap();
v.steal(note, vel, stamp); // 3 ms forced fade, then retrigger
}
pub fn render(&mut self, out: &mut [f32]) {
out.fill(0.0);
for v in self.voices.iter_mut().filter(|v| v.active()) {
v.add_into(out); // accumulate, never allocate a temp buffer
}
}
}
class Synth:
def __init__(self, n_voices=8):
self.voices = [Voice() for _ in range(n_voices)]
self.counter = 0
def note_on(self, note, vel):
self.counter += 1
stamp = self.counter
# 1. same note already sounding -> retrigger it
for v in self.voices:
if v.note == note and v.held:
return v.trigger(note, vel, stamp)
# 2. a free voice
for v in self.voices:
if not v.active:
return v.trigger(note, vel, stamp)
# 3. steal the oldest. min() scans in place - no sorting, no copy.
oldest = min(self.voices, key=lambda v: v.stamp)
oldest.steal(note, vel, stamp) # 3 ms forced fade, then retrigger
def render(self, out):
out[:] = 0.0
for v in self.voices:
if v.active:
v.add_into(out) # accumulate into the shared buffer
Time your callback with all voices sounding, maximum resonance, and every effect engaged — not the average. Divide by the block duration to get a load fraction. Anything above about 0.5 will glitch on a busy machine, because you are sharing the CPU with a browser, a DAW, and a scheduler that does not know your deadline exists.
You can play chords. Holding the sustain pedal through a fast passage does not glitch, stolen notes do not click, and you know your worst-case callback time.
Space & Shipping
One data structure — a circular buffer read at a fractional offset — generates chorus, flanging, delay and reverb. After that, the remaining work is packaging.
The delay line
Allocate a buffer, keep a write index, and read from write − delay, wrapping around.
Because the delay is rarely a whole number of samples — and because modulating it is the entire
point — the read is interpolated, exactly as in the wavetable of Chapter 3.
Everything below is that one structure with different parameters:
| Delay length | Feedback | Modulated? | What you call it |
|---|---|---|---|
| 0.1–10 ms | High | Yes | Flanger |
| 5–30 ms | None | Yes, gently | Chorus |
| 10–50 ms | Any | No | Comb filter / doubling |
| 50–1500 ms | Any | No | Echo |
| Many, in parallel | High | No | Reverb |
Combs, allpasses and Schroeder reverb
A delay with feedback is a comb filter: its response is a series of evenly spaced
peaks at multiples of 1/delay, because those frequencies come back in phase every
time round the loop. Short delays give widely spaced, audibly pitched combs; that hollow ringing
is what a flanger is.
1/delay: 500 Hz and 100 Hz. Modulate the delay length slowly and the whole comb
slides — the flanger sweep.An allpass filter passes every frequency at the same level but delays them by different amounts. Musically it does almost nothing on its own, which is exactly why it is useful: it smears a transient into a dense cloud without colouring the spectrum.
Schroeder's 1962 reverb combines them: several parallel combs with mutually prime delay lengths to build echo density without the echoes lining up, followed by a chain of allpasses to diffuse the result. Add a one-pole lowpass inside each comb's feedback path and high frequencies decay faster than low ones — which is what real rooms do, and without it a reverb sounds like a metal tank.
Modern designs use feedback delay networks — a matrix that mixes several delay lines into each other — which give smoother decay for similar cost. Schroeder is where to start; the intuition transfers.
Chorus
Take a short delay, modulate its length with a slow LFO, and mix it back with the dry signal. Because the read pointer is moving, the delayed copy is Doppler-shifted — it detunes slightly against the original, and the beating between them is the effect. Two or three copies at different LFO rates give the lush version. This is also why chorus and vibrato are the same device with different mix settings.
Saturation
A waveshaper maps input amplitude to output amplitude through a nonlinear curve —
tanh, a polynomial, a hard clip. Gentle curves add low-order harmonics and read as
warmth or glue; hard ones add high-order harmonics and read as distortion.
And, once again: harmonics above Nyquist fold. A hard clipper on a bright signal aliases badly. Oversample the nonlinear stage — upsample 4×, shape, lowpass, decimate — or use a curve chosen to be band-limited. This is the last appearance of the problem introduced in Chapter 0, and by now it should feel inevitable.
J · Effects rack
The Chapter 7 synth, into chorus → delay → reverbShipping it
A standalone binary with cpal is a fine end point. If you want it in a DAW, the
current answer in Rust is nih-plug, which targets CLAP and VST3 from one codebase
and handles parameter declaration, automation, state save/restore and GUI hosting. A plugin's
process callback has exactly the same constraints as the cpal one, so
the DSP you have written ports unchanged — which is the payoff for keeping the audio code free of
I/O from the very first chapter.
| Job | Rust | Python |
|---|---|---|
| Audio I/O | cpal | sounddevice |
| MIDI input | midir | mido + python-rtmidi |
| WAV read/write | hound | wave (stdlib), soundfile |
| FFT for analysis | rustfft | numpy.fft, scipy.signal |
| Arrays / block maths | plain slices | numpy |
| Lock-free queues | ringbuf, crossbeam | queue.Queue (locking, but adequate) |
| DSP to compare against | dasp, fundsp | scipy.signal, pyo |
| Plotting while you learn | write a WAV, open Audacity | matplotlib |
| CLAP / VST3 plugins | nih-plug | not really — port it |
| GUI | egui, vizia | dearpygui, tkinter |
Python is the better place to understand a filter: you can plot its impulse response in three lines and iterate in seconds. Rust is the better place to ship one. Plenty of people prototype in the first and port to the second, and that is a perfectly respectable way to work through this book.
Your synth loads in a DAW, holds its state, responds to automation, and sounds like something you would actually use.
Where to read next
Vadim Zavalishin, The Art of VA Filter Design — free, and the definitive treatment of the zero-delay-feedback material in Chapter 5. Read it after you have a working biquad, not before. Julius O. Smith's CCRMA books — Introduction to Digital Filters and Mathematics of the DFT, free online, rigorous and complete. Steven W. Smith, The Scientist and Engineer's Guide to DSP — free, and the gentlest on-ramp to Fourier and filters that exists. Will Pirkle, Designing Software Synthesizer Plugins in C++ — C++, but the DSP translates directly and the architectural advice is sound. Sound on Sound's Synth Secrets — around sixty articles, almost no mathematics, unmatched for intuition. Välimäki & Huovilainen, "Oscillator and Filter Algorithms for Virtual Analog Synthesis" — the paper behind Chapter 3. And musicdsp.org for the accumulated folklore.
The Instrument
Nine chapters of parts, assembled. Nothing new is introduced here — this is the same code you have been reading, wired together and given something to play.
What you have actually built
Every stage below exists because of a specific problem in a specific chapter. Read the chain left to right and the book reads back at you:
| Stage | From | The problem it solves |
|---|---|---|
| Phase accumulator | Ch 01 | Producing a steady pitch without drift or clicks |
| PolyBLEP oscillators | Ch 03 | A bright waveform that does not alias on high notes |
| Sub and noise | Ch 02–03 | Weight underneath, and air on top |
| TPT state-variable filter | Ch 05 | Removing harmonics, sweepable without thumping |
| Filter envelope | Ch 04–05 | The downward sweep that makes a note feel struck |
| Amplitude envelope | Ch 04 | Turning a continuous tone into a note |
| Key tracking | Ch 05 | High notes staying as bright as low ones |
| LFO and mod routing | Ch 06 | Movement, so nothing sits perfectly still |
| Voice allocation | Ch 07 | Playing more than one note without clicks |
| Chorus, delay, reverb | Ch 08 | Placing the sound somewhere rather than nowhere |
| Step sequencer | Ch 09 | Something to play, with timing you can trust |
Why the sequencer lives in the audio callback
It is tempting to drive a sequencer from a timer on the UI thread: wake up every so often, decide it is time for the next step, send a note-on. This is how a great many hobby synths do it, and it is why they feel slightly loose.
The arithmetic is unforgiving. A sixteenth note at 120 BPM is 125 ms. If your events can only land on block boundaries, and a block is 128 samples, then every note is quantised to the nearest 2.67 ms — and a browser or OS timer is far worse than that, typically drifting by 5 to 15 ms under load. At that point the sequencer is audibly behind itself, and worse, inconsistently behind itself, which the ear reads as sloppiness rather than lateness.
The fix is to count samples instead of milliseconds. The audio callback already knows exactly how
much time has passed, because it is handed exactly n samples' worth. So keep a
countdown in samples, and when it runs out mid-block, split the block: render up
to the step boundary, fire the note, render the rest. Steps then land on the exact sample they
should, forever, with no drift.
At 48 kHz and 120 BPM that is 6,000 samples exactly. Swing lengthens the on-beat sixteenth and shortens the off-beat one by the same amount, so the bar still lasts precisely as long — the groove moves, the tempo does not.
pub struct Seq {
notes: [i16; 16], // -1 means rest
accent: [bool; 16],
step: usize,
to_next: f32, // samples until the next step
gate_left: f32, // samples until this note releases
sounding: Option<u8>,
fs: f32, bpm: f32, swing: f32, gate: f32,
}
impl Seq {
fn step_samples(&self) -> f32 { (self.fs * 60.0 / self.bpm) / 4.0 }
fn advance(&mut self, synth: &mut Synth) {
self.step = (self.step + 1) % 16;
// Swing: on-beat sixteenths get longer, off-beat ones shorter
// by the same amount, so the bar length never changes.
let sw = self.swing.clamp(0.0, 0.66) * 0.5;
let dur = self.step_samples()
* if self.step % 2 == 0 { 1.0 + sw } else { 1.0 - sw };
self.to_next = dur;
let n = self.notes[self.step];
if n >= 0 {
if let Some(prev) = self.sounding.take() { synth.note_off(prev); }
let vel = if self.accent[self.step] { 1.0 } else { 0.6 };
synth.note_on(n as u8, vel);
self.sounding = Some(n as u8);
self.gate_left = dur * self.gate;
}
}
/// Split the block at every event. This is what buys you
/// sample-accurate timing instead of block-quantised timing.
pub fn render(&mut self, synth: &mut Synth, out: &mut [f32]) {
let mut i = 0;
while i < out.len() {
if self.to_next <= 0.0 { self.advance(synth); }
let mut next = self.to_next;
if self.gate_left > 0.0 { next = next.min(self.gate_left); }
let chunk = (out.len() - i).min(next.ceil() as usize).max(1);
synth.render(&mut out[i..i + chunk]);
i += chunk;
self.to_next -= chunk as f32;
if self.gate_left > 0.0 {
self.gate_left -= chunk as f32;
if self.gate_left <= 0.0 {
if let Some(n) = self.sounding.take() { synth.note_off(n); }
}
}
}
}
}
import math
class Seq:
def __init__(self, fs, synth):
self.fs, self.synth = fs, synth
self.notes = [-1] * 16 # -1 means rest
self.accent = [False] * 16
self.step, self.sounding = 15, None
self.to_next = self.gate_left = 0.0
self.bpm, self.swing, self.gate = 120.0, 0.0, 0.55
def step_samples(self):
return (self.fs * 60.0 / self.bpm) / 4.0
def advance(self):
self.step = (self.step + 1) % 16
# Swing: on-beat sixteenths longer, off-beat shorter by the
# same amount, so the bar length never changes.
sw = min(max(self.swing, 0.0), 0.66) * 0.5
dur = self.step_samples() * (1 + sw if self.step % 2 == 0 else 1 - sw)
self.to_next = dur
n = self.notes[self.step]
if n >= 0:
if self.sounding is not None:
self.synth.note_off(self.sounding)
vel = 1.0 if self.accent[self.step] else 0.6
self.synth.note_on(n, vel)
self.sounding = n
self.gate_left = dur * self.gate
def render(self, out):
"""Split the block at every event. This is what buys you
sample-accurate timing instead of block-quantised timing."""
i = 0
while i < len(out):
if self.to_next <= 0:
self.advance()
nxt = self.to_next
if self.gate_left > 0:
nxt = min(nxt, self.gate_left)
chunk = max(1, min(len(out) - i, int(math.ceil(nxt))))
# out is a numpy array, so this slice is a view, not a copy —
# the synth writes straight into the output buffer.
self.synth.render(out[i:i + chunk])
i += chunk
self.to_next -= chunk
if self.gate_left > 0:
self.gate_left -= chunk
if self.gate_left <= 0 and self.sounding is not None:
self.synth.note_off(self.sounding)
self.sounding = None
The panel below runs exactly this, in an AudioWorklet. The playhead you see stepping
across the grid is the audio thread reporting its own position back, not a UI timer guessing.
K · The instrument
Sequencer · 8 voices · 2 osc + sub + noise · filter · 2 envelopes · LFO · 3 effectsReading a patch
Four recipes, each expressed as a chain of decisions rather than a list of numbers. Load the pattern, load the patch, then change one thing at a time and listen for what it does.
| Sound | The decisions |
|---|---|
| Acid bass | Ladder filter for the saturation. Low cutoff, high resonance, a big filter envelope with a short decay. Accent raises velocity, which opens the filter further — so accented steps squelch and the rest do not. Gate short, so notes separate. |
| Pluck | Sustain at zero on both envelopes: the note is entirely attack and decay. High key tracking so the top of the keyboard stays bright. A little delay for space. |
| Pad | Slow attack on both envelopes, two saws detuned by 10 or so cents, a slow LFO on cutoff, and plenty of reverb. Detune is what makes it wide; without it, two saws in unison are just a louder saw. |
| Lead | Osc 2 up a fifth, moderate resonance, a touch of LFO on pitch for vibrato. Turn the sequencer off and play it from the keyboard. |
One. Load Acid, run it, and slowly sweep Cutoff while raising Res toward 0.9. That is Chapter 5 doing the single most recognisable thing in electronic music.
Two. Set Gate above 1.0 so each note overruns the next. Watch the voice meters: notes now overlap, and the allocator from Chapter 7 starts handing out second and third voices for a line you thought was monophonic.
Three. Turn Swing to 0.4 and count along. The bar still takes exactly as long — check it against the tempo — but the off-beats arrive late. Groove is arithmetic.
What was deliberately left out
This synth is complete enough to be useful and small enough to hold in your head. The obvious next steps, roughly in order of how much they improve the sound per hour spent:
- Unison and supersaw. Seven detuned saws per voice instead of two, with stereo spread. The cheapest large improvement in perceived quality there is.
- Oversampling the nonlinear stages. The ladder's saturator aliases, as Chapter 5 warned. Run it at 4× and filter, and the filter stops sounding fizzy when driven.
- A real modulation matrix. Chapter 6 described one; this synth has fixed routings. Sources × destinations × depth, evaluated per block.
- Sample-accurate MIDI. The same block-splitting the sequencer does, applied to incoming MIDI events.
- Stereo. Everything here is mono. Pan per voice, stereo effects, and a widener are mostly plumbing but transform the result.
- Patch save and load. Dull, and the first thing you will want once you find a sound you like.
- MPE. Per-note pitch and pressure, if you have a controller that sends it.
You have an instrument. It plays in time, it does not click, it does not alias, and every knob on it is something you could rewrite from scratch. The remaining work is taste.
References
Almost nothing in this book is new. What follows is an attempt to name the people who actually worked it out.
This book was written from knowledge of the field rather than assembled from sources open on a desk, and no text, figure or code was copied from any of the works below. So this is not a record of documents consulted while drafting — it is an attribution of ideas to the people who originated them, and a reading list for going deeper. Bibliographic details for the principal algorithmic sources were checked against publisher and primary records; the general reading list at the end is offered as recommendation rather than citation.
The author of this document is Claude Opus 5, an AI model made by Anthropic, writing in August 2026. That is relevant to provenance in both directions: nothing below was transcribed or paraphrased from a source, and equally, nothing below was re-checked against one while the prose was being written. Treat the technical claims as you would treat any confident secondary account — useful, and worth verifying at the primary source before you rely on it.
Where a specific published algorithm is implemented here more or less as its author formulated it, that is stated explicitly in the entry. Two cases matter most: the state-variable filter in Chapter 5 follows Andrew Simper's trapezoidal formulation closely, and the PolyBLEP correction in Chapter 3 is Välimäki and Huovilainen's closed-form polynomial. Everything else is an ordinary implementation of standard, long-published technique.
Foundations · sampling, Fourier, the DFT
- Harry Nyquist · Claude E. Shannon "Certain Topics in Telegraph Transmission Theory" (Transactions of the AIEE, 1928) and "Communication in the Presence of Noise" (Proceedings of the IRE, 1949). Independently anticipated by Vladimir Kotelnikov (1933) and E. T. Whittaker (1915). Ch 0 — the sampling theorem and the Nyquist limit
- Joseph Fourier Théorie analytique de la chaleur, 1822. The claim that a periodic function decomposes into a sum of sinusoids. Ch 2 — the entire chapter
- Henry Wilbraham · J. Willard Gibbs The overshoot at a discontinuity in a truncated Fourier series, described by Wilbraham in 1848 and independently by Gibbs in 1899. Fig 2.2 — Gibbs phenomenon
- James W. Cooley & John W. Tukey "An Algorithm for the Machine Calculation of Complex Fourier Series", Mathematics of Computation 19(90):297–301, 1965. Anticipated in unpublished work by Gauss around 1805. Every spectrum on this page — the radix-2 FFT is a direct implementation
- Julius von Hann · Ralph Beebe Blackman & John Tukey The raised-cosine window, named for Hann and popularised through Blackman and Tukey's 1958 work on spectral estimation. Ch 2 — windowing before every FFT here
Oscillators and anti-aliasing
- Tim Stilson & Julius O. Smith III "Alias-Free Digital Synthesis of Classic Analog Waveforms", Proc. ICMC, Hong Kong, 1996, pp. 332–335. The foundational BLIT and BLEP paper. Ch 3 — the idea that a naive waveform differs from its band-limited ideal only near the discontinuity
- Eli Brandt "Hard Sync Without Aliasing", Proc. ICMC, Havana, 2001, pp. 365–368. Introduced MinBLEP — the minimum-phase band-limited step, which removes the lookahead latency the symmetric BLEP requires. Ch 3 — the BLEP lineage, and the hard-sync discussion
-
Vesa Välimäki & Antti Huovilainen
"Antialiasing Oscillators in Subtractive Synthesis", IEEE Signal
Processing Magazine 24(2):116–125, March 2007. This is where PolyBLEP is introduced and
named: a closed-form polynomial standing in for a table lookup.
Ch 3 — the
poly_blepfunction here is this algorithm - Vesa Välimäki & Antti Huovilainen "Oscillator and Filter Algorithms for Virtual Analog Synthesis", Computer Music Journal 30(2):19–31, Summer 2006. Ch 3 and Ch 5 — the survey this book's framing follows
- Vesa Välimäki "Discrete-Time Synthesis of the Sawtooth Waveform with Reduced Aliasing", IEEE Signal Processing Letters 12(3):214–217, 2005. The DPW method — a fourth approach this book mentions only in passing. Ch 3 — further reading
-
Robert Bristow-Johnson
"Wavetable Synthesis 101, A Fundamental Perspective", AES 101st Convention,
1996. The per-octave mipmapped table strategy.
Ch 3 — the
Wavetableclass
Filters
-
Robert A. Moog
US Patent 3,475,623, "Electronic High-Pass and Low-Pass Filters Employing
the Base to Emitter Diode Resistance of Bipolar Transistors". Filed 10 October 1966, issued
28 October 1969. The transistor ladder.
Ch 5 — the circuit the
Ladderclass approximates - Hal Chamberlin Musical Applications of Microprocessors, Hayden, 1980. The digital state-variable filter with a unit delay in the feedback loop — the design whose limitations motivated everything that came after. Ch 5 — the "old solution" the chapter describes and then improves on
-
Robert Bristow-Johnson
"Audio EQ Cookbook". Originally circulated as
Audio-EQ-Cookbook.txton the music-dsp mailing list; adapted with permission as a W3C Working Group Note (2021). Closed-form biquad coefficients for every common response. Ch 5 — theBiquad.set()coefficients are these formulae - Vadim Zavalishin The Art of VA Filter Design. Written at Native Instruments and distributed free in successive revisions. The definitive treatment of topology-preserving transforms and zero-delay feedback. Ch 5 — the whole delay-free-loop argument
-
Andrew Simper · Cytomic
"Solving the Continuous SVF Equations Using Trapezoidal Integration and
Equivalent Currents", November 2013.
Ch 5 — the
SVFclass implements this formulation directly: theg/k/a1/a2/a3coefficients and the two-integrator update are Simper's - Ross Bencina · Aaron Wishnick · Jean Laroche Bencina's partial time-varying BIBO stability analysis of Simper's SVF (music-dsp mailing list, 2013), completed in Wishnick's "Time-Varying Filters for Musical Applications", Proc. DAFx-14, Erlangen, using Laroche's state-space method. Ch 5 — the claim that the TPT SVF stays stable under fast cutoff modulation is their result, not folklore
- Antti Huovilainen "Nonlinear Digital Implementation of the Moog Ladder Filter", Proc. DAFx-04, Naples, 2004, pp. 61–64. Nonlinearities placed inside the one-pole stages rather than only in the feedback path. Ch 5 — the saturation discussion; this book uses the simpler single-saturator form
- Tim Stilson & Julius O. Smith III "Analyzing the Moog VCF with Considerations for Digital Implementation", CCRMA, Stanford. Ch 5 — the structure of the digital ladder
- Julius O. Smith III Introduction to Digital Filters with Audio Applications, CCRMA / W3K Publishing. Free online, and the reason this book can state the pole-zero geometry in a paragraph. Ch 5 — difference equations, the z-transform, Fig 5.2
- Alan V. Oppenheim & Ronald W. Schafer Discrete-Time Signal Processing, Prentice Hall. The standard academic reference for LTI systems, convolution and the z-transform. Ch 5 — background
Envelopes, MIDI and perception
- Vladimir Ussachevsky The four-stage attack/decay/sustain/release envelope is conventionally credited to a suggestion Ussachevsky made to Robert Moog in the mid-1960s. Ch 4 — the ADSR shape
- Jean-Claude Risset & David Wessel "Exploration of Timbre by Analysis and Synthesis", in Diana Deutsch (ed.), The Psychology of Music, Academic Press. Includes the classic demonstrations that removing the attack transient makes instruments hard to identify. Ch 4 — "why attack transients carry identity"
- MIDI Manufacturers Association MIDI 1.0 Detailed Specification. Including the rule that note-on with velocity 0 is a note-off. Ch 4 — the message parsing in the Rust listing
Modulation
- John M. Chowning "The Synthesis of Complex Audio Spectra by Means of Frequency Modulation", Journal of the Audio Engineering Society 21(7):526–534, September 1973. Ch 6 — essentially the entire chapter: carrier/modulator ratios, the modulation index, and Bessel-function sideband amplitudes
- John Chowning & David Bristow FM Theory and Applications: By Musicians for Musicians, Yamaha Music Foundation, 1986. The practical companion to the 1973 paper. Ch 6 — the ratio table and the electric-piano recipe
-
Friedrich Wilhelm Bessel
Bessel functions of the first kind,
Jk, which give the sideband amplitudes — including the zero ofJ₀near index 2.4 at which the carrier vanishes. Ch 6 — Fig 6.1
Real-time practice
- Ross Bencina "Real-time audio programming 101: time waits for nothing", 2011, rossbencina.com. On blocking, worst-case complexity, allocation, locks, page faults and garbage collection in an audio callback. Ch 1 and Ch 7 — the rules list is his framing, including the emphasis on worst case over average case
-
George Marsaglia
"Xorshift RNGs", Journal of Statistical Software 8(14):1–6, 2003.
The
Osc.white()noise source — a 32-bit xorshift, chosen because it allocates nothing and branches never
Delay, reverberation and effects
-
Manfred R. Schroeder & Benjamin F. Logan
"'Colorless' Artificial Reverberation", IRE Transactions on Audio
9(6):209–214, 1961. The allpass reverberator.
Ch 8 — the
Allpassclass -
Manfred R. Schroeder
"Natural Sounding Artificial Reverberation", Journal of the Audio
Engineering Society 10(3):219–223, July 1962. Parallel combs into series allpasses.
Ch 8 — the
Reverbclass is this topology - "Jezar" at Dreampoint Freeverb, released to the public domain in the late 1990s. Not used as source, but its comb and allpass tunings established the delay-length conventions this book's reverb sits in the lineage of. Ch 8 — the mutually-prime delay lengths
- Jean-Marc Jot & Antoine Chaigne "Digital Delay Networks for Designing Artificial Reverberators", AES 90th Convention, 1991. Feedback delay networks, building on Stautner and Puckette (1982). Ch 8 — the "modern designs" this book points to but does not implement
-
Julius O. Smith III
Physical Audio Signal Processing, CCRMA / W3K Publishing. Free
online; the reference treatment of delay lines, fractional interpolation and comb structures.
Ch 8 — the
DelayandCombclasses
General reading recommended in the text
- Steven W. Smith The Scientist and Engineer's Guide to Digital Signal Processing, California Technical Publishing. Free at dspguide.com. Recommended in Ch 8 as the gentlest on-ramp
- Will Pirkle Designing Software Synthesizer Plugins in C++, Focal Press. Recommended in Ch 8
- Gordon Reid "Synth Secrets", Sound on Sound, 1999–2004. Around sixty articles, almost no mathematics, unmatched for intuition. Recommended in Ch 8
- Charles Dodge & Thomas A. Jerse Computer Music: Synthesis, Composition and Performance, Schirmer. Recommended in the study plan
- Curtis Roads The Computer Music Tutorial, MIT Press. The encyclopedic survey. General background
- musicdsp.org · the music-dsp mailing list Decades of accumulated practitioner folklore, contributed by too many people to name. Much of the working knowledge in this book has no other citable origin. Throughout — denormal handling, DC blockers, smoothing constants, voice stealing heuristics
Software and type used to build this page
-
W3C Audio Working Group
The Web Audio API, and specifically
AudioWorklet, which is what makes it possible to run hand-written per-sample DSP on the browser's audio thread rather than settling for built-in nodes. Every sound on this page -
The Rust audio ecosystem
Named in Ch 8:
cpalandmidir(RustAudio community),hound(Ruud van Asseldonk),rustfft,ringbuf,dasp,fundsp(Sami Perttu),nih-plug(Robbert van der Helm) andegui(Emil Ernerfeldt). Ch 8 — the tooling table. See the note below regarding nih-plug. - Typefaces Barlow Condensed (Jeremy Tribby), Spectral (Production Type) and IBM Plex Mono (Mike Abbink with Bold Monday, for IBM). All under the SIL Open Font License, served via Google Fonts. Panel legends, body text, and all data readouts respectively
Chapter 8 recommends nih-plug for building CLAP and VST3 plugins. Since that text
was written, the RustAudio community has forked it as nice-plug, a
community-led project that now describes itself as the recommended toolkit for Rust audio
plugin development. nih-plug remains a reasonable starting point and the API is
closely related, but check the current state of both before committing.
The DSP source embedded in this page was written for it. The two exceptions are noted above: the state-variable filter follows Simper's published formulation, and PolyBLEP is Välimäki and Huovilainen's polynomial. Both are published algorithms intended to be implemented. If you spot anything else here that belongs to someone and is not credited, that is an error worth correcting.