Learn Zig Series (#173) - Audio Mixing
What will I learn?
- Why summing is the whole secret of mixing -- and why that same summing is exactly what will blow your output past
-1..1and make it clip; - How to build a voice pool and a mixer from scratch, so a single note grows into real polyphony -- many notes ringing at once;
- The honest ways to keep a hot mix inside range: gain staging, headroom, and the difference between a brutal hard clip and a warm soft clip;
- How to allocate and retire voices safely, and why Zig's typed errors turn "ran out of voices" into a value you handle in stead of a crash;
- How to test a mixer with no sound card at all -- silence-in-silence-out, exact sums, clip bounds, and polyphony exhaustion;
- The performance rules for the mix loop, where SIMD (episode 19) actually earns its keep, and where C, Rust and Go land on the very same design.
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
Voice(oscillator plus envelope) and the filters from episode 172, the oscillator from episode 171, the PCM buffer from episode 169 and the real-time callback from episode 170; - Comfort with structs and enums (episode 6), comptime (episode 9), Zig's error handling (episode 4) and testing (episode 12);
- 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
- Learn Zig Series (#173) - Audio Mixing (this post)
Learn Zig Series (#173) - Audio Mixing
Last episode we finished a single voice -- 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. Play a chord and you need three notes at once; keep a note ringing while you start the next and you need two; drop in a drum hit under a pad and you need both at the same instant. Every one of those is the same question -- how do you get many sounds out of one speaker? The answer is almost insultingly simple, and it is the entire subject of today: you add them up. That is mixing. The subtlety is not in the adding; it is in what happens when the sum gets too big, and in how you manage a fixed pile of voices without allocating, crashing or clipping. Here we go!
But first, the three loose ends from the envelope-and-filter episode.
Solutions to Episode 172 Exercises
Exercise 1 -- exponential envelope segments. The linear envelope adds a fixed rate each sample. An exponential one instead moves a fraction of the remaining distance toward the target -- the exact same shape as the one-pole filter (level += coeff * (target - level)). It sounds more natural because physical things lose energy proportionally to how much they have left, not at a flat rate. The one wrinkle: an exponential curve asymptotes toward its target and technically never arrives, so for the attack we aim slightly past 1.0 and snap when we cross it:
const std = @import("std");
pub const Stage = enum { idle, attack, decay, sustain, release };
pub const ExpAdsr = struct {
stage: Stage = .idle,
level: f32 = 0.0,
attack_coeff: f32,
decay_coeff: f32,
sustain_level: f32,
release_coeff: f32,
/// A stage TIME in seconds -> a per-sample smoothing coefficient in 0..1.
fn coeff(seconds: f32, sample_rate: u32) f32 {
if (seconds <= 0.0) return 1.0; // instantaneous
return 1.0 - std.math.exp(-1.0 / (seconds * @as(f32, @floatFromInt(sample_rate))));
}
pub fn init(a: f32, d: f32, s: f32, r: f32, sample_rate: u32) ExpAdsr {
return .{
.attack_coeff = coeff(a, sample_rate),
.decay_coeff = coeff(d, sample_rate),
.sustain_level = std.math.clamp(s, 0.0, 1.0),
.release_coeff = coeff(r, sample_rate),
};
}
pub fn noteOn(self: *ExpAdsr) void { self.stage = .attack; }
pub fn noteOff(self: *ExpAdsr) void { if (self.stage != .idle) self.stage = .release; }
pub fn next(self: *ExpAdsr) f32 {
switch (self.stage) {
.idle => self.level = 0.0,
.attack => {
self.level += self.attack_coeff * (1.02 - self.level); // aim just past the top
if (self.level >= 1.0) { self.level = 1.0; self.stage = .decay; }
},
.decay => {
self.level += self.decay_coeff * (self.sustain_level - self.level);
if (self.level <= self.sustain_level + 1e-4) { self.level = self.sustain_level; self.stage = .sustain; }
},
.sustain => self.level = self.sustain_level,
.release => {
self.level += self.release_coeff * (0.0 - self.level);
if (self.level <= 1e-4) { self.level = 0.0; self.stage = .idle; }
},
}
return self.level;
}
};
test "exponential decay falls monotonically to the sustain level" {
var env = ExpAdsr.init(0.001, 0.05, 0.3, 0.1, 48_000);
env.noteOn();
for (0..300) |_| _ = env.next(); // let the very fast attack finish
var prev = env.next();
for (0..6000) |_| {
const cur = env.next();
try std.testing.expect(cur <= prev + 1e-6); // never rises through decay/sustain
prev = cur;
}
try std.testing.expectApproxEqAbs(@as(f32, 0.3), prev, 1e-2);
}
The key insight: exponential and linear differ only in the update line, but the exponential curve is self-similar -- it looks the same slowed down or sped up, which is why decays modelled this way sound like real instruments dying away.
Exercise 2 -- an envelope-driven filter sweep. Give a voice a second envelope whose only job is to move the filter's cutoff. Map its 0..1 output onto a frequency range and feed that into the Svf each sample. A fast attack and slow decay on that filter envelope is the classic "pluck": bright at the front, darkening as it holds:
const std = @import("std");
pub const SweptVoice = struct {
osc: Oscillator,
amp: Adsr, // shapes loudness
filt_env: Adsr, // shapes the cutoff
svf: Svf,
sample_rate: u32,
const min_hz: f32 = 200.0;
const max_hz: f32 = 6000.0;
pub fn noteOn(self: *SweptVoice) void { self.amp.noteOn(); self.filt_env.noteOn(); }
pub fn noteOff(self: *SweptVoice) void { self.amp.noteOff(); self.filt_env.noteOff(); }
pub fn next(self: *SweptVoice) f32 {
const e = self.filt_env.next(); // 0..1
const cutoff = min_hz + e * (max_hz - min_hz); // map onto Hz
self.svf = Svf.init(cutoff, 2.0, self.sample_rate); // recompute coefficients
const shaped = self.svf.process(self.osc.next());
return shaped * self.amp.next();
}
};
test "the swept cutoff never leaves the SVF stable range" {
const sr: u32 = 48_000;
const limit = @as(f32, @floatFromInt(sr)) / 6.0; // Chamberlin stability bound
var e = Adsr.init(0.001, 0.4, 0.0, 0.1, sr); // fast attack, slow decay
e.noteOn();
for (0..sr) |_| {
const env = e.next();
const cutoff = 200.0 + env * (6000.0 - 200.0);
try std.testing.expect(cutoff < limit); // 6000 < 8000, always safe
}
}
Nota bene: rebuilding the Svf every sample calls @sin every sample, which is exactly the per-sample expense episode 172 warned against. In real code you recompute the coefficient once per block (say every 32 or 64 samples) and let the sweep step in chunks -- the ear cannot tell, and the profiler thanks you. I do it per sample here only to keep the mapping obvious.
Exercise 3 -- a high-pass, and a band-pass by hand. The state-variable filter already computes a high output internally; we just stopped throwing it away. Return all three, then chain a low-pass into a high-pass to carve out a band -- everything below the high-pass cutoff and above the low-pass cutoff gets removed, leaving the middle:
const std = @import("std");
pub const Svf = struct {
low: f32 = 0.0,
band: f32 = 0.0,
f: f32,
q: f32,
pub fn init(cutoff_hz: f32, resonance: f32, sample_rate: u32) Svf {
const sr = @as(f32, @floatFromInt(sample_rate));
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) };
}
/// Now hands back all three outputs at once.
pub fn process(self: *Svf, input: f32) struct { low: f32, high: f32, band: f32 } {
self.low += self.f * self.band;
const high = input - self.low - self.q * self.band;
self.band += self.f * high;
return .{ .low = self.low, .high = high, .band = self.band };
}
};
test "high-pass blocks DC, and a chained band-pass passes a mid tone" {
var hp = Svf.init(500.0, 0.7, 48_000);
var y: f32 = 0.0;
for (0..20_000) |_| y = hp.process(1.0).high; // a constant input is pure DC
try std.testing.expectApproxEqAbs(@as(f32, 0.0), y, 1e-2); // and DC is blocked
var lp = Svf.init(2000.0, 0.7, 48_000);
var hp2 = Svf.init(300.0, 0.7, 48_000);
var peak: f32 = 0.0;
var phase: f32 = 0.0;
const inc = 1000.0 / 48_000.0; // a 1 kHz tone, comfortably inside the band
for (0..20_000) |_| {
const x = @sin(2.0 * std.math.pi * phase);
phase += inc;
if (phase >= 1.0) phase -= 1.0;
const banded = hp2.process(lp.process(x).low).high;
peak = @max(peak, @abs(banded));
}
try std.testing.expect(peak > 0.1); // the mid tone survives the band
}
The insight is that a band-pass is not a special filter -- it is a low-pass and a high-pass stacked, each throwing away one end. The SVF gives you its own band output for free, but building one by hand is the fastest way to feel why. Right -- three loose ends tied. Now let us make many voices sound at once.
Mixing is summing, and summing overflows
Here is the entire principle, and I promise it does not get more complicated than this: to hear two sounds at the same time, you add their samples together, sample for sample. Sound in the real world is just air pressure, and when two things vibrate the air near your ear at once, the pressures add. Digital audio is a faithful model of that -- so mixing a chord is literally a + b + c. That is it. The Voice from last episode produces a stream in -1.0 .. 1.0; add three such streams and you have a chord.
The catch stares right back at you from that sentence. Three signals each in -1.0 .. 1.0 can sum to anything in -3.0 .. 3.0. But your sound card, and the PCM format we built back in episode 169, only knows how to represent -1.0 .. 1.0 -- anything outside that gets clipped, sheared flat at the rails, which sounds like a nasty crackle-buzz. So the real work of a mixer is not the summing (trivial); it is keeping the sum inside the range the hardware can play, without making everything so quiet it disappears. That tension -- loud enough to hear, quiet enough not to clip -- is called gain staging, and every mixing console, DAW and game-audio engine on earth is, at bottom, a machine for managing it.
A voice pool: polyphony without allocation
Before we can mix voices we need somewhere to keep them. The rule from episode 170 still rules everything: the audio callback must never allocate, never lock, never block. So we do not grow a list of voices on the fly -- we pre-allocate a fixed pool at startup and hand slots out and back. A voice that has decayed to idle (that isFinished hook we built last time) is a free slot ready to be reused. This is the same fixed-capacity, no-surprises thinking behind the memory pools of episode 114, applied to sound.
I will make the pool a comptime-parameterised type (episode 14) so the voice count is baked into the type and the whole thing lives inline with no heap at all:
const std = @import("std");
/// A fixed pool of `max_voices` voices, summed into one output stream.
pub fn Mixer(comptime max_voices: usize) type {
return struct {
const Self = @This();
voices: [max_voices]Voice = undefined,
active: [max_voices]bool = [_]bool{false} ** max_voices,
master_gain: f32 = 1.0,
/// Start a note on the first free voice. Returns its index,
/// or a typed error if every voice is already busy.
pub fn noteOn(self: *Self, osc: Oscillator, env: Adsr) error{PolyphonyExhausted}!usize {
for (&self.active, 0..) |*busy, i| {
if (!busy.*) {
self.voices[i] = .{ .osc = osc, .env = env };
self.voices[i].noteOn();
busy.* = true;
return i;
}
}
return error.PolyphonyExhausted;
}
pub fn noteOff(self: *Self, index: usize) void {
self.voices[index].noteOff();
}
};
}
Notice noteOn returns error{PolyphonyExhausted}!usize -- an error union (episode 4). Running out of voices is a completely normal thing that happens the instant a player mashes more keys than the pool holds, and it is not a bug or a reason to crash. Making it a typed error means the caller is forced by the compiler to decide what to do: steal the oldest voice, drop the new note, or ignore it. Compare that to the C habit of returning -1 and hoping the caller checks -- here, the language will not let you forget.
The render loop: sum, scale, retire
Now the heart of it. For each output sample, walk every active voice, add its next sample to an accumulator, and write the accumulator (scaled by the master gain) to the buffer. Then, once per block and off the per-sample path, reap any voices that finished during this block and mark their slots free:
/// Sum every active voice into `out`, apply the master gain, then retire
/// finished voices. `out` is one channel's worth of samples for this block.
pub fn render(self: *Self, out: []f32) void {
for (out) |*sample| {
var acc: f32 = 0.0;
for (&self.voices, 0..) |*v, i| {
if (self.active[i]) acc += v.next();
}
sample.* = acc * self.master_gain;
}
// Reaping runs once per block, never inside the hot per-sample loop.
for (&self.voices, 0..) |*v, i| {
if (self.active[i] and v.isFinished()) self.active[i] = false;
}
}
The structure is deliberate. The inner double loop is the hot path -- samples times voices, every block, forever -- so it does nothing but a branch and an add. The bookkeeping (which slots became free) is pulled out of that loop and done once at the end, because checking isFinished every sample would be pure waste: a voice cannot un-finish mid-block. This is the same instinct as last episode's "do expensive work once at setup" -- here it is "do bookkeeping once per block".
Gain staging, headroom, and not clipping
Now back to the overflow problem, because acc * self.master_gain is where we either sound good or sound broken. There are three honest strategies, and grown-up audio uses all three together.
The first is headroom: simply do not run each voice at full blast. If you know you will mix up to eight voices, give each one a master_gain around 0.2 and the worst-case sum lands near 1.6 -- still occasionally over, but rarely, and gently. Leaving deliberate room below the ceiling is what "headroom" means, and it is why a professional mix meter usually sits well below the top.
The second is a clip as a last line of defence, for the moments a sum sneaks over anyway. The blunt option is a hard clip -- clamp to the rails. It is one instruction and it is honest, but it shears the waveform into a corner, and corners are rich in harmonics, so it sounds harsh and buzzy. The kinder option is a soft clip: bend the signal smoothly as it approaches the rails so it rounds off in stead of snapping. A tanh curve is the classic: near zero it is almost a straight line (quiet signals pass untouched), and it flattens gracefully toward +/-1 as things get loud, which the ear reads as warmth rather than damage:
const std = @import("std");
/// Hard clip: brute clamp to the rails. Cheap, honest, and audibly harsh.
pub fn hardClip(x: f32) f32 {
return std.math.clamp(x, -1.0, 1.0);
}
/// Soft clip: tanh saturation. Near zero it is ~linear (quiet stuff is untouched);
/// it rounds smoothly toward +/-1, so loud peaks "warm" instead of crackle.
pub fn softClip(x: f32) f32 {
return std.math.tanh(x);
}
The third strategy is a real limiter -- watching the signal's level over a short window and pulling the gain down before a peak arrives, then letting it back up. That is a whole episode of its own (it needs look-ahead and a smoothed gain-reduction envelope), so we leave it for the mini-project stretch ahead. For today, headroom plus a soft clip on the master bus is a mix that will not tear your speakers, and it is exactly what a small game or a chiptune engine ships with.
Where Zig's design quietly pays off
None of this is exotic code, but it is code where an off-by-one or a wrong sign becomes an audible defect the moment someone plays a chord. Zig helps in three concrete ways here. First, PolyphonyExhausted as a typed error means "too many notes" is a value you must handle, not a silent -1 that slips through. Second, the comptime max_voices bakes the pool size into the type, so there is no heap, no allocator on the audio thread, and the array bounds are known to the compiler. Third -- and this is the underrated one -- Zig's explicitness about floats means a NaN cannot sneak in unnoticed: if a caller ever hands a voice a bad frequency, we can reject it up front rather than let one poisoned sample turn the entire summed buffer into silence-or-static. A single NaN in the accumulator infects every voice's contribution downstream, because NaN + anything is NaN. Catching bad inputs at noteOn time, on the control thread, is far cheaper than debugging a buffer that mysteriously went quiet three seconds in.
Testing a mixer with no sound card
Just like the oscillator and the envelope, the mixer is a pure, deterministic function of its state -- so we test the numbers and never open a sound device. The properties that must hold are wonderfully concrete. Silence in, silence out. Two known voices sum to exactly their two contributions. The master gain scales the sum linearly. A soft clip keeps a hot signal inside range. And running out of voices produces a typed error, not a crash:
const std = @import("std");
test "an idle mixer renders pure silence" {
var mix = Mixer(8){};
var buf: [64]f32 = undefined;
mix.render(&buf);
for (buf) |s| try std.testing.expectEqual(@as(f32, 0.0), s);
}
test "polyphony exhaustion is a typed error, not a crash" {
var mix = Mixer(2){};
const osc = Oscillator.init(440.0, 48_000);
const env = Adsr.init(0.01, 0.01, 0.7, 0.2, 48_000);
_ = try mix.noteOn(osc, env);
_ = try mix.noteOn(osc, env);
try std.testing.expectError(error.PolyphonyExhausted, mix.noteOn(osc, env));
}
test "soft clip is near-linear when quiet and bounded when hot" {
try std.testing.expectApproxEqAbs(@as(f32, 0.1), softClip(0.1), 1e-2); // quiet: ~unchanged
try std.testing.expect(softClip(5.0) < 1.0 and softClip(5.0) > 0.99); // hot: bounded
try std.testing.expect(softClip(-5.0) > -1.0 and softClip(-5.0) < -0.99);
}
These are exactly the bugs people actually ship: a mixer that leaks a tiny DC offset when it should be silent (the first test catches it), a note-stealing path that panics under a fast trill (the second), a clipper with the wrong bound that either does nothing or crushes quiet signals (the third). Every one is caught here, on the CPU, in microseconds, with nobody listening. This is episode 12's discipline made concrete for audio for the third episode running -- and it keeps earning its keep.
Performance: the mix loop on the hot path
The render loop runs frames * voices times per block, forever, so it is the hottest code in the whole synth. A few habits keep it honest:
- Accumulate into the buffer, do the expensive stuff per block. Coefficient changes, voice reaping and gain recalculation all happen once per block (or per control tick), never per sample. The per-sample body should be a branch and an add, nothing more.
- Watch the inner branch. The
if (self.active[i])skip is cheap, but if your pool is mostly idle you are still walking dead slots. A tighter design keeps a small list of active indices and iterates only those -- fewer iterations, fewer branches. - This is where SIMD finally fits (episode 19). Summing is the textbook vectorizable operation: process four or eight samples of a voice at once with
@Vector, and add vectors of partial sums. Unlike the envelope's branchy state machine, the mix accumulator is pure multiply-add across contiguous data -- exactly what a CPU's vector units are built for. But measure first (episode 34): a handful of voices is trivially cheap, and SIMD only earns its complexity when you are mixing dozens. - Mind the denormals again. As every voice's release tail decays toward zero, its samples get extremely small and can slide into denormal floats, which some CPUs process dramatically slower. Our envelope already snaps to exactly
0.0and goes idle at the end of release, and the reaping step then stops summing that voice entirely -- so the tail cannot quietly wreck the mix loop's timing.
The theme is identical to the last two episodes: pay the human-facing maths once at setup or per block, and let the per-sample loop be nothing but cheap arithmetic feeding the ring buffer.
Real-world shape: from chiptune to a mixing console
The little Mixer above is, in miniature, exactly what larger systems are. A game-audio engine mixes dozens of sound-effect voices plus music into one master bus, with per-voice gain and stereo pan (a left/right balance is just two gains, one per channel). A DAW stacks the same idea into a tree: each track is a mini-mixer of its own clips, tracks feed group buses, buses feed the master -- summing all the way up. A hardware mixing console is that tree made of physical faders, each one a master_gain you can touch. The concepts do not change as you scale; there are simply more summing nodes, each with its own gain, and the same clipping question at every stage. Get the small one right and the big one is only more of it.
The same job in C, Rust and Go
The maths of mixing -- add, scale, clip -- is identical in every language. What differs is how safely each one lets you manage the pool and how confidently you can trust the audio thread. In C, the render loop is the same sum, but nothing stops you forgetting the clamp, and "out of voices" is a -1 you hope the caller checks:
// C: the same sum -- but the ceiling and the "no free voice" case are on you.
void mixer_render(Mixer* m, float* out, int frames) {
for (int n = 0; n < frames; n++) {
float acc = 0.0f;
for (int v = 0; v < m->count; v++)
if (m->active[v]) acc += voice_next(&m->voices[v]);
out[n] = acc * m->master_gain; /* nothing here keeps it in -1..1 */
}
}
Rust expresses the sum as an iterator fold and hands you clamp in the standard library, and its borrow checker tracks the &mut aliasing of the voice slice for you -- so a data race between the control thread and the audio thread is a compile error, not a heisenbug:
// Rust: an iterator fold, with the borrow checker guarding the &mut voices.
fn render(&mut self, out: &mut [f32]) {
for sample in out.iter_mut() {
let acc: f32 = self.voices.iter_mut()
.filter(|v| !v.is_finished())
.map(|v| v.next())
.sum();
*sample = (acc * self.master_gain).clamp(-1.0, 1.0);
}
}
Go writes the loop just as cleanly, but the garbage collector is the real story on the audio thread: you engineer hard to keep the whole voice pool allocation-free so the GC never pauses mid-callback -- the same pull-model glitch risk we designed around in episode 170:
// Go: clean to write, but the GC is the audio-thread hazard to engineer around.
func (m *Mixer) Render(out []float32) {
for n := range out {
var acc float32
for i := range m.voices {
if m.active[i] {
acc += m.voices[i].Next()
}
}
out[n] = acc * m.masterGain
}
}
Where does Zig land? Our render loop is as blunt and fast as the C, "out of voices" is as type-safe as the Rust Result, the pool bakes at comptime with no allocator to dodge and no collector to pause, and the whole thing stays explicit about every float that flows through it. That is the trade this entire audio arc keeps demonstrating -- C's directness and Rust's safety, without having to pick one.
Exercises
Per-voice gain and stereo pan. Give each voice its own
gainand apanin-1..1, and changerenderto fill an interleaved stereo buffer (episode 169): a pan of-1sends the voice fully left,+1fully right,0equally to both. Use the constant-power law (left = cos(theta),right = sin(theta)forthetafrom0topi/2) so the total loudness stays constant as a voice sweeps across the stereo field. Add a test that a centred voice puts equal energy in both channels.Voice stealing. When
noteOnfinds the pool full, in stead of returningPolyphonyExhausted, steal the quietest voice (lowest current envelope level) and restart it for the new note. Add a test that a full mixer still accepts a new note, and that the stolen slot is the one that was closest to silence.A block-based soft-clip limiter. Scan each rendered block for its peak; if the peak exceeds
1.0, compute a gain that would bring it exactly to1.0and apply that gain smoothly (interpolated across the block, not snapped) before writing out. Compare it by ear against the per-samplesoftClip, and write a test that no output sample in a deliberately-overdriven block exceeds1.0.
What we learned
- Mixing is summing -- to hear many sounds at once you add their samples, because that is literally what air pressure does; the hard part is never the adding, it is keeping the sum inside
-1..1; - A voice pool gives you polyphony without allocation: pre-allocate a fixed pile of voices, hand out
idleslots and reap finished ones once per block, never on the per-sample path; - Gain staging is the whole game -- use headroom so sums rarely overflow, a soft clip (
tanh) as a warm last defence in stead of a harsh hard clip, and a real limiter when you need more; - Zig's typed errors make "out of voices" a value you must handle, comptime bakes the pool with no heap on the audio thread, and explicit floats keep a stray
NaNfrom silently poisoning the entire summed buffer; - You test the numbers, not the sound: silence-in-silence-out, exact sums, gain linearity, clip bounds and polyphony exhaustion all fall out as deterministic checks with no speaker in sight;
- The mix loop is the hottest code in the synth, so keep the per-sample body a branch-and-an-add, do bookkeeping per block, mind the denormal tails, and reach for SIMD only once a profiler says the voice count earns it.
So the solo is a chord now: many voices, summed, gain-staged, kept inside the rails. An oscillator, an envelope, a filter, and a mixer -- that is a real, playable instrument, from first principles, with no library doing the interesting part for us. But a chord you play by hand is still you pressing keys. The moment you want the machine to play itself -- notes arriving on a clock, from a keyboard, from a file, from another instrument entirely -- you need a way to describe "this note, this loud, right now" as data that flows between devices. That is the language every synth and DAW on earth already speaks, and it is where we head next. Plenty still to build, and it only gets more fun from here.
Bedankt voor het lezen, en tot de volgende keer! ;-)