Learn Zig Series (#172) - Synthesis: Envelopes and Filters
What will I learn?
- What an envelope is, and why a note needs one -- how ADSR (attack, decay, sustain, release) turns a lifeless drone into something that actually plays;
- How to model the envelope as an explicit state machine with a tagged enum (episodes 6 and 33), so every stage is exhaustive and impossible to forget;
- How to gate a voice on and off, and why the release stage is the piece almost everyone gets wrong the first time;
- What a filter really does to a sound, why subtractive synthesis starts from a harmonically-rich saw, and how a one-pole low-pass works from first principles;
- How to build a resonant state-variable filter with independent cutoff and resonance -- the knob that makes a synth sing;
- How to test envelopes and filters deterministically with no sound card in sight, and the honest performance rules for the audio hot path;
- Where C, Rust and Go land on the same design, and where Zig's exhaustive
switchand comptime quietly pay off.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Zig 0.14+ distribution (download from ziglang.org) -- the code here is written against Zig 0.16;
- The oscillator, phase accumulator and PolyBLEP from episode 171, plus the PCM buffer (episode 169) and the real-time callback (episode 170);
- Comfort with structs, enums and tagged unions (episode 6), the state-machine pattern (episode 33), comptime (episode 9) and Zig's error handling (episode 4);
- The ambition to learn Zig programming.
Difficulty
- Advanced
Curriculum (of the Learn Zig Series):
- Zig Programming Tutorial - ep001 - Intro
- Learn Zig Series (#2) - Hello Zig, Variables and Types
- Learn Zig Series (#3) - Functions and Control Flow
- Learn Zig Series (#4) - Error Handling (Zig's Best Feature)
- Learn Zig Series (#5) - Arrays, Slices, and Strings
- Learn Zig Series (#6) - Structs, Enums, and Tagged Unions
- Learn Zig Series (#7) - Memory Management and Allocators
- Learn Zig Series (#8) - Pointers and Memory Layout
- Learn Zig Series (#9) - Comptime (Zig's Superpower)
- Learn Zig Series (#10) - Project Structure, Modules, and File I/O
- Learn Zig Series (#11) - Mini Project: Building a Step Sequencer
- Learn Zig Series (#12) - Testing and Test-Driven Development
- Learn Zig Series (#13) - Interfaces via Type Erasure
- Learn Zig Series (#14) - Generics with Comptime Parameters
- Learn Zig Series (#15) - The Build System (build.zig)
- Learn Zig Series (#16) - Sentinel-Terminated Types and C Strings
- Learn Zig Series (#17) - Packed Structs and Bit Manipulation
- Learn Zig Series (#18b) - Addendum: Async Returns in Zig 0.16
- Learn Zig Series (#19) - SIMD with @Vector
- Learn Zig Series (#20) - Working with JSON
- Learn Zig Series (#21) - Networking and TCP Sockets
- Learn Zig Series (#22) - Hash Maps and Data Structures
- Learn Zig Series (#23) - Iterators and Lazy Evaluation
- Learn Zig Series (#24) - Logging, Formatting, and Debug Output
- Learn Zig Series (#25) - Mini Project: HTTP Status Checker
- Learn Zig Series (#26) - Writing a Custom Allocator
- Learn Zig Series (#27) - C Interop: Calling C from Zig
- Learn Zig Series (#28) - C Interop: Exposing Zig to C
- Learn Zig Series (#29) - Inline Assembly and Low-Level Control
- Learn Zig Series (#30) - Thread Safety and Atomics
- Learn Zig Series (#31) - Memory-Mapped I/O and Files
- Learn Zig Series (#32) - Compile-Time Reflection with @typeInfo
- Learn Zig Series (#33) - Building a State Machine with Tagged Unions
- Learn Zig Series (#34) - Performance Profiling and Optimization
- Learn Zig Series (#35) - Cross-Compilation and Target Triples
- Learn Zig Series (#36) - Mini Project: CLI Task Runner
- Learn Zig Series (#37) - Markdown to HTML: Tokenizer and Lexer
- Learn Zig Series (#38) - Markdown to HTML: Parser and AST
- Learn Zig Series (#39) - Markdown to HTML: Renderer and CLI
- Learn Zig Series (#40) - Key-Value Store: In-Memory Store
- Learn Zig Series (#41) - Key-Value Store: Write-Ahead Log
- Learn Zig Series (#42) - Key-Value Store: TCP Server
- Learn Zig Series (#43) - Key-Value Store: Client Library and Benchmarks
- Learn Zig Series (#44) - Image Tool: Reading and Writing PPM/BMP
- Learn Zig Series (#45) - Image Tool: Pixel Operations
- Learn Zig Series (#46) - Image Tool: CLI Pipeline
- Learn Zig Series (#47) - Build a Shell: Parsing Commands
- Learn Zig Series (#48) - Build a Shell: Process Spawning
- Learn Zig Series (#49) - Build a Shell: Built-in Commands
- Learn Zig Series (#50) - Build a Shell: Job Control and Signals
- Learn Zig Series (#51) - HTTP Server: Accept Loop and Parsing
- Learn Zig Series (#52) - HTTP Server: Router and Responses
- Learn Zig Series (#53) - HTTP Server: Static Files and MIME
- Learn Zig Series (#54) - HTTP Server: Middleware and Logging
- Learn Zig Series (#55) - ECS Game Engine: Architecture
- Learn Zig Series (#56) - ECS Game Engine: Component Storage
- Learn Zig Series (#57) - ECS Game Engine: Systems and Queries
- Learn Zig Series (#58) - ECS Game Engine: Terminal Rendering
- Learn Zig Series (#59) - Assembler: Instruction Encoding
- Learn Zig Series (#60) - Assembler: Two-Pass Assembly
- Learn Zig Series (#61) - Assembler: Disassembler and Binary Inspector
- Learn Zig Series (#62) - File Systems: Reading Directories and Metadata
- Learn Zig Series (#63) - File Watching: Detecting Changes
- Learn Zig Series (#64) - Process Management: Fork, Exec, Wait
- Learn Zig Series (#65) - Pipes and Inter-Process Communication
- Learn Zig Series (#66) - Shared Memory and Semaphores
- Learn Zig Series (#67) - Signal Handling Deep Dive
- Learn Zig Series (#68) - Unix Domain Sockets
- Learn Zig Series (#69) - Daemonization: Background Services
- Learn Zig Series (#70) - Timers and Scheduling
- Learn Zig Series (#71) - Resource Limits and Capabilities
- Learn Zig Series (#72) - System Call Wrappers
- Learn Zig Series (#73) - seccomp and Sandboxing
- Learn Zig Series (#74) - ptrace: Process Tracing
- Learn Zig Series (#75) - Reading Kernel State from /proc and /sys
- Learn Zig Series (#76) - Mini Project: Process Monitor
- Learn Zig Series (#77) - Mini Project: File Sync Tool - Part 1
- Learn Zig Series (#78) - Mini Project: File Sync Tool - Part 2: Delta Transfer
- Learn Zig Series (#79) - Mini Project: File Sync Tool - Part 3: Network Protocol
- Learn Zig Series (#80) - Mini Project: File Sync Tool - Part 4: Polish
- Learn Zig Series (#81) - UDP Sockets and Datagrams
- Learn Zig Series (#82) - DNS Resolver from Scratch
- Learn Zig Series (#83) - DNS Server Implementation
- Learn Zig Series (#84) - HTTP/1.1 Deep Dive
- Learn Zig Series (#85) - HTTP/2 Frames and Streams
- Learn Zig Series (#86) - TLS via C Interop
- Learn Zig Series (#87) - WebSocket Protocol
- Learn Zig Series (#88) - WebSocket Server
- Learn Zig Series (#89) - MQTT Messaging Protocol
- Learn Zig Series (#90) - Protocol Buffers Serialization
- Learn Zig Series (#91) - MessagePack Format
- Learn Zig Series (#92) - gRPC Service in Zig
- Learn Zig Series (#93) - SOCKS5 Proxy
- Learn Zig Series (#94) - NAT Traversal and Hole Punching
- Learn Zig Series (#95) - Mini Project: Chat Server - Protocol Design
- Learn Zig Series (#96) - Mini Project: Chat Server - Server Core
- Learn Zig Series (#97) - Mini Project: Chat Server - Client TUI
- Learn Zig Series (#98) - Mini Project: Chat Server - Rooms and History
- Learn Zig Series (#99) - Mini Project: DNS-over-HTTPS Proxy
- Learn Zig Series (#100) - Mini Project: Port Scanner
- Learn Zig Series (#101) - Mini Project: HTTP Load Tester - Part 1
- Learn Zig Series (#102) - Mini Project: HTTP Load Tester - Part 2
- Learn Zig Series (#103) - Mini Project: Reverse Proxy - Routing
- Learn Zig Series (#104) - Mini Project: Reverse Proxy - Load Balancing
- Learn Zig Series (#105) - Mini Project: Reverse Proxy - Health Checks
- Learn Zig Series (#106) - Linked Lists: Singly and Doubly
- Learn Zig Series (#107) - Skip Lists
- Learn Zig Series (#108) - B-Trees
- Learn Zig Series (#109) - Red-Black Trees
- Learn Zig Series (#110) - Tries: Prefix Trees
- Learn Zig Series (#111) - Bloom Filters
- Learn Zig Series (#112) - Cuckoo Filters
- Learn Zig Series (#113) - Ring Buffers: Lock-Free
- Learn Zig Series (#114) - Memory Pools
- Learn Zig Series (#115) - Slab Allocators
- Learn Zig Series (#116) - Sorting Algorithms in Zig
- Learn Zig Series (#117) - Binary Search Variations
- Learn Zig Series (#118) - Graph Representation
- Learn Zig Series (#119) - BFS and DFS
- Learn Zig Series (#120) - Dijkstra and A*
- Learn Zig Series (#121) - Topological Sort
- Learn Zig Series (#122) - Union-Find
- Learn Zig Series (#123) - LRU Cache
- Learn Zig Series (#124) - Consistent Hashing
- Learn Zig Series (#125) - Mini Project: Search Engine - Inverted Index
- Learn Zig Series (#126) - Mini Project: Search Engine - TF-IDF
- Learn Zig Series (#127) - Mini Project: Search Engine - Query Parser
- Learn Zig Series (#128) - Mini Project: Database Engine - Page Storage
- Learn Zig Series (#129) - Mini Project: Database Engine - B-Tree Index
- Learn Zig Series (#130) - Mini Project: Database Engine - SQL Parser
- Learn Zig Series (#131) - Lexing a Simple Language
- Learn Zig Series (#132) - Recursive Descent Parsing
- Learn Zig Series (#133) - AST Design and Traversal
- Learn Zig Series (#134) - Type Checking
- Learn Zig Series (#135) - Bytecode Design
- Learn Zig Series (#136) - Stack-Based Virtual Machine
- Learn Zig Series (#137) - Closures and Upvalues
- Learn Zig Series (#138) - Garbage Collection: Mark and Sweep
- Learn Zig Series (#139) - Garbage Collection: Generational
- Learn Zig Series (#140) - JIT Compilation Basics
- Learn Zig Series (#141) - Regex: Thompson NFA
- Learn Zig Series (#142) - Regex: NFA to DFA
- Learn Zig Series (#143) - Regex: Matching Engine
- Learn Zig Series (#144) - Code Generation: AST to Machine Code
- Learn Zig Series (#145) - Register Allocation
- Learn Zig Series (#146) - Mini Project: Calculator - Lexer/Parser
- Learn Zig Series (#147) - Mini Project: Calculator - Interpreter
- Learn Zig Series (#148) - Mini Project: Calculator - Bytecode Compiler
- Learn Zig Series (#149) - Mini Project: Calculator - VM with Debugger
- Learn Zig Series (#150) - Mini Project: Lisp - Reader
- Learn Zig Series (#151) - Mini Project: Lisp - Evaluator
- Learn Zig Series (#152) - Mini Project: Lisp - Special Forms and Macros
- Learn Zig Series (#153) - Mini Project: Lisp - Standard Library
- Learn Zig Series (#154) - Mini Project: Regex Engine - NFA
- Learn Zig Series (#155) - Mini Project: Regex Engine - Matching
- Learn Zig Series (#156) - Framebuffer Basics
- Learn Zig Series (#157) - Line Drawing: Bresenham
- Learn Zig Series (#158) - Circle and Ellipse Rasterization
- Learn Zig Series (#159) - Polygon Filling: Scanline
- Learn Zig Series (#160) - 2D Transform Matrices
- Learn Zig Series (#161) - Double Buffering and Vsync
- Learn Zig Series (#162) - Sprite Rendering and Tile Maps
- Learn Zig Series (#163) - Bitmap Font Rendering
- Learn Zig Series (#164) - TrueType Parsing
- Learn Zig Series (#165) - Color Spaces: RGB, HSV, sRGB
- Learn Zig Series (#166) - Alpha Blending and Compositing
- Learn Zig Series (#167) - PNG Decoder in Zig
- Learn Zig Series (#168) - JPEG Decoder Basics
- Learn Zig Series (#169) - Audio Fundamentals: PCM and Buffers
- Learn Zig Series (#170) - Audio Output via C Interop
- Learn Zig Series (#171) - Synthesis: Oscillators
- Learn Zig Series (#172) - Synthesis: Envelopes and Filters (this post)
Learn Zig Series (#172) - Synthesis: Envelopes and Filters
Last episode we built the oscillator -- one little phase accumulator that can be a sine, a saw, a square or a triangle, at any pitch, with the aliasing shaved off. It makes a sound. But it makes exactly one sound, forever: hold the key and it drones on, flat and lifeless, the same the millisecond you press it as the minute after. A real instrument does not do that. A plucked string leaps up loud and dies away. A bowed note swells in. A brass stab is bright at the front and mellows as it holds. Two things create almost all of that life: how the note's loudness moves over time (the envelope), and how its harmonics are carved (the filter). Today we build both, and by the end that drone finally becomes something you can call a note. Here we go!
But first, the three loose ends I left you with at the end of the oscillator episode.
Solutions to Episode 171 Exercises
Exercise 1 -- a pulse-width knob. The square wave is just the special case of a pulse where the high part and the low part are equal. Generalize it with a duty field: output +1 while the phase is below duty, else -1. A duty of 0.5 is the square; slide it down toward 0.05 and the tone thins out into a nasal, reedy buzz, because you are changing which harmonics are present. The test pins the meaning of duty precisely -- at 0.25, exactly a quarter of the samples in one cycle are high:
const std = @import("std");
pub const Pulse = struct {
phase: f32 = 0.0,
increment: f32,
duty: f32 = 0.5, // fraction of the cycle spent high
pub fn init(freq: f32, sample_rate: u32) Pulse {
return .{ .increment = freq / @as(f32, @floatFromInt(sample_rate)) };
}
pub fn next(self: *Pulse) f32 {
const value: f32 = if (self.phase < self.duty) 1.0 else -1.0;
self.phase += self.increment;
if (self.phase >= 1.0) self.phase -= 1.0;
return value;
}
};
test "a duty of 0.25 spends a quarter of each cycle high" {
var p = Pulse.init(480.0, 48_000); // 100 samples per cycle
p.duty = 0.25;
var high: usize = 0;
for (0..100) |_| {
if (p.next() > 0.0) high += 1;
}
try std.testing.expectEqual(@as(usize, 25), high);
}
Exercise 2 -- detune two saws. Run two sawtooths a few cents apart (a cent is a hundredth of a semitone), sum them, and halve. Because the two waves have almost the same period but not quite, they slide slowly in and out of phase: where the peaks line up they reinforce, where they oppose they cancel, and that slow rise-and-fall of loudness is the beating you hear -- at a rate equal to the difference between the two frequencies. It is the whole reason a "supersaw" sounds fat and alive in stead of thin and static:
const std = @import("std");
/// Two saws a few cents apart, summed and halved -- the classic "supersaw" fatness.
pub const DetunedPair = struct {
a: Oscillator,
b: Oscillator,
pub fn init(freq: f32, cents: f32, sample_rate: u32) DetunedPair {
const ratio = std.math.pow(f32, 2.0, cents / 1200.0); // cents -> frequency ratio
var a = Oscillator.init(freq, sample_rate);
var b = Oscillator.init(freq * ratio, sample_rate);
a.waveform = .saw;
b.waveform = .saw;
return .{ .a = a, .b = b };
}
pub fn next(self: *DetunedPair) f32 {
return 0.5 * (self.a.next() + self.b.next());
}
};
test "summed detuned saws stay inside -1..1" {
var pair = DetunedPair.init(220.0, 7.0, 48_000); // 7 cents apart
for (0..4096) |_| {
const s = pair.next();
try std.testing.expect(s >= -1.0 and s <= 1.0);
}
}
Two values each in -1..1, averaged, can never leave -1..1 -- so the halving is not cosmetic, it is the thing that guarantees no clipping. Notice we reused the Oscillator from last episode wholesale; the "supersaw" is not a new oscillator, it is two old ones and a plus sign.
Exercise 3 -- a wavetable from your own shape. The naive saw buzzes because it carries an infinite stack of harmonics that fold back. So build one that cannot: add up only a handful of harmonics by hand -- sin(x) + sin(2x)/2 + sin(3x)/3 + ... -- and bake the result into a table at comptime (episode 9). Because nothing above the 16th harmonic exists in the table, there is nothing high enough to alias at moderate pitches, so it stays clean where the naive saw grits:
const std = @import("std");
/// One cycle of a band-limited saw: sum of harmonics (1/k)*sin(k*x), built at COMPILE time.
pub fn harmonicSawTable(comptime n: usize, comptime harmonics: usize) [n]f32 {
var t: [n]f32 = undefined;
for (0..n) |i| {
const x = @as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(n)) * 2.0 * std.math.pi;
var acc: f32 = 0.0;
var k: usize = 1;
while (k <= harmonics) : (k += 1) {
const kf = @as(f32, @floatFromInt(k));
acc += @sin(kf * x) / kf; // the k-th harmonic at 1/k amplitude
}
t[i] = acc;
}
return t;
}
const saw_table = harmonicSawTable(1024, 16); // 16 harmonics, computed by the compiler
Drive last episode's WavetableOsc from &saw_table and compare it by ear (or by a spectrum) against the naive ramp. The difference is night and day at high notes. This is really additive synthesis meeting wavetable synthesis -- we construct the band-limited shape from harmonics, then read it back cheaply. Right -- three loose ends tied. Now let us make the note breathe.
An oscillator drones; an envelope makes it a note
Here is the core idea, and it is almost embarrassingly simple: an envelope is just a number between 0.0 and 1.0 that changes over time, and you multiply the oscillator's output by it. When the envelope is 1.0 the note is at full volume; when it is 0.0 the note is silent; the shape of how it travels between those is what your ear reads as "pluck", "swell", "stab" or "pad".
The classic model, in synths since the 1960s, is ADSR -- four segments:
- Attack: the time to rise from
0.0(silence, the moment you press the key) up to1.0(peak). A short attack is a percussive snap; a long attack is a slow swell. - Decay: the time to fall from that peak down to the sustain level.
- Sustain: not a time but a level -- the volume the note holds at for as long as you keep the key down.
- Release: the time to fall from wherever it currently is back down to
0.0, once you let go of the key.
That last distinction trips up almost everyone: attack, decay and release are durations, but sustain is a level. A note can sit in the sustain stage for a tenth of a second or a full minute, entirely depending on how long you hold the key. That is why an envelope cannot be a fixed pre-computed curve -- it has to react to a gate signal (key down, key up) that arrives whenever the player decides.
The ADSR envelope as a state machine
Because the envelope reacts to events and moves through named stages, it is a state machine -- exactly the pattern from episode 33, and a perfect fit for a Zig tagged enum. Each sample, we look at which stage we are in, nudge the level toward that stage's target, and switch stages when we arrive. Modeling the stage as an enum means the switch is exhaustive: if I ever add a stage and forget to handle it, the compiler refuses to build. That is the whole safety argument for enums-as-states, and it is why I reach for one here in stead of a bare integer or a pile of booleans:
const std = @import("std");
pub const Stage = enum { idle, attack, decay, sustain, release };
pub const Adsr = struct {
stage: Stage = .idle,
level: f32 = 0.0, // current envelope value, 0..1
// Per-sample increments, precomputed from times and the sample rate.
attack_rate: f32,
decay_rate: f32,
sustain_level: f32,
release_rate: f32,
/// Convert a stage TIME in seconds into a per-sample step over a 0..1 span.
fn ratePerSample(seconds: f32, sample_rate: u32) f32 {
if (seconds <= 0.0) return 1.0; // instantaneous stage
return 1.0 / (seconds * @as(f32, @floatFromInt(sample_rate)));
}
pub fn init(a: f32, d: f32, s: f32, r: f32, sample_rate: u32) Adsr {
return .{
.attack_rate = ratePerSample(a, sample_rate),
.decay_rate = ratePerSample(d, sample_rate),
.sustain_level = std.math.clamp(s, 0.0, 1.0),
.release_rate = ratePerSample(r, sample_rate),
};
}
};
Notice how much work happens once in init: every stage time is converted from human-friendly seconds into a per-sample increment. The audio thread should never divide or call anything expensive per sample if it can help it, so we pay that cost up front. This is the same "hoist the constants" instinct from the oscillator's hot path -- pushed all the way into construction.
Now the gate and the per-sample tick. Pressing a key calls noteOn, which jumps us into attack; releasing calls noteOff, which jumps to release from wherever we are (this is the crucial part -- you can release a note mid-attack, and it must glide down from its current level, not snap):
pub fn noteOn(self: *Adsr) void {
self.stage = .attack;
}
pub fn noteOff(self: *Adsr) void {
if (self.stage != .idle) self.stage = .release;
}
/// Advance one sample and return the current envelope value (0..1).
pub fn next(self: *Adsr) f32 {
switch (self.stage) {
.idle => self.level = 0.0,
.attack => {
self.level += self.attack_rate;
if (self.level >= 1.0) {
self.level = 1.0;
self.stage = .decay;
}
},
.decay => {
self.level -= self.decay_rate;
if (self.level <= self.sustain_level) {
self.level = self.sustain_level;
self.stage = .sustain;
}
},
.sustain => self.level = self.sustain_level, // hold until noteOff
.release => {
self.level -= self.release_rate;
if (self.level <= 0.0) {
self.level = 0.0;
self.stage = .idle;
}
},
}
return self.level;
}
Read it stage by stage and there is nothing mysterious: each arm moves level toward that stage's target and, on arrival, transitions to the next stage. The idle stage is the resting state -- silent, and (importantly) the signal that a voice is free to be reused, which matters the moment you build polyphony. Linear segments like these are the honest starting point; real synths often use exponential curves (a decay that slows as it falls sounds more natural, because that is how physical things lose energy), and that is exactly one of tonight's exercises.
Wiring the envelope to the oscillator
A voice is an oscillator and an envelope glued together: ask the oscillator for a raw sample, ask the envelope for its current level, multiply. That multiply is the entire connection -- amplitude modulation at its most basic:
pub const Voice = struct {
osc: Oscillator,
env: Adsr,
pub fn noteOn(self: *Voice) void {
self.env.noteOn();
}
pub fn noteOff(self: *Voice) void {
self.env.noteOff();
}
pub fn next(self: *Voice) f32 {
return self.osc.next() * self.env.next(); // raw tone shaped by loudness
}
pub fn isFinished(self: *const Voice) bool {
return self.env.stage == .idle;
}
};
Drop Voice.next() into last episode's producer in place of the bare osc.next(), wire a key press to noteOn and a key release to noteOff, and the drone is gone -- now the note swells, holds and dies exactly as you shaped it. isFinished is the small but vital hook: a voice that has fallen back to idle produces pure silence and can be handed out to the next note, which is how a synth plays more than one thing at a time without an unbounded pile of voices. Having said that, we are still only shaping loudness. To shape tone -- to make a bright note go dark as it decays, the sound almost every analog synth is built on -- we need the other half of today: the filter.
Filters: carving harmonics with subtractive synthesis
The dominant style of synthesis for fifty years is subtractive: start with a harmonically rich waveform (our saw or square, which is stuffed with harmonics), then remove some of them with a filter to sculpt the tone. A low-pass filter lets frequencies below a cutoff through and attenuates the ones above it -- so sweeping the cutoff down on a saw gradually strips the top harmonics and the sound goes from bright and buzzy to dark and round. That cutoff sweep, especially with an envelope driving it, is the signature gesture of electronic music.
The simplest useful filter is the one-pole low-pass, and it is almost suspiciously small. Each sample, you move the output a little bit of the way from where it currently is toward the new input. That "little bit" is a coefficient derived from the cutoff frequency: a low cutoff means small steps (sluggish, so it cannot follow fast wiggles -- i.e. high frequencies -- which is exactly what "low-pass" means), a high cutoff means large steps:
const std = @import("std");
pub const OnePole = struct {
prev: f32 = 0.0, // last output (the filter's memory)
alpha: f32, // smoothing coefficient, 0..1
/// alpha from a cutoff frequency: the standard RC-filter mapping.
pub fn init(cutoff_hz: f32, sample_rate: u32) OnePole {
const dt = 1.0 / @as(f32, @floatFromInt(sample_rate));
const rc = 1.0 / (2.0 * std.math.pi * cutoff_hz);
return .{ .alpha = dt / (rc + dt) };
}
pub fn process(self: *OnePole, input: f32) f32 {
self.prev += self.alpha * (input - self.prev); // step toward the input
return self.prev;
}
};
That single line -- prev += alpha * (input - prev) -- is a genuine, working low-pass filter, and it is worth staring at until it clicks. When alpha is near 1.0 the output snaps almost instantly to the input (nothing is filtered, cutoff is high); when alpha is tiny the output crawls, smoothing out anything fast (a low cutoff). It is the same exponential-smoothing formula you would use to average a noisy sensor reading -- audio filtering and data smoothing are, at the bottom, the same maths. The one weakness: a one-pole rolls off gently (6 dB per octave) and has no resonance -- no emphasis right at the cutoff. For a synth that can actually sing, we want more.
A filter worth playing: the state-variable filter
The filter that made synthesizers expressive has a resonance control: a peak of emphasis right at the cutoff frequency that, turned up high, whistles and eventually self-oscillates. A compact, classic way to get one is the state-variable filter (the Chamberlin form), which is lovely because it computes low-pass, high-pass and band-pass outputs all at once from two little state registers, with independent cutoff and resonance:
const std = @import("std");
pub const Svf = struct {
low: f32 = 0.0,
band: f32 = 0.0,
f: f32, // frequency coefficient
q: f32, // damping (1/resonance): lower q = sharper peak
pub fn init(cutoff_hz: f32, resonance: f32, sample_rate: u32) Svf {
const sr = @as(f32, @floatFromInt(sample_rate));
// Chamberlin is stable while cutoff stays below ~sr/6; clamp to be safe.
const fc = @min(cutoff_hz, sr / 6.0);
return .{
.f = 2.0 * @sin(std.math.pi * fc / sr),
.q = 1.0 / @max(resonance, 0.5), // resonance >= 0.5 keeps it stable
};
}
/// Returns the low-pass output; high and band are available too.
pub fn process(self: *Svf, input: f32) f32 {
self.low += self.f * self.band;
const high = input - self.low - self.q * self.band;
self.band += self.f * high;
return self.low;
}
};
Three lines of update, two state registers, and you have the filter at the heart of countless classic synths. The q term is the resonance: it is the damping, so a smaller q means a sharper, louder peak at the cutoff (which is why I invert resonance to get it, and clamp so a careless caller cannot make it blow up). Sweep f with an ADSR envelope -- literally feed the envelope value into cutoff -- and you get the "wah", the "pluck", the bright-attack-into-dark-sustain that is the sound of subtractive synthesis. That is the same envelope we built above, pointed at tone in stead of loudness. One idea, two jobs.
Where Zig's type system and errors quietly help
None of this is complicated code, but it is code where a silently-wrong number becomes an audible defect -- a click, a blown-up filter, a note that never releases. Zig's insistence on the explicit is a real asset here. The exhaustive switch on Stage means a forgotten envelope stage is a compile error, not a stuck note. And when a filter takes user-facing parameters, Zig's error unions (episode 4) let us reject nonsense at the door in stead of returning a NaN that quietly poisons the whole audio buffer downstream:
const std = @import("std");
pub const FilterError = error{ CutoffTooLow, CutoffAboveNyquist };
/// Validate a cutoff before building a filter, rather than trusting the caller.
pub fn checkedCutoff(cutoff_hz: f32, sample_rate: u32) FilterError!f32 {
if (cutoff_hz <= 0.0) return error.CutoffTooLow;
const nyquist = @as(f32, @floatFromInt(sample_rate)) / 2.0;
if (cutoff_hz >= nyquist) return error.CutoffAboveNyquist;
return cutoff_hz;
}
test "cutoff validation rejects the impossible" {
try std.testing.expectError(error.CutoffTooLow, checkedCutoff(-20.0, 48_000));
try std.testing.expectError(error.CutoffAboveNyquist, checkedCutoff(30_000.0, 48_000));
try std.testing.expectEqual(@as(f32, 1000.0), try checkedCutoff(1000.0, 48_000));
}
The point is not that this particular check is profound -- it is that Zig makes the failure path visible and typed. A cutoff above Nyquist (episode 171) is physically meaningless; catching it as a named error at construction is far kinder than a filter that goes unstable three seconds into playback and screams. Nota bene: the audio callback itself must stay error-free and allocation-free, so this kind of validation belongs at setup time, on the control thread -- never inside the per-sample loop.
Testing envelopes and filters with no speaker
Just like the oscillator, both of these are pure, deterministic functions of their state, so we test the numbers and leave the sound card out of it entirely. An envelope has properties that must hold: attack really does reach the top, and after a release it really does return to exactly zero and go idle. Here is the full life-cycle in one test:
test "adsr rises to peak, sustains, and releases to silence" {
var env = Adsr.init(0.01, 0.01, 0.5, 0.01, 48_000); // 10ms stages, 0.5 sustain
env.noteOn();
// Run well past attack+decay: we must settle at the sustain level.
for (0..2000) |_| _ = env.next();
try std.testing.expectApproxEqAbs(@as(f32, 0.5), env.next(), 1e-3);
// Release, run past it: must land exactly on silence and go idle.
env.noteOff();
for (0..2000) |_| _ = env.next();
try std.testing.expectEqual(@as(f32, 0.0), env.next());
try std.testing.expectEqual(Stage.idle, env.stage);
}
A filter's defining property is just as checkable without ears: a low-pass must pass a DC (constant) input through untouched once it settles -- zero is very much a frequency below the cutoff -- while heavily attenuating something that flips every sample (the highest frequency a sampled system can hold, right at Nyquist). Feed it both and assert:
test "one-pole passes DC and attenuates the fastest wiggle" {
var lp = OnePole.init(1000.0, 48_000);
// A constant 1.0 must settle to ~1.0 (DC sails through a low-pass).
var y: f32 = 0.0;
for (0..10_000) |_| y = lp.process(1.0);
try std.testing.expectApproxEqAbs(@as(f32, 1.0), y, 1e-2);
// A +1,-1,+1,... input is the fastest signal; its output must be tiny.
var lp2 = OnePole.init(1000.0, 48_000);
var peak: f32 = 0.0;
for (0..10_000) |i| {
const x: f32 = if (i % 2 == 0) 1.0 else -1.0;
peak = @max(peak, @abs(lp2.process(x)));
}
try std.testing.expect(peak < 0.2); // strongly attenuated
}
These are the mistakes people actually ship: a filter coefficient with the wrong sign (DC drifts instead of settling), an envelope that overshoots 1.0 and clicks, a release that stops just shy of zero and leaves a faint tail. Every one of them is caught here, on the CPU, in microseconds, with no listening involved. This is episode 12's discipline made concrete for audio for the second episode running -- and it keeps paying off.
Performance: envelopes and filters on the hot path
Both an envelope and a filter run per sample, often once per voice per sample, so with a chord of voices this is squarely on the audio hot path (episode 170's commandment: the callback must always finish in time). A few habits keep them cheap:
- Precompute coefficients on the control thread. Every
@sin, division and reciprocal here happens ininit, never inprocess/next. When the player turns a cutoff knob, recomputefonce for that block -- not once per sample. - Keep the per-sample body branch-light. The envelope's
switchis unavoidable, but it is a cheap jump; the filters have no branches at all, just multiply-adds, which is exactly what a CPU (and a vectorizer) loves. - Watch the denormals. When a release tail decays toward zero, the numbers can get extremely small and slide into denormal floats, which on some CPUs are dramatically slower to compute. The pragmatic fix is a tiny flush: once the level is below something like
1e-6, snap it to0.0and go idle -- which our envelope already does at the end of release. - Measure before reaching for SIMD (episode 34). A single voice is trivially cheap; only when you are mixing dozens does a profiler earn you the right to vectorize a filter across voices. For the envelope's state machine, SIMD barely helps anyway -- the branchy filters and the oscillator are the better targets.
The structural theme is identical to last episode: do the expensive, human-facing maths once at setup, and let the per-sample loop be nothing but a handful of cheap arithmetic ops feeding the ring.
The same job in C, Rust and Go
The envelope-as-state-machine and the filter-as-difference-equation are universal -- the maths is the maths in every language. What differs is how safely each one lets you package the stage machine. In C, the ADSR is the same switch on an enum, but nothing forces you to handle every case -- forget the release arm and the compiler shrugs; you find out when a note hangs:
// C: same ADSR switch -- but a missing case is silently allowed.
float adsr_next(Adsr* e) {
switch (e->stage) {
case ATTACK:
e->level += e->attack_rate;
if (e->level >= 1.0f) { e->level = 1.0f; e->stage = DECAY; }
break;
case DECAY:
e->level -= e->decay_rate;
if (e->level <= e->sustain) { e->level = e->sustain; e->stage = SUSTAIN; }
break;
/* forget RELEASE here and gcc says nothing */
default: break;
}
return e->level;
}
Rust models the stage as an enum and matches on it, and -- like Zig -- the match is exhaustive, so a forgotten stage is a compile error. That is the same guarantee Zig gives, arrived at from the safety-first direction:
// Rust: an exhaustive match -- a forgotten stage won't compile.
fn next(&mut self) -> f32 {
match self.stage {
Stage::Attack => {
self.level += self.attack_rate;
if self.level >= 1.0 { self.level = 1.0; self.stage = Stage::Decay; }
}
Stage::Decay => { /* ... */ }
Stage::Sustain => self.level = self.sustain_level,
Stage::Release => { /* ... */ }
Stage::Idle => self.level = 0.0,
}
self.level
}
Go would use a small struct and an integer or typed constant for the stage, but its switch is not exhaustive-checked either, and its garbage collector is the real story on the audio thread: you engineer hard to keep every voice allocation-free so the GC never pauses mid-callback, the same pull-model glitch risk we designed around in episode 170. Where does Zig land? Our next() is as blunt and fast as the C, its switch is as safe as the Rust match, and coefficients bake at comptime or in init with no runtime tax and no collector to dodge. That is the trade this whole audio arc keeps demonstrating -- C's directness and Rust's safety, without having to pick one.
Exercises
Exponential envelope segments. Replace the linear attack/decay/release with exponential curves: in stead of adding a fixed rate, move the level a fraction of the remaining distance to the target each sample (
level += coeff * (target - level)), the same shape as the one-pole filter. Add a test that the decay from1.0toward a0.3sustain is monotonically falling and settles near0.3. Which sounds more natural to you, and why?An envelope-driven filter sweep. Give a
Voicea secondAdsrand use its output to modulate anSvfcutoff each sample (map the0..1envelope onto, say,200 Hz .. 6000 Hz). Play a saw through it with a fast attack and slow decay -- the classic "pluck". Write a test that the cutoff coefficient stays within its stable range across the whole sweep.A high-pass from the state-variable filter. The
Svfalready computes ahighvalue internally -- expose it. Then chain a low-pass and a high-pass to build a band-pass by hand, and test that a DC (constant) input is blocked by the high-pass (settles to ~0) while a mid-frequency tone passes. Compare your hand-built band-pass to the SVF's ownbandoutput.
What we learned
- An envelope is nothing but a
0..1number that changes over time, and you multiply the oscillator by it -- that single multiply is what turns a flat drone into a note that swells, holds and dies; - ADSR has three times (attack, decay, release) and one level (sustain), and because it reacts to a gate it must be a state machine -- a Zig tagged enum with an exhaustive
switch, so a forgotten stage cannot compile; - Subtractive synthesis starts from a harmonically rich saw or square and removes harmonics with a filter; a one-pole low-pass is a single line (
prev += alpha * (input - prev)), the very same exponential smoothing you would use on noisy data; - A state-variable filter adds the missing ingredient -- resonance -- giving independent cutoff and emphasis from two state registers, and sweeping its cutoff with an envelope is the signature electronic-music gesture;
- Zig's exhaustive switch and typed errors turn silent audio bugs into compile errors and named failures at setup time, while the per-sample loop stays branch-light and allocation-free on the hot path (measure first, episode 34);
- Envelopes and filters are pure, deterministic functions of their state, so you test the numbers, not the sound -- an attack-sustain-release life-cycle check and a DC-passes/high-frequency-attenuates check catch the real bugs with no speaker involved.
So the voice is complete: an oscillator for the raw tone, an envelope to shape its loudness, a filter to carve its harmonics. Press a key and it lives -- swells, holds, brightens, darkens, dies. But one voice is a solo. The moment you want a chord, or two players at once, or a note that keeps ringing while you start the next, you run head-first into the question of how many voices sound at once and how you share the mix between them without clipping the output to pieces. That is where we head next, and it is where a single voice grows into an instrument. Plenty still to build, and it only gets more fun from here.
Thanks for reading -- de groeten, en tot de volgende! ;-)